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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-09 16:32:40 +08:00
parent a7263cbaf7
commit 0a8f247781
9 changed files with 606 additions and 51 deletions

View File

@ -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(<Settings />)
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(<Settings />)
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: {

View File

@ -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<Set<string>>(() => new Set())
const [confirmBatchAction, setConfirmBatchAction] = useState<BatchAction | null>(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 (
<div className="flex justify-center py-12">
@ -176,6 +265,45 @@ export function PluginList() {
</p>
)}
</div>
<div className="flex flex-col gap-3 border-t border-[var(--color-border)] px-5 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">
checklist
</span>
<span className="font-medium text-[var(--color-text-primary)]">
{t('settings.plugins.selectionCount', { count: String(selectedPlugins.length) })}
</span>
{selectedPlugins.length > 0 && (
<button
type="button"
onClick={clearSelection}
className="rounded-md px-2 py-1 text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
>
{t('settings.plugins.clearSelection')}
</button>
)}
</div>
<div className="flex flex-wrap gap-2 sm:justify-end">
<Button
size="sm"
disabled={enableCandidates.length === 0 || isApplying}
onClick={() => setConfirmBatchAction('enable')}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">toggle_on</span>
{t('settings.plugins.enableSelected')}
</Button>
<Button
variant="secondary"
size="sm"
disabled={disableCandidates.length === 0 || isApplying}
onClick={() => setConfirmBatchAction('disable')}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">toggle_off</span>
{t('settings.plugins.disableSelected')}
</Button>
</div>
</div>
</section>
{marketplaces.length > 0 && (
@ -223,19 +351,65 @@ export function PluginList() {
</section>
)}
{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,
})}
<ConfirmDialog
open={confirmBatchAction !== null}
onClose={() => 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}
/>
</div>
)
}
type RenderGroupOptions = {
fetchPluginDetail: (id: string, cwd?: string) => Promise<void>
cwd: string | undefined
t: ReturnType<typeof useTranslation>
selectedPluginIds: Set<string>
onToggleSelection: (pluginId: string, selected: boolean) => void
}
function renderGroup(
bucket: PluginBucket,
items: PluginSummary[],
fetchPluginDetail: (id: string, cwd?: string) => Promise<void>,
cwd: string | undefined,
t: ReturnType<typeof useTranslation>,
{
fetchPluginDetail,
cwd,
t,
selectedPluginIds,
onToggleSelection,
}: RenderGroupOptions,
) {
if (items.length === 0) return null
@ -264,60 +438,90 @@ function renderGroup(
</div>
<div className="flex flex-col p-2">
{items.map((plugin) => (
<button
<div
key={plugin.id}
onClick={() => void fetchPluginDetail(plugin.id, cwd)}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
className={`group rounded-xl border px-3 py-3 transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] ${
selectedPluginIds.has(plugin.id)
? 'border-[var(--color-brand)]/45 bg-[var(--color-surface-selected)]'
: 'border-transparent'
}`}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
{plugin.hasErrors ? 'warning' : plugin.enabled ? 'extension' : 'extension_off'}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">
{plugin.name}
</span>
<StatusPill plugin={plugin} />
<ScopePill scope={plugin.scope} />
{plugin.version && (
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
v{plugin.version}
{canMutatePlugin(plugin) ? (
<label className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)]">
<input
type="checkbox"
aria-label={t('settings.plugins.selectPlugin', { name: plugin.name })}
checked={selectedPluginIds.has(plugin.id)}
onChange={(event) => onToggleSelection(plugin.id, event.currentTarget.checked)}
className="h-4 w-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
</label>
) : (
<span className="mt-0.5 h-6 w-6 shrink-0" aria-hidden="true" />
)}
<button
type="button"
onClick={() => void fetchPluginDetail(plugin.id, cwd)}
className="flex min-w-0 flex-1 items-start gap-3 rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
>
<span className="mt-0.5 material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
{plugin.hasErrors ? 'warning' : plugin.enabled ? 'extension' : 'extension_off'}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">
{plugin.name}
</span>
)}
<StatusPill plugin={plugin} />
<ScopePill scope={plugin.scope} />
{plugin.version && (
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
v{plugin.version}
</span>
)}
</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words">
{plugin.description || t('settings.plugins.noDescription')}
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>{plugin.marketplace}</span>
{plugin.componentCounts.skills > 0 && (
<span>{t('settings.plugins.capability.skills', { count: String(plugin.componentCounts.skills) })}</span>
)}
{plugin.componentCounts.agents > 0 && (
<span>{t('settings.plugins.capability.agents', { count: String(plugin.componentCounts.agents) })}</span>
)}
{plugin.componentCounts.mcpServers > 0 && (
<span>{t('settings.plugins.capability.mcpServers', { count: String(plugin.componentCounts.mcpServers) })}</span>
)}
{plugin.errors.length > 0 && (
<span className="text-[var(--color-error)]">
{t('settings.plugins.errorCount', { count: String(plugin.errors.length) })}
</span>
)}
</div>
</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words">
{plugin.description || t('settings.plugins.noDescription')}
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>{plugin.marketplace}</span>
{plugin.componentCounts.skills > 0 && (
<span>{t('settings.plugins.capability.skills', { count: String(plugin.componentCounts.skills) })}</span>
)}
{plugin.componentCounts.agents > 0 && (
<span>{t('settings.plugins.capability.agents', { count: String(plugin.componentCounts.agents) })}</span>
)}
{plugin.componentCounts.mcpServers > 0 && (
<span>{t('settings.plugins.capability.mcpServers', { count: String(plugin.componentCounts.mcpServers) })}</span>
)}
{plugin.errors.length > 0 && (
<span className="text-[var(--color-error)]">
{t('settings.plugins.errorCount', { count: String(plugin.errors.length) })}
</span>
)}
</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
chevron_right
</span>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
chevron_right
</span>
</button>
</div>
</button>
</div>
))}
</div>
</section>
)
}
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,

View File

@ -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}',

View File

@ -776,6 +776,17 @@ export const jp: Record<TranslationKey, string> = {
'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}',

View File

@ -776,6 +776,17 @@ export const kr: Record<TranslationKey, string> = {
'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}',

View File

@ -776,6 +776,17 @@ export const zh: Record<TranslationKey, string> = {
'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}',

View File

@ -776,6 +776,17 @@ export const zh: Record<TranslationKey, string> = {
'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}',

View File

@ -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')
})
})

View File

@ -23,11 +23,18 @@ type PluginStore = {
reloadPlugins: (cwd?: string, sessionId?: string) => Promise<PluginReloadSummary>
enablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
disablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
bulkEnablePlugins: (plugins: PluginActionTarget[], cwd?: string, sessionId?: string) => Promise<number>
bulkDisablePlugins: (plugins: PluginActionTarget[], cwd?: string, sessionId?: string) => Promise<number>
updatePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string, sessionId?: string) => Promise<string>
clearSelection: () => void
}
export type PluginActionTarget = {
id: string
scope?: PluginScope
}
export const usePluginStore = create<PluginStore>((set, get) => ({
plugins: [],
marketplaces: [],
@ -108,6 +115,28 @@ export const usePluginStore = create<PluginStore>((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<PluginStore>) => void,
get: () => PluginStore,
cwd?: string,
sessionId?: string,
): Promise<number> {
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
}
}