Skip to content

feat: add sort and batch operations to installed plugins page#8856

Open
AnxForever wants to merge 3 commits into
AstrBotDevs:masterfrom
AnxForever:feat/installed-plugins-sort-and-batch
Open

feat: add sort and batch operations to installed plugins page#8856
AnxForever wants to merge 3 commits into
AstrBotDevs:masterfrom
AnxForever:feat/installed-plugins-sort-and-batch

Conversation

@AnxForever

@AnxForever AnxForever commented Jun 18, 2026

Copy link
Copy Markdown

Summary

Add sorting controls and batch multi-select operations to the Installed Plugins management page, addressing issue #8749.

Changes

Sorting (#8749 - Part 1)

  • Add PluginSortControl component to the installed plugins header (reuses existing component used by the market tab)
  • Support 5 sort modes: default (pinned-first), name, author, last updated, update status
  • Pinned plugins always remain on top regardless of sort selection

Batch Operations (#8749 - Part 2)

  • Add multi-select mode with checkbox overlay on plugin cards (top-left corner)
  • Selected plugins trigger a slide-in batch action toolbar with:
    • Select All / Deselect All toggle
    • Batch Enable — enable all selected disabled plugins
    • Batch Disable — disable all selected enabled plugins
    • Batch Uninstall — uninstall selected plugins (with confirmation dialog)
  • System/reserved plugins are excluded from selection
  • Operation results display success/failure counts via toast notifications

Files Modified

File Changes Purpose
useExtensionPage.js +174 Sorting state, batch selection state, batch operation methods
InstalledPluginsTab.vue +158 Sort control UI, batch toolbar, uninstall confirmation dialog
ExtensionCard.vue +39 Selection mode checkbox overlay + CSS
extension.json (zh-CN) +21 Chinese i18n keys (19 batch keys)
extension.json (en-US) +21 English i18n keys (19 batch keys, matched with zh-CN)

Screenshots / Demo

Sorting Control (added next to search box):

[Search...] [▼ Sort by: Default  ↑↓]

Batch Toolbar (appears when plugins are selected):

┌─────────────────────────────────────────────────────────┐
│ 5 plugin(s) selected  [Select All] │ [✓ Enable] [✕ Disable] [🗑 Uninstall]    [Clear] │
└─────────────────────────────────────────────────────────┘

Checkbox on Cards (top-left corner in selection mode):

┌───────┐
│ ☑     │  ← checkbox overlay
│  ┌──┐ │
│  │  │ │  Plugin Name  v1.0
│  └──┘ │  Description...
│       │  [Pin] [Docs] [Config] [Reload] [···]
└───────┘

Testing

  • Sort controls display correctly and all 5 sort modes work
  • Clicking checkbox enters selection mode; clicking again deselects
  • Batch toolbar slides in/out with correct count
  • Select All / Deselect All behaves correctly
  • System (reserved) plugins show no checkbox and cannot be selected
  • Batch enable/disable functions correctly with toast feedback
  • Batch uninstall shows confirmation dialog before executing
  • Existing single-plugin operations (enable, disable, configure, pin, etc.) still work normally

Related Issue

Closes #8749

Summary by Sourcery

Add sorting and batch management capabilities to the Installed Plugins page.

New Features:

  • Introduce configurable sort options for installed plugins, including name, author, last updated, and update-available status while keeping pinned plugins prioritized.
  • Add multi-select mode on plugin cards with a batch action toolbar to enable, disable, or uninstall multiple plugins at once, with confirmation for uninstall and toast feedback.

Enhancements:

  • Exclude reserved/system plugins from batch selection and operations, and surface partial success or failure outcomes via toasts.
  • Extend the shared ExtensionCard component to support selectable state with an overlaid checkbox for batch operations.
  • Localize new sorting and batch operation labels and messages in English and Chinese extension i18n files.

- 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
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. area:webui The bug / feature is about webui(dashboard) of astrbot. feature:plugin The bug / feature is about AstrBot plugin system. labels Jun 18, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread dashboard/src/views/extension/useExtensionPage.js Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 487 to +491
<ExtensionCard
:extension="extension"
:is-pinned="isPinnedExtension(extension)"
:selectable="selectModeActive"
:selected="selectedPluginNames.has(extension.name)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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)"

Comment on lines +28 to 36
selectable: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Add the selectionMode prop to support showing checkboxes on all cards when selection mode is active.

  selectable: {
    type: Boolean,
    default: false,
  },
  selected: {
    type: Boolean,
    default: false,
  },
  selectionMode: {
    type: Boolean,
    default: false,
  },
});

Comment on lines +177 to +191
<!-- 选择模式下的复选框覆盖层 -->
<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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Update the checkbox overlay template to:

  1. Bind the show-overlay class when the card is selected or selection mode is active.
  2. Apply pointer-events: none to the v-checkbox component. This ensures that clicking anywhere on the overlay (including directly on the checkbox) is handled cleanly by the overlay's @click.stop handler, 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>

Comment on lines +444 to +456
.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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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;
}

Comment on lines +245 to +344
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");
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The batch operations (batchEnable, batchDisable, and executeBatchUninstall) currently call helper functions (pluginOn, pluginOff, and uninstallExtension) in a loop. This introduces several critical issues:

  1. Redundant API Calls: Each of these helper functions internally triggers getExtensions() (and checkAndPromptConflicts() for pluginOn), causing a flood of redundant network requests.
  2. Incorrect Success/Fail Counts: The helper functions catch their own errors internally and do not rethrow them. As a result, the try-catch blocks in your batch operations will never catch any errors, leading to incorrect success/failure counts in the toast notifications.
  3. 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. feature:plugin The bug / feature is about AstrBot plugin system. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 希望已安装插件页面支持排序和多选

1 participant