feat:批量操作与插件排序(iss#8749)#8774
Conversation
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- The three batch operation handlers (
batchEnablePlugins,batchDisablePlugins,batchUninstallPlugins) share a lot of nearly identical Promise handling and toast logic; consider extracting a generic helper to reduce duplication and keep behavior consistent when you tweak one of them. - Instead of storing a callback function in the reactive
batchConfirmDialog.onConfirm, you might makeconfirmBatchOperationbranch onbatchConfirmDialog.operationand call the appropriate handler; this avoids keeping arbitrary closures in reactive state and makes the flow easier to follow. - In
sortedInstalledPlugins, the pinned/unpinned split is implemented twice (default and non-default branches); consider extracting the "float pinned to top by pin order" step into a small helper to simplify the computed and make the pinning behavior easier to reason about.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The three batch operation handlers (`batchEnablePlugins`, `batchDisablePlugins`, `batchUninstallPlugins`) share a lot of nearly identical Promise handling and toast logic; consider extracting a generic helper to reduce duplication and keep behavior consistent when you tweak one of them.
- Instead of storing a callback function in the reactive `batchConfirmDialog.onConfirm`, you might make `confirmBatchOperation` branch on `batchConfirmDialog.operation` and call the appropriate handler; this avoids keeping arbitrary closures in reactive state and makes the flow easier to follow.
- In `sortedInstalledPlugins`, the pinned/unpinned split is implemented twice (default and non-default branches); consider extracting the "float pinned to top by pin order" step into a small helper to simplify the computed and make the pinning behavior easier to reason about.
## Individual Comments
### Comment 1
<location path="dashboard/src/components/shared/ExtensionCard.vue" line_range="244-250" />
<code_context>
</p>
<template v-if="!marketMode">
+ <v-checkbox
+ :model-value="selected"
+ density="compact"
+ hide-details
+ color="primary"
+ class="extension-checkbox-inline"
+ @click.stop="emit('select')"
+ @update:model-value="emit('select')"
+ />
</code_context>
<issue_to_address>
**issue (bug_risk):** Clicking the checkbox will likely emit the `select` event twice, causing a double-toggle.
Because the checkbox uses both `@click.stop="emit('select')"` and `@update:model-value="emit('select')"`, a single user click will invoke `emit('select')` twice in Vuetify (once for each handler), which cancels the parent’s toggle logic. Removing one handler—ideally keeping only `@update:model-value` for state changes—will avoid the double emission.
</issue_to_address>
### Comment 2
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="531" />
<code_context>
+ }
+ };
+
+ const batchEnablePlugins = async () => {
+ const names = [...selectedPluginNames.value];
+ if (names.length === 0) return;
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting shared helpers for batch operations, selection management, and pinning/sorting so the new features stay but the control flow and duplication are reduced.
You can keep all the new features but reduce the complexity/duplication by extracting a few small helpers.
### 1. Unify batch operations
`batchEnablePlugins`, `batchDisablePlugins`, and `batchUninstallPlugins` share a lot of flow. You can centralize the Promise/flags/toast logic into a single helper and keep each operation’s differences declarative:
```js
const runBatchOperation = async ({
getNames,
callApi,
onAfterSuccess = async () => {},
onAfterAll = async () => {},
successMessage,
}) => {
const names = getNames();
if (!names.length) return;
batchOperationInProgress.value = true;
const results = await Promise.allSettled(names.map(callApi));
const succeeded = [];
const failed = [];
results.forEach((result, index) => {
const name = names[index];
if (result.status === "fulfilled" && result.value.data?.status !== "error") {
succeeded.push(name);
} else {
const errorMsg =
result.status === "fulfilled"
? result.value.data?.message
: result.reason?.message;
failed.push({ name, error: errorMsg });
}
});
batchOperationInProgress.value = false;
await onAfterSuccess({ names, succeeded, failed });
await onAfterAll({ names, succeeded, failed });
if (failed.length > 0) {
toast(
`${tm("batch.results.title")}: ${tm("batch.results.summary", {
succeeded: succeeded.length,
failed: failed.length,
})}`,
"warning",
);
} else {
toast(successMessage, "success");
}
};
```
Then each batch method becomes much smaller:
```js
const batchEnablePlugins = () =>
runBatchOperation({
getNames: () => [...selectedPluginNames.value],
callApi: (name) => axios.post("/api/plugin/on", { name }),
onAfterSuccess: async () => {
await getExtensions({ withLoading: false });
await checkAndPromptConflicts();
},
onAfterAll: async () => {
deselectAllPlugins();
},
successMessage:
tm("batch.results.title") + " — " + tm("messages.updateSuccess").replace("!", ""),
});
const batchDisablePlugins = () =>
runBatchOperation({
getNames: () => [...selectedPluginNames.value],
callApi: (name) => axios.post("/api/plugin/off", { name }),
onAfterSuccess: async () => {
await getExtensions({ withLoading: false });
},
onAfterAll: async () => {
deselectAllPlugins();
},
successMessage: tm("batch.results.title") + " — OK",
});
const batchUninstallPlugins = () =>
runBatchOperation({
getNames: () =>
[...selectedPluginNames.value].filter((name) => {
const ext = filteredPlugins.value.find((p) => p?.name === name);
return ext && !ext.reserved;
}),
callApi: (name) =>
axios.post("/api/plugin/uninstall", {
name,
delete_config: batchDeleteConfig.value,
delete_data: batchDeleteData.value,
}),
onAfterSuccess: async () => {
batchDeleteConfig.value = false;
batchDeleteData.value = false;
await getExtensions({ withLoading: false });
},
onAfterAll: async () => {
exitBatchSelectionMode();
},
successMessage: tm("messages.deleteSuccess"),
});
```
This keeps all behavior but eliminates most duplicated control flow.
### 2. Simplify selection helpers
There are multiple places that reset `selectedPluginNames` and batch mode. A tiny `clearSelection` helper can reduce repetition and clarify intent:
```js
const clearSelection = () => {
selectedPluginNames.value = new Set();
};
const toggleBatchSelectionMode = () => {
batchSelectionMode.value = !batchSelectionMode.value;
if (!batchSelectionMode.value) clearSelection();
};
const exitBatchSelectionMode = () => {
batchSelectionMode.value = false;
clearSelection();
};
const deselectAllPlugins = clearSelection;
```
You keep the public API the same, but the logic is centralized and less error-prone.
### 3. Extract sorting & pinning helpers
`sortedInstalledPlugins` mixes filtering, pinning, and multiple comparators. You can pull out pinning and comparators to flatten the computed:
```js
const applyPinning = (plugins) => {
const pinnedNames = new Set(pinnedExtensionNames.value);
if (!pinnedNames.size) return plugins;
const pinned = [];
const unpinned = [];
for (const p of plugins) {
(p && pinnedNames.has(p.name) ? pinned : unpinned).push(p);
}
pinned.sort((a, b) => {
const aIdx = pinnedExtensionNames.value.indexOf(a?.name);
const bIdx = pinnedExtensionNames.value.indexOf(b?.name);
return aIdx - bIdx;
});
return [...pinned, ...unpinned];
};
const compareByName = (a, b) =>
normalizeStr(a?.display_name || a?.name || "").localeCompare(
normalizeStr(b?.display_name || b?.name || ""),
undefined,
{ sensitivity: "base" },
);
const compareByAuthor = (a, b) => {
const getAuthor = (ext) => {
const author = ext?.author;
if (Array.isArray(author)) return author.join(", ").toLowerCase();
return String(author || "").toLowerCase();
};
return getAuthor(a).localeCompare(getAuthor(b));
};
const compareByActivated = (a, b) =>
Number(!!b?.activated) - Number(!!a?.activated);
const compareByUpdateStatus = (a, b) =>
Number(!!b?.has_update) - Number(!!a?.has_update);
const applySortOrder = (result, order) =>
order === "desc" ? -result : result;
```
Then `sortedInstalledPlugins` becomes:
```js
const sortedInstalledPlugins = computed(() => {
const plugins = [...filteredPlugins.value];
if (installedSortBy.value === "default") {
return applyPinning(plugins);
}
const { value: sortBy } = installedSortBy;
const { value: sortOrder } = installedSortOrder;
const comparatorMap = {
name: compareByName,
author: compareByAuthor,
activated: compareByActivated,
updateStatus: compareByUpdateStatus,
};
const comparator = comparatorMap[sortBy];
if (comparator) {
plugins.sort((a, b) => applySortOrder(comparator(a, b), sortOrder));
}
return applyPinning(plugins);
});
```
This keeps the same behavior (including default vs explicit sort) but separates concerns and removes duplicated pinning logic.
### 4. Drop `pinnedExtensionOrder` Map if you don’t need it
If pin order is always the array order, you can avoid the extra computed `Map` and just use `indexOf` for sorting and a simple presence check:
```js
// remove pinnedExtensionOrder computed
const isPinnedExtension = (extension) => {
const name = extension?.name;
return !!name && pinnedExtensionNames.value.includes(name);
};
// applyPinning (above) uses `indexOf` instead of order Map
```
If perf becomes a concern, you could still build a `Set` or `Map` locally inside `applyPinning` instead of keeping a global computed, which makes the state shape simpler to reason about.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| <v-checkbox | ||
| :model-value="selected" | ||
| density="compact" | ||
| hide-details | ||
| color="primary" | ||
| class="extension-checkbox-inline" | ||
| @click.stop="emit('select')" |
There was a problem hiding this comment.
issue (bug_risk): Clicking the checkbox will likely emit the select event twice, causing a double-toggle.
Because the checkbox uses both @click.stop="emit('select')" and @update:model-value="emit('select')", a single user click will invoke emit('select') twice in Vuetify (once for each handler), which cancels the parent’s toggle logic. Removing one handler—ideally keeping only @update:model-value for state changes—will avoid the double emission.
| } | ||
| }; | ||
|
|
||
| const batchEnablePlugins = async () => { |
There was a problem hiding this comment.
issue (complexity): Consider extracting shared helpers for batch operations, selection management, and pinning/sorting so the new features stay but the control flow and duplication are reduced.
You can keep all the new features but reduce the complexity/duplication by extracting a few small helpers.
1. Unify batch operations
batchEnablePlugins, batchDisablePlugins, and batchUninstallPlugins share a lot of flow. You can centralize the Promise/flags/toast logic into a single helper and keep each operation’s differences declarative:
const runBatchOperation = async ({
getNames,
callApi,
onAfterSuccess = async () => {},
onAfterAll = async () => {},
successMessage,
}) => {
const names = getNames();
if (!names.length) return;
batchOperationInProgress.value = true;
const results = await Promise.allSettled(names.map(callApi));
const succeeded = [];
const failed = [];
results.forEach((result, index) => {
const name = names[index];
if (result.status === "fulfilled" && result.value.data?.status !== "error") {
succeeded.push(name);
} else {
const errorMsg =
result.status === "fulfilled"
? result.value.data?.message
: result.reason?.message;
failed.push({ name, error: errorMsg });
}
});
batchOperationInProgress.value = false;
await onAfterSuccess({ names, succeeded, failed });
await onAfterAll({ names, succeeded, failed });
if (failed.length > 0) {
toast(
`${tm("batch.results.title")}: ${tm("batch.results.summary", {
succeeded: succeeded.length,
failed: failed.length,
})}`,
"warning",
);
} else {
toast(successMessage, "success");
}
};Then each batch method becomes much smaller:
const batchEnablePlugins = () =>
runBatchOperation({
getNames: () => [...selectedPluginNames.value],
callApi: (name) => axios.post("/api/plugin/on", { name }),
onAfterSuccess: async () => {
await getExtensions({ withLoading: false });
await checkAndPromptConflicts();
},
onAfterAll: async () => {
deselectAllPlugins();
},
successMessage:
tm("batch.results.title") + " — " + tm("messages.updateSuccess").replace("!", ""),
});
const batchDisablePlugins = () =>
runBatchOperation({
getNames: () => [...selectedPluginNames.value],
callApi: (name) => axios.post("/api/plugin/off", { name }),
onAfterSuccess: async () => {
await getExtensions({ withLoading: false });
},
onAfterAll: async () => {
deselectAllPlugins();
},
successMessage: tm("batch.results.title") + " — OK",
});
const batchUninstallPlugins = () =>
runBatchOperation({
getNames: () =>
[...selectedPluginNames.value].filter((name) => {
const ext = filteredPlugins.value.find((p) => p?.name === name);
return ext && !ext.reserved;
}),
callApi: (name) =>
axios.post("/api/plugin/uninstall", {
name,
delete_config: batchDeleteConfig.value,
delete_data: batchDeleteData.value,
}),
onAfterSuccess: async () => {
batchDeleteConfig.value = false;
batchDeleteData.value = false;
await getExtensions({ withLoading: false });
},
onAfterAll: async () => {
exitBatchSelectionMode();
},
successMessage: tm("messages.deleteSuccess"),
});This keeps all behavior but eliminates most duplicated control flow.
2. Simplify selection helpers
There are multiple places that reset selectedPluginNames and batch mode. A tiny clearSelection helper can reduce repetition and clarify intent:
const clearSelection = () => {
selectedPluginNames.value = new Set();
};
const toggleBatchSelectionMode = () => {
batchSelectionMode.value = !batchSelectionMode.value;
if (!batchSelectionMode.value) clearSelection();
};
const exitBatchSelectionMode = () => {
batchSelectionMode.value = false;
clearSelection();
};
const deselectAllPlugins = clearSelection;You keep the public API the same, but the logic is centralized and less error-prone.
3. Extract sorting & pinning helpers
sortedInstalledPlugins mixes filtering, pinning, and multiple comparators. You can pull out pinning and comparators to flatten the computed:
const applyPinning = (plugins) => {
const pinnedNames = new Set(pinnedExtensionNames.value);
if (!pinnedNames.size) return plugins;
const pinned = [];
const unpinned = [];
for (const p of plugins) {
(p && pinnedNames.has(p.name) ? pinned : unpinned).push(p);
}
pinned.sort((a, b) => {
const aIdx = pinnedExtensionNames.value.indexOf(a?.name);
const bIdx = pinnedExtensionNames.value.indexOf(b?.name);
return aIdx - bIdx;
});
return [...pinned, ...unpinned];
};
const compareByName = (a, b) =>
normalizeStr(a?.display_name || a?.name || "").localeCompare(
normalizeStr(b?.display_name || b?.name || ""),
undefined,
{ sensitivity: "base" },
);
const compareByAuthor = (a, b) => {
const getAuthor = (ext) => {
const author = ext?.author;
if (Array.isArray(author)) return author.join(", ").toLowerCase();
return String(author || "").toLowerCase();
};
return getAuthor(a).localeCompare(getAuthor(b));
};
const compareByActivated = (a, b) =>
Number(!!b?.activated) - Number(!!a?.activated);
const compareByUpdateStatus = (a, b) =>
Number(!!b?.has_update) - Number(!!a?.has_update);
const applySortOrder = (result, order) =>
order === "desc" ? -result : result;Then sortedInstalledPlugins becomes:
const sortedInstalledPlugins = computed(() => {
const plugins = [...filteredPlugins.value];
if (installedSortBy.value === "default") {
return applyPinning(plugins);
}
const { value: sortBy } = installedSortBy;
const { value: sortOrder } = installedSortOrder;
const comparatorMap = {
name: compareByName,
author: compareByAuthor,
activated: compareByActivated,
updateStatus: compareByUpdateStatus,
};
const comparator = comparatorMap[sortBy];
if (comparator) {
plugins.sort((a, b) => applySortOrder(comparator(a, b), sortOrder));
}
return applyPinning(plugins);
});This keeps the same behavior (including default vs explicit sort) but separates concerns and removes duplicated pinning logic.
4. Drop pinnedExtensionOrder Map if you don’t need it
If pin order is always the array order, you can avoid the extra computed Map and just use indexOf for sorting and a simple presence check:
// remove pinnedExtensionOrder computed
const isPinnedExtension = (extension) => {
const name = extension?.name;
return !!name && pinnedExtensionNames.value.includes(name);
};
// applyPinning (above) uses `indexOf` instead of order MapIf perf becomes a concern, you could still build a Set or Map locally inside applyPinning instead of keeping a global computed, which makes the state shape simpler to reason about.
There was a problem hiding this comment.
Code Review
This pull request introduces batch operations (enable, disable, and uninstall) and advanced sorting/pinning features for installed plugins in the dashboard. It removes a large auto-generated MDI subset CSS file, updates the ExtensionCard component to support selection states, adds localization strings, and implements the frontend logic for batch selection, confirmation dialogs, and API requests. The review feedback highlights several critical issues: a duplicate event emission on the selection checkbox in ExtensionCard.vue that causes selection toggles to cancel themselves out; incorrect usage of search-filtered plugin lists (filteredPlugins) instead of the full installed list (filteredExtensions) during batch confirmation and uninstallation (which leads to incorrect counts or skipped plugins if the search query changes); and a reversed sorting order logic for the updateStatus sorting option.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| <v-checkbox | ||
| :model-value="selected" | ||
| density="compact" | ||
| hide-details | ||
| color="primary" | ||
| class="extension-checkbox-inline" | ||
| @click.stop="emit('select')" | ||
| @update:model-value="emit('select')" | ||
| /> |
There was a problem hiding this comment.
在 v-checkbox 上同时绑定了 @click.stop="emit('select')" 和 @update:model-value="emit('select')"。当用户点击复选框时,这两个事件都会被触发,导致 select 事件被发送两次。由于 togglePluginSelection 是通过取反(Set 的 add/delete)来切换选中状态的,触发两次会导致选中状态保持不变或表现异常。\n\n建议将 @click.stop 修改为仅阻止事件冒泡,而不触发 emit,让 @update:model-value 统一处理选中状态的更新。
<v-checkbox\n :model-value="selected"\n density="compact"\n hide-details\n color="primary"\n class="extension-checkbox-inline"\n @click.stop\n @update:model-value="emit('select')"\n />
| const plugins = filteredPlugins.value.filter((p) => | ||
| selected.includes(p?.name), | ||
| ); |
There was a problem hiding this comment.
在 showBatchConfirm 中,过滤已选择插件时使用的是 filteredPlugins.value。由于 filteredPlugins 是受搜索框输入过滤的,如果用户在搜索过滤状态下进行了选择,然后更改了搜索内容,那么那些已被选择但当前被搜索过滤隐藏的插件将无法在 showBatchConfirm 中被正确统计(例如系统插件跳过数量等)。\n\n建议使用 filteredExtensions.value(包含所有已安装插件,不受搜索过滤影响)来获取完整的已选择插件信息。
const plugins = filteredExtensions.value.filter((p) =>\n selected.includes(p?.name),\n );| const batchUninstallPlugins = async () => { | ||
| // 跳过保留插件 | ||
| const names = [...selectedPluginNames.value].filter((name) => { | ||
| const ext = filteredPlugins.value.find((p) => p?.name === name); |
There was a problem hiding this comment.
在 batchUninstallPlugins 中,过滤保留插件时使用的是 filteredPlugins.value。这会导致如果用户在搜索过滤状态下选择了某些插件,然后更改了搜索内容,那些已被选择外但当前被搜索过滤隐藏的插件在批量卸载时会被错误地过滤掉(无法被卸载)。\n\n建议使用 filteredExtensions.value(包含所有已安装插件,不受搜索过滤影响)来查找并过滤保留插件。
| const ext = filteredPlugins.value.find((p) => p?.name === name); | |
| const ext = filteredExtensions.value.find((p) => p?.name === name); |
| case "updateStatus": | ||
| plugins.sort((a, b) => { | ||
| const result = Number(!!b?.has_update) - Number(!!a?.has_update); | ||
| return installedSortOrder.value === "desc" ? -result : result; |
There was a problem hiding this comment.
在 updateStatus 排序逻辑中,升降序的返回值方向反了。当 installedSortOrder 为 "desc"(降序)时,返回了 -result(升序,无更新的排在前面);而为 "asc"(升序)时,返回了 result(降序,有更新的排在前面)。这与 activated 排序逻辑不一致,且不符合用户的直觉。\n\n建议将 installedSortOrder.value === "desc" ? -result : result 修改为 installedSortOrder.value === "asc" ? -result : result。
| return installedSortOrder.value === "desc" ? -result : result; | |
| return installedSortOrder.value === "asc" ? -result : result; |
Modifications / 改动点
已安装插件页面支持排序与批量操作(Closes #8749)
为已安装插件页面新增排序控件和多选批量操作功能,方便插件数量较多时快速管理和操作。
排序功能:
PluginSortControl组件)批量操作功能:
Promise.allSettled并行调用单个 API,操作完成后展示成功/失败数通知改动文件:
src/views/extension/useExtensionPage.jsinstalledSortBy/installedSortOrder排序状态InstalledPluginsTab.vue移入此文件(pinnedExtensionNames、isPinnedExtension、togglePinnedExtension等)sortedInstalledPluginscomputed(含 5 种排序 + 置顶浮动逻辑)batchSelectionMode、selectedPluginNames、selectAllPlugins、deselectAllPlugins、invertSelection、batchEnablePlugins、batchDisablePlugins、batchUninstallPlugins等)batchConfirmDialog、batchDeleteConfig、batchDeleteData)src/views/extension/InstalledPluginsTab.vuePluginSortControl排序控件selectable/selected/@selectsrc/components/shared/ExtensionCard.vueselectable、selectedprops 和selectemitextension-card--selected)src/i18n/locales/{zh-CN,en-US,ru-RU}/features/extension.jsonsort.activated:启用状态排序选项batch.*:多选/批量操作相关文案(选择、全选、取消全选、反选、批量启用/禁用/卸载、确认对话框、结果通知)Screenshots or Test Results / 运行截图或测试结果
验证步骤:
多选模式实机展示:

排序展示:

Checklist / 检查清单
😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
/ 我的更改经过了良好的测试,并已在上方提供了"验证步骤"和"运行截图"。
🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in
requirements.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.toml文件相应位置。😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
Summary by Sourcery
Add sorting controls and batch operations to the installed plugins page to improve plugin management at scale.
New Features:
Enhancements:
Documentation: