feat: add sort and batch operations to installed plugins page#8856
feat: add sort and batch operations to installed plugins page#8856AnxForever wants to merge 3 commits into
Conversation
- Add sorting controls (name, author, updated time, update status) to installed plugins tab - Add multi-select mode with checkbox overlay on plugin cards - Add batch operations toolbar (enable, disable, uninstall) with confirmation dialog - System/reserved plugins are excluded from selection - Reuse existing PluginSortControl component for consistency with market tab - Add i18n keys for zh-CN and en-US
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The
sortedInstalledPluginscomputed runs two sorts (one for the selected sort mode and another for pinned order) which will likely override the first sort for non‑pinned items; consider using a single comparator that enforces pinned-first but otherwise respects the chosen sort field so the secondary sort doesn’t undo the primary ordering. - The batch operations (
batchEnable,batchDisable,requestBatchUninstall,executeBatchUninstall) repeatedly recomputefilteredPlugins.filter(p => selectedPluginNames.has(p.name)); you could extract a shared helper (e.g.,getSelectedPlugins({ onlyActivated, onlyDeactivated } )) to simplify the code and avoid subtle inconsistencies between these paths.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The `sortedInstalledPlugins` computed runs two sorts (one for the selected sort mode and another for pinned order) which will likely override the first sort for non‑pinned items; consider using a single comparator that enforces pinned-first but otherwise respects the chosen sort field so the secondary sort doesn’t undo the primary ordering.
- The batch operations (`batchEnable`, `batchDisable`, `requestBatchUninstall`, `executeBatchUninstall`) repeatedly recompute `filteredPlugins.filter(p => selectedPluginNames.has(p.name))`; you could extract a shared helper (e.g., `getSelectedPlugins({ onlyActivated, onlyDeactivated } )`) to simplify the code and avoid subtle inconsistencies between these paths.
## Individual Comments
### Comment 1
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="245" />
<code_context>
+ selectModeActive.value = false;
+ };
+
+ const batchEnable = async () => {
+ const targets = filteredPlugins.value.filter(
+ (p) => selectedPluginNames.value.has(p.name) && !p.activated,
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the shared batch operation logic into a helper and deriving selection state from `selectedPluginNames` to avoid duplicated, error-prone control flow.
You can meaningfully reduce complexity here by (1) extracting a generic batch helper and (2) deriving selection state instead of mutating it in multiple places.
### 1. Extract a generic batch helper
`batchEnable`, `batchDisable`, and `executeBatchUninstall` share the same pattern (filter → loop → success/fail counts → toast → clearSelection).
You can centralize this into a small helper while keeping all behavior intact:
```ts
const runBatchAction = async ({
filter,
action,
emptyMessageKey,
partialMessageKey,
successMessageKey,
}: {
filter: (p: Plugin) => boolean;
action: (p: Plugin) => Promise<void>;
emptyMessageKey: string;
partialMessageKey: string;
successMessageKey: string;
}) => {
const targets = filteredPlugins.value.filter(
(p) => selectedPluginNames.value.has(p.name) && filter(p),
);
if (targets.length === 0) {
toast(tm(emptyMessageKey), "info");
return;
}
let successCount = 0;
let failCount = 0;
for (const ext of targets) {
try {
await action(ext);
successCount += 1;
} catch (e) {
console.error(`Batch action failed for ${ext.name}:`, e);
failCount += 1;
}
}
clearSelection();
if (failCount > 0) {
toast(
tm(partialMessageKey, { success: successCount, failed: failCount }),
"warning",
);
} else {
toast(tm(successMessageKey, { count: successCount }), "success");
}
};
```
Then the three batch methods become very small and consistent:
```ts
const batchEnable = async () =>
runBatchAction({
filter: (p) => !p.activated,
action: (p) => pluginOn(p),
emptyMessageKey: "batch.noDisabledPlugins",
partialMessageKey: "batch.enablePartial",
successMessageKey: "batch.enableSuccess",
});
const batchDisable = async () =>
runBatchAction({
filter: (p) => p.activated,
action: (p) => pluginOff(p),
emptyMessageKey: "batch.noEnabledPlugins",
partialMessageKey: "batch.disablePartial",
successMessageKey: "batch.disableSuccess",
});
const executeBatchUninstall = async () => {
batchUninstallConfirmDialog.value = false;
await runBatchAction({
filter: () => true,
action: (p) =>
uninstallExtension(p.name, {
deleteConfig: false,
deleteData: false,
}),
emptyMessageKey: "batch.noPluginsToUninstall",
partialMessageKey: "batch.uninstallPartial",
successMessageKey: "batch.uninstallSuccess",
});
};
```
This keeps all current behavior (including uninstall options and logging) but moves the shared control flow into one place.
### 2. Derive `selectModeActive` and centralize selection mutations
Right now `selectModeActive` is manually toggled in `toggleSelectPlugin`, `toggleSelectAll`, and `clearSelection`, which is easy to get out of sync.
You can remove the explicit writes and derive it from `selectedPluginNames`:
```ts
const selectedPluginNames = ref(new Set<string>());
const selectedCount = computed(() => selectedPluginNames.value.size);
const selectModeActive = computed(() => selectedCount.value > 0);
```
Then simplify the selection functions to only manipulate `selectedPluginNames`:
```ts
const setSelectedPlugins = (names: string[]) => {
selectedPluginNames.value = new Set(names);
};
const clearSelection = () => {
setSelectedPlugins([]);
};
const toggleSelectPlugin = (pluginName?: string) => {
if (!pluginName) return;
const plugin = filteredPlugins.value.find((p) => p.name === pluginName);
if (!plugin || plugin.reserved) return;
const next = new Set(selectedPluginNames.value);
if (next.has(pluginName)) {
next.delete(pluginName);
} else {
next.add(pluginName);
}
selectedPluginNames.value = next;
};
const toggleSelectAll = () => {
if (isAllSelected.value) {
clearSelection();
} else {
setSelectedPlugins(selectableInstalledPlugins.value.map((p) => p.name));
}
};
```
`isAllSelected` can stay as-is, but now there’s no duplicated logic updating both `selectedPluginNames` and `selectModeActive`. All UI can continue to read `selectModeActive` as before, but it’s now guaranteed to be consistent with the actual selection.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
Code Review
This pull request introduces batch operations (enable, disable, uninstall) and sorting capabilities for installed plugins, including UI updates with checkboxes and a batch action toolbar. Key feedback highlights a usability bug where selection mode cannot be initiated because the checkbox overlay is conditionally hidden, which can be resolved by separating the selectable and selection-mode states and styling the overlay to appear on hover. Additionally, event bubbling issues on the checkbox should be prevented using pointer-events: none. Finally, the batch operations require refactoring to avoid redundant API calls, toast flooding, and incorrect success/fail counts caused by calling helper functions in a loop instead of calling the API directly.
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.
| <ExtensionCard | ||
| :extension="extension" | ||
| :is-pinned="isPinnedExtension(extension)" | ||
| :selectable="selectModeActive" | ||
| :selected="selectedPluginNames.has(extension.name)" |
There was a problem hiding this comment.
There is a chicken-and-egg usability bug here: selectable is bound to selectModeActive, which is initially false. Because it is false, the checkbox overlay on the plugin cards is never rendered, meaning the user has no way to click a checkbox to enter selection mode in the first place.
To fix this, we should pass selectable as true (or simply selectable) so that the checkboxes are always available for selection, and pass selectModeActive as a separate selection-mode prop to control the active selection state.
<ExtensionCard
:extension="extension"
:is-pinned="isPinnedExtension(extension)"
selectable
:selection-mode="selectModeActive"
:selected="selectedPluginNames.has(extension.name)"
| selectable: { | ||
| type: Boolean, | ||
| default: false, | ||
| }, | ||
| selected: { | ||
| type: Boolean, | ||
| default: false, | ||
| }, | ||
| }); |
| <!-- 选择模式下的复选框覆盖层 --> | ||
| <div | ||
| v-if="selectable && !extension.reserved" | ||
| class="extension-select-overlay" | ||
| @click.stop="$emit('toggle-select')" | ||
| > | ||
| <v-checkbox | ||
| :model-value="selected" | ||
| density="compact" | ||
| color="primary" | ||
| hide-details | ||
| class="extension-select-checkbox" | ||
| @click.stop="$emit('toggle-select')" | ||
| /> | ||
| </div> |
There was a problem hiding this comment.
Update the checkbox overlay template to:
- Bind the
show-overlayclass when the card is selected or selection mode is active. - Apply
pointer-events: noneto thev-checkboxcomponent. This ensures that clicking anywhere on the overlay (including directly on the checkbox) is handled cleanly by the overlay's@click.stophandler, preventing event bubbling/double-triggering conflicts with Vuetify's internal checkbox handlers.
<!-- 选择模式下的复选框覆盖层 -->
<div
v-if="selectable && !extension.reserved"
class="extension-select-overlay"
:class="{ 'show-overlay': selected || selectionMode }"
@click.stop="$emit('toggle-select')"
>
<v-checkbox
:model-value="selected"
density="compact"
color="primary"
hide-details
class="extension-select-checkbox"
style="pointer-events: none"
/>
</div>
| .extension-select-overlay { | ||
| position: absolute; | ||
| top: 0; | ||
| left: 0; | ||
| z-index: 10; | ||
| padding: 4px; | ||
| } | ||
|
|
||
| .extension-select-checkbox { | ||
| background: rgba(var(--v-theme-surface), 0.92); | ||
| border-radius: 6px; | ||
| padding: 2px; | ||
| } |
There was a problem hiding this comment.
Add hover and active selection state styles to the overlay. This keeps the checkbox hidden by default to maintain a clean UI, but reveals it gracefully on hover or when selection mode is active.
.extension-select-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 10;
padding: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.extension-card:hover .extension-select-overlay,
.extension-select-overlay.show-overlay {
opacity: 1;
}
.extension-select-checkbox {
background: rgba(var(--v-theme-surface), 0.92);
border-radius: 6px;
padding: 2px;
}
| const batchEnable = async () => { | ||
| const targets = filteredPlugins.value.filter( | ||
| (p) => selectedPluginNames.value.has(p.name) && !p.activated, | ||
| ); | ||
| if (targets.length === 0) { | ||
| toast(tm("batch.noDisabledPlugins"), "info"); | ||
| return; | ||
| } | ||
| let successCount = 0; | ||
| let failCount = 0; | ||
| for (const ext of targets) { | ||
| try { | ||
| await pluginOn(ext); | ||
| successCount += 1; | ||
| } catch { | ||
| failCount += 1; | ||
| } | ||
| } | ||
| clearSelection(); | ||
| if (failCount > 0) { | ||
| toast( | ||
| tm("batch.enablePartial", { success: successCount, failed: failCount }), | ||
| "warning", | ||
| ); | ||
| } else { | ||
| toast(tm("batch.enableSuccess", { count: successCount }), "success"); | ||
| } | ||
| }; | ||
|
|
||
| const batchDisable = async () => { | ||
| const targets = filteredPlugins.value.filter( | ||
| (p) => selectedPluginNames.value.has(p.name) && p.activated, | ||
| ); | ||
| if (targets.length === 0) { | ||
| toast(tm("batch.noEnabledPlugins"), "info"); | ||
| return; | ||
| } | ||
| let successCount = 0; | ||
| let failCount = 0; | ||
| for (const ext of targets) { | ||
| try { | ||
| await pluginOff(ext); | ||
| successCount += 1; | ||
| } catch { | ||
| failCount += 1; | ||
| } | ||
| } | ||
| clearSelection(); | ||
| if (failCount > 0) { | ||
| toast( | ||
| tm("batch.disablePartial", { success: successCount, failed: failCount }), | ||
| "warning", | ||
| ); | ||
| } else { | ||
| toast(tm("batch.disableSuccess", { count: successCount }), "success"); | ||
| } | ||
| }; | ||
|
|
||
| const requestBatchUninstall = () => { | ||
| const targets = filteredPlugins.value.filter( | ||
| (p) => selectedPluginNames.value.has(p.name), | ||
| ); | ||
| if (targets.length === 0) { | ||
| toast(tm("batch.noPluginsToUninstall"), "info"); | ||
| return; | ||
| } | ||
| batchUninstallConfirmDialog.value = true; | ||
| }; | ||
|
|
||
| const executeBatchUninstall = async () => { | ||
| batchUninstallConfirmDialog.value = false; | ||
| const targets = filteredPlugins.value.filter( | ||
| (p) => selectedPluginNames.value.has(p.name), | ||
| ); | ||
| if (targets.length === 0) return; | ||
| let successCount = 0; | ||
| let failCount = 0; | ||
| for (const ext of targets) { | ||
| try { | ||
| await uninstallExtension(ext.name, { | ||
| deleteConfig: false, | ||
| deleteData: false, | ||
| }); | ||
| successCount += 1; | ||
| } catch (e) { | ||
| console.error(`Batch uninstall failed for ${ext.name}:`, e); | ||
| failCount += 1; | ||
| } | ||
| } | ||
| clearSelection(); | ||
| if (failCount > 0) { | ||
| toast( | ||
| tm("batch.uninstallPartial", { success: successCount, failed: failCount }), | ||
| "warning", | ||
| ); | ||
| } else { | ||
| toast(tm("batch.uninstallSuccess", { count: successCount }), "success"); | ||
| } | ||
| }; | ||
|
|
There was a problem hiding this comment.
The batch operations (batchEnable, batchDisable, and executeBatchUninstall) currently call helper functions (pluginOn, pluginOff, and uninstallExtension) in a loop. This introduces several critical issues:
- Redundant API Calls: Each of these helper functions internally triggers
getExtensions()(andcheckAndPromptConflicts()forpluginOn), causing a flood of redundant network requests. - Incorrect Success/Fail Counts: The helper functions catch their own errors internally and do not rethrow them. As a result, the
try-catchblocks in your batch operations will never catch any errors, leading to incorrect success/failure counts in the toast notifications. - Toast Flooding: Each helper function triggers its own success/error toast notification, resulting in overlapping/flooding toast messages.
We should call the API methods directly in the loop, track success/failure correctly, and trigger a single getExtensions() call and a single toast notification after the loop completes.
- Derive selectModeActive from selectedPluginNames computed (prevents sync bugs) - Extract runBatchAction helper to eliminate ~50 lines of duplicated code - Add clarifying comment explaining pin-sort vs user-sort interaction
- Fix checkbox overlay: always render but hidden by default, reveal on hover. Separate selectable (always true) from selectionMode to avoid the deadlock where the checkbox overlay can never appear because selection mode is never entered. - Apply pointer-events: none to v-checkbox to prevent Vuetify internal handler conflicts with the overlay @click handler. - Refactor batch operations to call raw pluginApi methods directly instead of wrapper functions (pluginOn/pluginOff/uninstallExtension) that have internal toast notifications and getExtensions() calls. This eliminates redundant API requests, incorrect success/fail counts, and toast flooding. - Perform a single getExtensions() refresh after all batch operations complete.
Summary
Add sorting controls and batch multi-select operations to the Installed Plugins management page, addressing issue #8749.
Changes
Sorting (#8749 - Part 1)
PluginSortControlcomponent to the installed plugins header (reuses existing component used by the market tab)Batch Operations (#8749 - Part 2)
Files Modified
useExtensionPage.jsInstalledPluginsTab.vueExtensionCard.vueextension.json(zh-CN)extension.json(en-US)Screenshots / Demo
Sorting Control (added next to search box):
Batch Toolbar (appears when plugins are selected):
Checkbox on Cards (top-left corner in selection mode):
Testing
Related Issue
Closes #8749
Summary by Sourcery
Add sorting and batch management capabilities to the Installed Plugins page.
New Features:
Enhancements: