From 0a8f2477810ff52a72e258616f78170f7ee5d471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 9 Jun 2026 16:32:40 +0800 Subject: [PATCH] feat(desktop): add plugin list bulk toggles (#527) Tested: - cd desktop && bun run test -- src/__tests__/pluginsSettings.test.tsx src/stores/pluginStore.test.ts --run --reporter=verbose --pool=forks --maxWorkers=1 --minWorkers=1 - bun run check:desktop - git diff --check Scope-risk: moderate Confidence: high --- .../src/__tests__/pluginsSettings.test.tsx | 168 +++++++++- desktop/src/components/plugins/PluginList.tsx | 304 +++++++++++++++--- desktop/src/i18n/locales/en.ts | 11 + desktop/src/i18n/locales/jp.ts | 11 + desktop/src/i18n/locales/kr.ts | 11 + desktop/src/i18n/locales/zh-TW.ts | 11 + desktop/src/i18n/locales/zh.ts | 11 + desktop/src/stores/pluginStore.test.ts | 68 ++++ desktop/src/stores/pluginStore.ts | 62 ++++ 9 files changed, 606 insertions(+), 51 deletions(-) diff --git a/desktop/src/__tests__/pluginsSettings.test.tsx b/desktop/src/__tests__/pluginsSettings.test.tsx index 11e20e49..b71902dc 100644 --- a/desktop/src/__tests__/pluginsSettings.test.tsx +++ b/desktop/src/__tests__/pluginsSettings.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' @@ -179,6 +179,8 @@ describe('Settings > Plugins tab', () => { }), enablePlugin: vi.fn().mockResolvedValue('enabled'), disablePlugin: vi.fn().mockResolvedValue('disabled'), + bulkEnablePlugins: vi.fn().mockResolvedValue(0), + bulkDisablePlugins: vi.fn().mockResolvedValue(0), updatePlugin: vi.fn().mockResolvedValue('updated'), uninstallPlugin: vi.fn().mockResolvedValue('uninstalled'), clearSelection: vi.fn(), @@ -251,6 +253,170 @@ describe('Settings > Plugins tab', () => { expect(screen.getByText('Known marketplaces')).toBeInTheDocument() }) + it('bulk enables selected disabled plugins from the list after confirmation', async () => { + const bulkEnablePlugins = vi.fn().mockResolvedValue(2) + usePluginStore.setState({ + plugins: [ + { + id: 'drawing@claude-plugins-official', + name: 'drawing', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: false, + hasErrors: false, + isBuiltin: false, + description: 'Render diagrams.', + componentCounts: { + commands: 0, + agents: 0, + skills: 1, + hooks: 0, + mcpServers: 0, + lspServers: 0, + }, + errors: [], + }, + { + id: 'review@claude-plugins-official', + name: 'review', + marketplace: 'claude-plugins-official', + scope: 'project', + enabled: false, + hasErrors: false, + isBuiltin: false, + description: 'Review pull requests.', + componentCounts: { + commands: 0, + agents: 1, + skills: 0, + hooks: 0, + mcpServers: 0, + lspServers: 0, + }, + errors: [], + }, + { + id: 'github@claude-plugins-official', + name: 'github', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: true, + hasErrors: false, + isBuiltin: false, + description: 'GitHub integration', + componentCounts: { + commands: 1, + agents: 0, + skills: 0, + hooks: 0, + mcpServers: 1, + lspServers: 0, + }, + errors: [], + }, + ], + summary: { total: 3, enabled: 1, errorCount: 0, marketplaceCount: 1 }, + bulkEnablePlugins, + }) + + render() + switchToPluginsTab() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select drawing' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Select review' })) + fireEvent.click(screen.getByRole('button', { name: 'Enable selected' })) + + expect(screen.getByText('Enable 2 selected plugins?')).toBeInTheDocument() + expect(screen.getByText('This will enable drawing, review and apply plugin changes to the current desktop runtime.')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Enable' })) + + await waitFor(() => { + expect(bulkEnablePlugins).toHaveBeenCalledWith( + [ + { id: 'drawing@claude-plugins-official', scope: 'user' }, + { id: 'review@claude-plugins-official', scope: 'project' }, + ], + '/workspace/project', + 'session-1', + ) + }) + + await waitFor(() => { + expect(screen.getByRole('checkbox', { name: 'Select drawing' })).not.toBeChecked() + expect(screen.queryByText('2 selected')).not.toBeInTheDocument() + }) + }) + + it('bulk disables selected enabled plugins from the list after confirmation', async () => { + const bulkDisablePlugins = vi.fn().mockResolvedValue(1) + usePluginStore.setState({ + plugins: [ + { + id: 'github@claude-plugins-official', + name: 'github', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: true, + hasErrors: false, + isBuiltin: false, + description: 'GitHub integration', + componentCounts: { + commands: 1, + agents: 0, + skills: 0, + hooks: 0, + mcpServers: 1, + lspServers: 0, + }, + errors: [], + }, + { + id: 'drawing@claude-plugins-official', + name: 'drawing', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: false, + hasErrors: false, + isBuiltin: false, + description: 'Render diagrams.', + componentCounts: { + commands: 0, + agents: 0, + skills: 1, + hooks: 0, + mcpServers: 0, + lspServers: 0, + }, + errors: [], + }, + ], + summary: { total: 2, enabled: 1, errorCount: 0, marketplaceCount: 1 }, + bulkDisablePlugins, + }) + + render() + switchToPluginsTab() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select github' })) + fireEvent.click(screen.getByRole('button', { name: 'Disable selected' })) + + expect(screen.getByText('Disable 1 selected plugins?')).toBeInTheDocument() + expect(screen.getByText('This will disable github and apply plugin changes to the current desktop runtime.')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Disable' })) + + await waitFor(() => { + expect(bulkDisablePlugins).toHaveBeenCalledWith( + [ + { id: 'github@claude-plugins-official', scope: 'user' }, + ], + '/workspace/project', + 'session-1', + ) + }) + }) + it('renders plugin detail with bundled capability sections', () => { usePluginStore.setState({ selectedPlugin: { diff --git a/desktop/src/components/plugins/PluginList.tsx b/desktop/src/components/plugins/PluginList.tsx index d3189b63..1d380dfa 100644 --- a/desktop/src/components/plugins/PluginList.tsx +++ b/desktop/src/components/plugins/PluginList.tsx @@ -1,12 +1,14 @@ -import { useEffect, useMemo } from 'react' -import { usePluginStore } from '../../stores/pluginStore' +import { useEffect, useMemo, useState } from 'react' +import { usePluginStore, type PluginActionTarget } from '../../stores/pluginStore' import { useSessionStore } from '../../stores/sessionStore' import { useTranslation } from '../../i18n' import { useUIStore } from '../../stores/uiStore' import { Button } from '../shared/Button' +import { ConfirmDialog } from '../shared/ConfirmDialog' import type { PluginSummary } from '../../types/plugin' type PluginBucket = 'attention' | 'enabled' | 'disabled' +type BatchAction = 'enable' | 'disable' export function PluginList() { const { @@ -20,11 +22,15 @@ export function PluginList() { fetchPlugins, fetchPluginDetail, reloadPlugins, + bulkEnablePlugins, + bulkDisablePlugins, } = usePluginStore() const sessions = useSessionStore((s) => s.sessions) const activeSessionId = useSessionStore((s) => s.activeSessionId) const addToast = useUIStore((s) => s.addToast) const t = useTranslation() + const [selectedPluginIds, setSelectedPluginIds] = useState>(() => new Set()) + const [confirmBatchAction, setConfirmBatchAction] = useState(null) const activeSession = sessions.find((session) => session.id === activeSessionId) const currentWorkDir = activeSession?.workDir || undefined @@ -52,6 +58,32 @@ export function PluginList() { return buckets }, [plugins]) + useEffect(() => { + setSelectedPluginIds((current) => { + const selectableIds = new Set(plugins.filter(canMutatePlugin).map((plugin) => plugin.id)) + const next = new Set([...current].filter((id) => selectableIds.has(id))) + return next.size === current.size ? current : next + }) + }, [plugins]) + + const selectedPlugins = useMemo( + () => plugins.filter((plugin) => selectedPluginIds.has(plugin.id) && canMutatePlugin(plugin)), + [plugins, selectedPluginIds], + ) + const enableCandidates = useMemo( + () => selectedPlugins.filter((plugin) => !plugin.enabled), + [selectedPlugins], + ) + const disableCandidates = useMemo( + () => selectedPlugins.filter((plugin) => plugin.enabled), + [selectedPlugins], + ) + const confirmBatchPlugins = confirmBatchAction === 'enable' ? enableCandidates : disableCandidates + const confirmBatchNames = useMemo( + () => formatPluginNames(confirmBatchPlugins), + [confirmBatchPlugins], + ) + const handleReload = async () => { try { const reloadSummary = await reloadPlugins(currentWorkDir, activeSessionId || undefined) @@ -71,6 +103,63 @@ export function PluginList() { } } + const togglePluginSelection = (pluginId: string, selected: boolean) => { + setSelectedPluginIds((current) => { + const next = new Set(current) + if (selected) { + next.add(pluginId) + } else { + next.delete(pluginId) + } + return next + }) + } + + const clearSelection = () => { + setSelectedPluginIds(new Set()) + } + + const toActionTargets = (items: PluginSummary[]): PluginActionTarget[] => + items.map((plugin) => ({ id: plugin.id, scope: plugin.scope })) + + const handleBatchConfirm = async () => { + if (!confirmBatchAction) return + + const action = confirmBatchAction + const targets = action === 'enable' ? enableCandidates : disableCandidates + if (targets.length === 0) { + setConfirmBatchAction(null) + return + } + + try { + const changed = action === 'enable' + ? await bulkEnablePlugins(toActionTargets(targets), currentWorkDir, activeSessionId || undefined) + : await bulkDisablePlugins(toActionTargets(targets), currentWorkDir, activeSessionId || undefined) + + setSelectedPluginIds((current) => { + const next = new Set(current) + for (const plugin of targets) { + next.delete(plugin.id) + } + return next + }) + setConfirmBatchAction(null) + addToast({ + type: 'success', + message: t(action === 'enable' ? 'settings.plugins.bulkEnableToast' : 'settings.plugins.bulkDisableToast', { + count: String(changed), + }), + }) + } catch (err) { + setConfirmBatchAction(null) + addToast({ + type: 'error', + message: err instanceof Error ? err.message : String(err), + }) + } + } + if (isLoading) { return (
@@ -176,6 +265,45 @@ export function PluginList() {

)}
+ +
+
+ + checklist + + + {t('settings.plugins.selectionCount', { count: String(selectedPlugins.length) })} + + {selectedPlugins.length > 0 && ( + + )} +
+
+ + +
+
{marketplaces.length > 0 && ( @@ -223,19 +351,65 @@ export function PluginList() { )} - {renderGroup('attention', grouped.attention, fetchPluginDetail, currentWorkDir, t)} - {renderGroup('enabled', grouped.enabled, fetchPluginDetail, currentWorkDir, t)} - {renderGroup('disabled', grouped.disabled, fetchPluginDetail, currentWorkDir, t)} + {renderGroup('attention', grouped.attention, { + fetchPluginDetail, + cwd: currentWorkDir, + t, + selectedPluginIds, + onToggleSelection: togglePluginSelection, + })} + {renderGroup('enabled', grouped.enabled, { + fetchPluginDetail, + cwd: currentWorkDir, + t, + selectedPluginIds, + onToggleSelection: togglePluginSelection, + })} + {renderGroup('disabled', grouped.disabled, { + fetchPluginDetail, + cwd: currentWorkDir, + t, + selectedPluginIds, + onToggleSelection: togglePluginSelection, + })} + + setConfirmBatchAction(null)} + onConfirm={handleBatchConfirm} + title={confirmBatchAction === 'enable' + ? t('settings.plugins.bulkEnableTitle', { count: String(confirmBatchPlugins.length) }) + : t('settings.plugins.bulkDisableTitle', { count: String(confirmBatchPlugins.length) })} + body={confirmBatchAction === 'enable' + ? t('settings.plugins.bulkEnableBody', { names: confirmBatchNames }) + : t('settings.plugins.bulkDisableBody', { names: confirmBatchNames })} + confirmLabel={confirmBatchAction === 'enable' ? t('settings.plugins.enable') : t('settings.plugins.disable')} + cancelLabel={t('common.cancel')} + confirmVariant={confirmBatchAction === 'disable' ? 'danger' : 'primary'} + loading={isApplying} + /> ) } +type RenderGroupOptions = { + fetchPluginDetail: (id: string, cwd?: string) => Promise + cwd: string | undefined + t: ReturnType + selectedPluginIds: Set + onToggleSelection: (pluginId: string, selected: boolean) => void +} + function renderGroup( bucket: PluginBucket, items: PluginSummary[], - fetchPluginDetail: (id: string, cwd?: string) => Promise, - cwd: string | undefined, - t: ReturnType, + { + fetchPluginDetail, + cwd, + t, + selectedPluginIds, + onToggleSelection, + }: RenderGroupOptions, ) { if (items.length === 0) return null @@ -264,60 +438,90 @@ function renderGroup(
{items.map((plugin) => ( -
- - chevron_right - + + chevron_right + + - + ))} ) } +function canMutatePlugin(plugin: PluginSummary) { + return plugin.scope !== 'managed' && plugin.scope !== 'builtin' +} + +function formatPluginNames(plugins: PluginSummary[]) { + return plugins.map((plugin) => plugin.name).join(', ') +} + function SummaryCard({ label, value, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index b7439d6b..9bf7e193 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -774,6 +774,17 @@ export const en = { 'settings.plugins.update': 'Update', 'settings.plugins.uninstall': 'Uninstall', 'settings.plugins.confirmUninstall': 'Uninstall plugin "{name}"? This removes the installed copy for the selected scope.', + 'settings.plugins.selectionCount': '{count} selected', + 'settings.plugins.clearSelection': 'Clear', + 'settings.plugins.enableSelected': 'Enable selected', + 'settings.plugins.disableSelected': 'Disable selected', + 'settings.plugins.selectPlugin': 'Select {name}', + 'settings.plugins.bulkEnableTitle': 'Enable {count} selected plugins?', + 'settings.plugins.bulkDisableTitle': 'Disable {count} selected plugins?', + 'settings.plugins.bulkEnableBody': 'This will enable {names} and apply plugin changes to the current desktop runtime.', + 'settings.plugins.bulkDisableBody': 'This will disable {names} and apply plugin changes to the current desktop runtime.', + 'settings.plugins.bulkEnableToast': 'Enabled {count} plugins and applied plugin changes.', + 'settings.plugins.bulkDisableToast': 'Disabled {count} plugins and applied plugin changes.', 'settings.plugins.author': 'Author: {value}', 'settings.plugins.projectPath': 'Project: {value}', 'settings.plugins.installPath': 'Installed at: {value}', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 98d26b17..7feafc87 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -776,6 +776,17 @@ export const jp: Record = { 'settings.plugins.update': '更新', 'settings.plugins.uninstall': 'アンインストール', 'settings.plugins.confirmUninstall': 'プラグイン「{name}」をアンインストールしますか?選択したスコープのインストール済みコピーが削除されます。', + 'settings.plugins.selectionCount': '{count} 件選択中', + 'settings.plugins.clearSelection': 'クリア', + 'settings.plugins.enableSelected': '選択を有効化', + 'settings.plugins.disableSelected': '選択を無効化', + 'settings.plugins.selectPlugin': '{name} を選択', + 'settings.plugins.bulkEnableTitle': '選択した {count} 件のプラグインを有効化しますか?', + 'settings.plugins.bulkDisableTitle': '選択した {count} 件のプラグインを無効化しますか?', + 'settings.plugins.bulkEnableBody': '{names} を有効化し、プラグイン変更を現在のデスクトップランタイムに適用します。', + 'settings.plugins.bulkDisableBody': '{names} を無効化し、プラグイン変更を現在のデスクトップランタイムに適用します。', + 'settings.plugins.bulkEnableToast': '{count} 件のプラグインを有効化し、変更を適用しました。', + 'settings.plugins.bulkDisableToast': '{count} 件のプラグインを無効化し、変更を適用しました。', 'settings.plugins.author': '作者: {value}', 'settings.plugins.projectPath': 'プロジェクト: {value}', 'settings.plugins.installPath': 'インストール先: {value}', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 7160e04c..519f2b7d 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -776,6 +776,17 @@ export const kr: Record = { 'settings.plugins.update': '업데이트', 'settings.plugins.uninstall': '제거', 'settings.plugins.confirmUninstall': '플러그인 "{name}"을(를) 제거하시겠습니까? 선택한 범위의 설치된 복사본이 삭제됩니다.', + 'settings.plugins.selectionCount': '{count}개 선택됨', + 'settings.plugins.clearSelection': '지우기', + 'settings.plugins.enableSelected': '선택 항목 사용', + 'settings.plugins.disableSelected': '선택 항목 사용 안 함', + 'settings.plugins.selectPlugin': '{name} 선택', + 'settings.plugins.bulkEnableTitle': '선택한 플러그인 {count}개를 사용하시겠습니까?', + 'settings.plugins.bulkDisableTitle': '선택한 플러그인 {count}개를 사용 안 함으로 변경하시겠습니까?', + 'settings.plugins.bulkEnableBody': '{names}을(를) 사용하고 플러그인 변경 사항을 현재 데스크톱 런타임에 적용합니다.', + 'settings.plugins.bulkDisableBody': '{names}을(를) 사용 안 함으로 변경하고 플러그인 변경 사항을 현재 데스크톱 런타임에 적용합니다.', + 'settings.plugins.bulkEnableToast': '플러그인 {count}개를 사용하고 변경 사항을 적용했습니다.', + 'settings.plugins.bulkDisableToast': '플러그인 {count}개를 사용 안 함으로 변경하고 변경 사항을 적용했습니다.', 'settings.plugins.author': '작성자: {value}', 'settings.plugins.projectPath': '프로젝트: {value}', 'settings.plugins.installPath': '설치 위치: {value}', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index c3f50455..58ffe3bd 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -776,6 +776,17 @@ export const zh: Record = { 'settings.plugins.update': '更新', 'settings.plugins.uninstall': '解除安裝', 'settings.plugins.confirmUninstall': '確定解除安裝外掛“{name}”嗎?會刪除當前 scope 下的安裝副本。', + 'settings.plugins.selectionCount': '已選擇 {count} 個', + 'settings.plugins.clearSelection': '清除', + 'settings.plugins.enableSelected': '啟用所選', + 'settings.plugins.disableSelected': '禁用所選', + 'settings.plugins.selectPlugin': '選擇 {name}', + 'settings.plugins.bulkEnableTitle': '啟用已選擇的 {count} 個外掛?', + 'settings.plugins.bulkDisableTitle': '禁用已選擇的 {count} 個外掛?', + 'settings.plugins.bulkEnableBody': '將啟用 {names},並把外掛變更應用到當前桌面端執行時。', + 'settings.plugins.bulkDisableBody': '將禁用 {names},並把外掛變更應用到當前桌面端執行時。', + 'settings.plugins.bulkEnableToast': '已啟用 {count} 個外掛,並應用外掛變更。', + 'settings.plugins.bulkDisableToast': '已禁用 {count} 個外掛,並應用外掛變更。', 'settings.plugins.author': '作者:{value}', 'settings.plugins.projectPath': '專案:{value}', 'settings.plugins.installPath': '安裝位置:{value}', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index cdcfa270..6b5f7cd4 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -776,6 +776,17 @@ export const zh: Record = { 'settings.plugins.update': '更新', 'settings.plugins.uninstall': '卸载', 'settings.plugins.confirmUninstall': '确定卸载插件“{name}”吗?会删除当前 scope 下的安装副本。', + 'settings.plugins.selectionCount': '已选择 {count} 个', + 'settings.plugins.clearSelection': '清除', + 'settings.plugins.enableSelected': '启用所选', + 'settings.plugins.disableSelected': '禁用所选', + 'settings.plugins.selectPlugin': '选择 {name}', + 'settings.plugins.bulkEnableTitle': '启用已选择的 {count} 个插件?', + 'settings.plugins.bulkDisableTitle': '禁用已选择的 {count} 个插件?', + 'settings.plugins.bulkEnableBody': '将启用 {names},并把插件变更应用到当前桌面端运行时。', + 'settings.plugins.bulkDisableBody': '将禁用 {names},并把插件变更应用到当前桌面端运行时。', + 'settings.plugins.bulkEnableToast': '已启用 {count} 个插件,并应用插件变更。', + 'settings.plugins.bulkDisableToast': '已禁用 {count} 个插件,并应用插件变更。', 'settings.plugins.author': '作者:{value}', 'settings.plugins.projectPath': '项目:{value}', 'settings.plugins.installPath': '安装位置:{value}', diff --git a/desktop/src/stores/pluginStore.test.ts b/desktop/src/stores/pluginStore.test.ts index 1a6be4b8..69da9857 100644 --- a/desktop/src/stores/pluginStore.test.ts +++ b/desktop/src/stores/pluginStore.test.ts @@ -88,4 +88,72 @@ describe('pluginStore', () => { errors: 0, }) }) + + it('reloads and refreshes once after bulk enabling plugins', async () => { + mockedPluginsApi.enable.mockResolvedValue({ + ok: true, + message: 'enabled', + }) + + const changed = await usePluginStore.getState().bulkEnablePlugins( + [ + { id: 'draw@test', scope: 'user' }, + { id: 'review@test', scope: 'project' }, + ], + '/workspace/project', + 'session-1', + ) + + expect(changed).toBe(2) + expect(mockedPluginsApi.enable).toHaveBeenCalledTimes(2) + expect(mockedPluginsApi.enable).toHaveBeenNthCalledWith(1, { + id: 'draw@test', + scope: 'user', + }) + expect(mockedPluginsApi.enable).toHaveBeenNthCalledWith(2, { + id: 'review@test', + scope: 'project', + }) + expect(mockedPluginsApi.reload).toHaveBeenCalledTimes(1) + expect(mockedPluginsApi.reload).toHaveBeenCalledWith( + '/workspace/project', + 'session-1', + ) + expect(mockedPluginsApi.list).toHaveBeenCalledTimes(1) + expect(mockedPluginsApi.list).toHaveBeenCalledWith('/workspace/project') + }) + + it('reloads and refreshes once after bulk disabling plugins', async () => { + mockedPluginsApi.disable.mockResolvedValue({ + ok: true, + message: 'disabled', + }) + + const changed = await usePluginStore.getState().bulkDisablePlugins( + [ + { id: 'github@test', scope: 'user' }, + { id: 'review@test', scope: 'project' }, + ], + '/workspace/project', + 'session-1', + ) + + expect(changed).toBe(2) + expect(mockedPluginsApi.disable).toHaveBeenCalledTimes(2) + expect(mockedPluginsApi.disable).toHaveBeenNthCalledWith(1, { + id: 'github@test', + scope: 'user', + }) + expect(mockedPluginsApi.disable).toHaveBeenNthCalledWith(2, { + id: 'review@test', + scope: 'project', + }) + expect(mockedPluginsApi.reload).toHaveBeenCalledTimes(1) + expect(mockedPluginsApi.reload).toHaveBeenCalledWith( + '/workspace/project', + 'session-1', + ) + expect(mockedPluginsApi.list).toHaveBeenCalledTimes(1) + expect(mockedPluginsApi.list).toHaveBeenCalledWith('/workspace/project') + }) }) diff --git a/desktop/src/stores/pluginStore.ts b/desktop/src/stores/pluginStore.ts index 9c70b9ba..db3dd24b 100644 --- a/desktop/src/stores/pluginStore.ts +++ b/desktop/src/stores/pluginStore.ts @@ -23,11 +23,18 @@ type PluginStore = { reloadPlugins: (cwd?: string, sessionId?: string) => Promise enablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise disablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise + bulkEnablePlugins: (plugins: PluginActionTarget[], cwd?: string, sessionId?: string) => Promise + bulkDisablePlugins: (plugins: PluginActionTarget[], cwd?: string, sessionId?: string) => Promise updatePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string, sessionId?: string) => Promise clearSelection: () => void } +export type PluginActionTarget = { + id: string + scope?: PluginScope +} + export const usePluginStore = create((set, get) => ({ plugins: [], marketplaces: [], @@ -108,6 +115,28 @@ export const usePluginStore = create((set, get) => ({ ) }, + bulkEnablePlugins: async (plugins, cwd, sessionId) => { + return runBulkAction( + plugins, + (plugin) => pluginsApi.enable(plugin), + set, + get, + cwd, + sessionId, + ) + }, + + bulkDisablePlugins: async (plugins, cwd, sessionId) => { + return runBulkAction( + plugins, + (plugin) => pluginsApi.disable(plugin), + set, + get, + cwd, + sessionId, + ) + }, + updatePlugin: async (id, scope, cwd, sessionId) => { return runAction( () => pluginsApi.update({ id, scope }), @@ -161,3 +190,36 @@ async function runAction( throw err } } + +async function runBulkAction( + plugins: PluginActionTarget[], + action: (plugin: PluginActionTarget) => Promise<{ ok: true; message: string }>, + set: (updater: Partial) => void, + get: () => PluginStore, + cwd?: string, + sessionId?: string, +): Promise { + if (plugins.length === 0) return 0 + + set({ isApplying: true, error: null }) + try { + for (const plugin of plugins) { + await action(plugin) + } + + const { summary } = await pluginsApi.reload(cwd, sessionId) + await get().fetchPlugins(cwd) + const selected = get().selectedPlugin + if (selected) { + await get().fetchPluginDetail(selected.id, cwd) + } + set({ isApplying: false, lastReloadSummary: summary }) + return plugins.length + } catch (err) { + set({ + isApplying: false, + error: err instanceof Error ? err.message : String(err), + }) + throw err + } +}