mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
a7263cbaf7
commit
0a8f247781
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user