From 57f546bab0ddf3a6126cbe1591a3264b161b0560 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:16:38 +0300 Subject: [PATCH] fix(extensions): scope plugin operation state by install scope --- .../extensions/plugins/PluginCard.tsx | 6 +- .../extensions/plugins/PluginDetailDialog.tsx | 11 ++- src/renderer/store/slices/extensionsSlice.ts | 94 +++++++++++-------- src/shared/utils/extensionNormalizers.ts | 7 ++ test/renderer/store/extensionsSlice.test.ts | 90 ++++++++++++------ .../shared/utils/extensionNormalizers.test.ts | 9 ++ 6 files changed, 145 insertions(+), 72 deletions(-) diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index 24ca2d87..deef088f 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -7,6 +7,7 @@ import { useStore } from '@renderer/store'; import { getInstallationSummaryLabel, getCapabilityLabel, + getPluginOperationKey, hasInstallationInScope, inferCapabilities, normalizeCategory, @@ -27,10 +28,11 @@ interface PluginCardProps { export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => { const capabilities = inferCapabilities(plugin); const category = normalizeCategory(plugin.category); - const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle'); + const operationKey = getPluginOperationKey(plugin.pluginId, 'user'); + const installProgress = useStore((s) => s.pluginInstallProgress[operationKey] ?? 'idle'); const installPlugin = useStore((s) => s.installPlugin); const uninstallPlugin = useStore((s) => s.uninstallPlugin); - const installError = useStore((s) => s.installErrors[plugin.pluginId]); + const installError = useStore((s) => s.installErrors[operationKey]); const isUserInstalled = hasInstallationInScope(plugin.installations, 'user'); const installSummaryLabel = getInstallationSummaryLabel(plugin.installations); const baseStriped = index % 2 === 0; diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 8d6e05ac..732e9ca6 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -27,6 +27,7 @@ import { useStore } from '@renderer/store'; import { getInstallationSummaryLabel, getCapabilityLabel, + getPluginOperationKey, hasInstallationInScope, inferCapabilities, normalizeCategory, @@ -74,10 +75,6 @@ export const PluginDetailDialog = ({ pluginCatalogProjectPath: s.pluginCatalogProjectPath, })) ); - const installProgress = useStore( - (s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle' - ); - const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined)); const [scope, setScope] = useState('user'); const projectScopeAvailable = Boolean(pluginCatalogProjectPath); @@ -100,6 +97,12 @@ export const PluginDetailDialog = ({ } }, [projectScopeAvailable, scope]); + const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null; + const installProgress = useStore( + (s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle' + ); + const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined)); + if (!plugin) return <>; const capabilities = inferCapabilities(plugin); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 7932eeeb..fdea0a79 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -5,6 +5,7 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; +import { getPluginOperationKey } from '@shared/utils/extensionNormalizers'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; @@ -150,6 +151,10 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set { return new Set(catalog.map((plugin) => plugin.pluginId)); } +function buildPluginOperationKeys(pluginId: string): string[] { + return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope)); +} + function clearPluginOperationState( pluginIds: Set, pluginInstallProgress: Record, @@ -166,8 +171,10 @@ function clearPluginOperationState( const nextInstallErrors = { ...installErrors }; for (const pluginId of pluginIds) { - delete nextPluginInstallProgress[pluginId]; - delete nextInstallErrors[pluginId]; + for (const operationKey of buildPluginOperationKeys(pluginId)) { + delete nextPluginInstallProgress[operationKey]; + delete nextInstallErrors[operationKey]; + } } return { @@ -176,40 +183,42 @@ function clearPluginOperationState( }; } -function clearPluginSuccessResetTimer(pluginId: string): void { - const timer = pluginSuccessResetTimers.get(pluginId); +function clearPluginSuccessResetTimer(operationKey: string): void { + const timer = pluginSuccessResetTimers.get(operationKey); if (!timer) { return; } clearTimeout(timer); - pluginSuccessResetTimers.delete(pluginId); + pluginSuccessResetTimers.delete(operationKey); } function clearPluginSuccessResetTimers(pluginIds: Set): void { for (const pluginId of pluginIds) { - clearPluginSuccessResetTimer(pluginId); + for (const operationKey of buildPluginOperationKeys(pluginId)) { + clearPluginSuccessResetTimer(operationKey); + } } } function schedulePluginSuccessReset( - pluginId: string, + operationKey: string, set: Parameters>[0] ): void { - clearPluginSuccessResetTimer(pluginId); + clearPluginSuccessResetTimer(operationKey); const timer = setTimeout(() => { - pluginSuccessResetTimers.delete(pluginId); + pluginSuccessResetTimers.delete(operationKey); set((prev) => { - if (prev.pluginInstallProgress[pluginId] !== 'success') { + if (prev.pluginInstallProgress[operationKey] !== 'success') { return {}; } return { - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'idle' }, }; }); }, SUCCESS_DISPLAY_MS); - pluginSuccessResetTimers.set(pluginId, timer); + pluginSuccessResetTimers.set(operationKey, timer); } function getSkillsCatalogKey(projectPath?: string): string { @@ -226,6 +235,7 @@ const CLI_STATUS_UNKNOWN_MESSAGE = 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; const PROJECT_SCOPE_REQUIRED_MESSAGE = 'Project- and local-scoped plugins require an active project in the Extensions tab.'; +const PLUGIN_OPERATION_SCOPES: InstallScope[] = ['user', 'project', 'local']; export const createExtensionsSlice: StateCreator = ( set, @@ -691,6 +701,7 @@ export const createExtensionsSlice: StateCreator ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [request.pluginId]: preflightError }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: preflightError }, })); return; } - clearPluginSuccessResetTimer(request.pluginId); + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, - installErrors: { ...prev.installErrors, [request.pluginId]: '' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' }, + installErrors: { ...prev.installErrors, [operationKey]: '' }, })); try { const result = await api.plugins.install(effectiveRequest); if (result.state === 'error') { set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, installErrors: { ...prev.installErrors, - [request.pluginId]: result.error ?? 'Install failed', + [operationKey]: result.error ?? 'Install failed', }, })); return; } set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'success' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' }, })); // Refresh catalog to pick up new installed state void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true); - schedulePluginSuccessReset(request.pluginId, set); + schedulePluginSuccessReset(operationKey, set); } catch (err) { - clearPluginSuccessResetTimer(request.pluginId); + clearPluginSuccessResetTimer(operationKey); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [request.pluginId]: message }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: message }, })); } }, @@ -769,48 +780,53 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const effectiveScope = scope ?? 'user'; + const operationKey = getPluginOperationKey(pluginId, effectiveScope); const effectiveProjectPath = - scope && scope !== 'user' + effectiveScope !== 'user' ? (projectPath ?? get().pluginCatalogProjectPath ?? undefined) : projectPath; - if (scope && scope !== 'user' && !effectiveProjectPath) { - clearPluginSuccessResetTimer(pluginId); + if (effectiveScope !== 'user' && !effectiveProjectPath) { + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: PROJECT_SCOPE_REQUIRED_MESSAGE }, })); return; } - clearPluginSuccessResetTimer(pluginId); + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' }, })); try { const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath); if (result.state === 'error') { set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [pluginId]: result.error ?? 'Uninstall failed' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { + ...prev.installErrors, + [operationKey]: result.error ?? 'Uninstall failed', + }, })); return; } set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'success' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' }, })); // Refresh catalog void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true); - schedulePluginSuccessReset(pluginId, set); + schedulePluginSuccessReset(operationKey, set); } catch (err) { - clearPluginSuccessResetTimer(pluginId); + clearPluginSuccessResetTimer(operationKey); const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [pluginId]: message }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: message }, })); } }, diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 7684925a..a5c6420e 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -100,6 +100,13 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri return `${pluginName}@${marketplaceName}`; } +/** + * Namespaced operation-state key for plugin install/uninstall UI state. + */ +export function getPluginOperationKey(pluginId: string, scope: InstallScope): string { + return `plugin:${pluginId}:${scope}`; +} + /** * Check whether a plugin has an installation for the selected scope. */ diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 058b32d6..8e2a0c8b 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -40,6 +40,7 @@ vi.mock('../../../src/renderer/api', () => ({ })); import { api } from '../../../src/renderer/api'; +import { getPluginOperationKey } from '../../../src/shared/utils/extensionNormalizers'; import type { EnrichedPlugin, @@ -133,6 +134,9 @@ const makeReadyCliStatus = () => ({ providers: [], }); +const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => + getPluginOperationKey(pluginId, scope); + describe('extensionsSlice', () => { let store: TestStore; @@ -187,10 +191,10 @@ describe('extensionsSlice', () => { pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], pluginCatalogProjectPath: '/tmp/project-a', pluginInstallProgress: { - 'project-a@m': 'error', + [pluginOperationKey('project-a@m', 'project')]: 'error', }, installErrors: { - 'project-a@m': 'Install failed', + [pluginOperationKey('project-a@m', 'project')]: 'Install failed', 'mcp-server': 'Keep me', }, }); @@ -201,8 +205,10 @@ describe('extensionsSlice', () => { await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); - expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); - expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect( + store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')], + ).toBeUndefined(); + expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined(); expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); }); @@ -266,10 +272,10 @@ describe('extensionsSlice', () => { pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], pluginCatalogProjectPath: '/tmp/project-a', pluginInstallProgress: { - 'project-a@m': 'error', + [pluginOperationKey('project-a@m', 'project')]: 'error', }, installErrors: { - 'project-a@m': 'Install failed', + [pluginOperationKey('project-a@m', 'project')]: 'Install failed', 'mcp-server': 'Keep me', }, }); @@ -278,8 +284,10 @@ describe('extensionsSlice', () => { await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginCatalog).toEqual([]); - expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); - expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect( + store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')], + ).toBeUndefined(); + expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined(); expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); }); }); @@ -448,10 +456,10 @@ describe('extensionsSlice', () => { const promise = store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); // During execution, should be pending - expect(store.getState().pluginInstallProgress['test@m']).toBe('pending'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending'); await promise; - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); }); it('sets progress to error on failure', async () => { @@ -463,7 +471,7 @@ describe('extensionsSlice', () => { await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' }); - expect(store.getState().pluginInstallProgress['fail@m']).toBe('error'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('fail@m')]).toBe('error'); }); it('fills missing projectPath from the active Extensions project context', async () => { @@ -488,8 +496,12 @@ describe('extensionsSlice', () => { await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' }); expect(api.plugins!.install).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); - expect(store.getState().installErrors['project@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain( + 'active project', + ); }); it('fills missing projectPath for local scope from the active Extensions project context', async () => { @@ -514,8 +526,24 @@ describe('extensionsSlice', () => { await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); expect(api.plugins!.install).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); - expect(store.getState().installErrors['local@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain( + 'active project', + ); + }); + + it('keeps user-scope state isolated from local-scope failures', async () => { + store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); + + await store.getState().installPlugin({ pluginId: 'shared@m', scope: 'local' }); + + expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'local')]).toBe( + 'error', + ); + expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'user')]).toBeUndefined(); + expect(store.getState().installErrors[pluginOperationKey('shared@m', 'user')]).toBeUndefined(); }); it('clears older success reset timers before a new operation on the same plugin', async () => { @@ -527,14 +555,14 @@ describe('extensionsSlice', () => { .mockResolvedValueOnce({ state: 'error', error: 'second failure' }); await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); - expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error'); await vi.advanceTimersByTimeAsync(2_000); - expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error'); }); }); @@ -546,10 +574,10 @@ describe('extensionsSlice', () => { const promise = store.getState().uninstallPlugin('test@m', 'user'); - expect(store.getState().pluginInstallProgress['test@m']).toBe('pending'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending'); await promise; - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); }); it('fills missing projectPath from the active Extensions project context', async () => { @@ -567,8 +595,12 @@ describe('extensionsSlice', () => { await store.getState().uninstallPlugin('project@m', 'project'); expect(api.plugins!.uninstall).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); - expect(store.getState().installErrors['project@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain( + 'active project', + ); }); it('fills missing projectPath for local uninstall from the active Extensions project context', async () => { @@ -586,8 +618,12 @@ describe('extensionsSlice', () => { await store.getState().uninstallPlugin('local@m', 'local'); expect(api.plugins!.uninstall).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); - expect(store.getState().installErrors['local@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain( + 'active project', + ); }); it('does not restore idle state after project switch clears a pending success timer', async () => { @@ -602,14 +638,14 @@ describe('extensionsSlice', () => { (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().uninstallPlugin('test@m', 'user'); - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); await store.getState().fetchPluginCatalog('/tmp/project-b'); - expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined(); await vi.advanceTimersByTimeAsync(2_000); - expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined(); }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 7905fd99..427a22bb 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -8,6 +8,7 @@ import { getExtensionActionDisableReason, getCapabilityLabel, getInstallationSummaryLabel, + getPluginOperationKey, getPrimaryCapabilityLabel, hasInstallationInScope, inferCapabilities, @@ -152,6 +153,14 @@ describe('buildPluginId', () => { }); }); +describe('getPluginOperationKey', () => { + it('namespaces plugin operation keys by scope', () => { + expect(getPluginOperationKey('context7@claude-plugins-official', 'local')).toBe( + 'plugin:context7@claude-plugins-official:local', + ); + }); +}); + describe('hasInstallationInScope', () => { it('returns true when the selected scope exists', () => { expect(