diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 298788c7..e7aedff4 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -91,7 +91,9 @@ export const PluginDetailDialog = ({ } }, [projectScopeAvailable, scope]); - const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null; + const operationKey = plugin + ? getPluginOperationKey(plugin.pluginId, scope, scope !== 'user' ? projectPath : undefined) + : null; const installProgress = useStore( (s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle' ); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 20dbae3b..4ae2855a 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -163,8 +163,8 @@ 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 isPluginOperationKeyForPlugin(operationKey: string, pluginId: string): boolean { + return operationKey.startsWith(`plugin:${pluginId}:`); } function clearPluginOperationState( @@ -181,10 +181,16 @@ function clearPluginOperationState( const nextPluginInstallProgress = { ...pluginInstallProgress }; const nextInstallErrors = { ...installErrors }; + const pluginIdsList = Array.from(pluginIds); - for (const pluginId of pluginIds) { - for (const operationKey of buildPluginOperationKeys(pluginId)) { + for (const operationKey of Object.keys(nextPluginInstallProgress)) { + if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) { delete nextPluginInstallProgress[operationKey]; + } + } + + for (const operationKey of Object.keys(nextInstallErrors)) { + if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) { delete nextInstallErrors[operationKey]; } } @@ -206,8 +212,9 @@ function clearPluginSuccessResetTimer(operationKey: string): void { } function clearPluginSuccessResetTimers(pluginIds: Set): void { - for (const pluginId of pluginIds) { - for (const operationKey of buildPluginOperationKeys(pluginId)) { + const pluginIdsList = Array.from(pluginIds); + for (const operationKey of Array.from(pluginSuccessResetTimers.keys())) { + if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) { clearPluginSuccessResetTimer(operationKey); } } @@ -339,8 +346,6 @@ 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, get @@ -865,11 +870,16 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined; const effectiveProjectPath = request.scope !== 'user' ? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined) : request.projectPath; - const operationKey = getPluginOperationKey(request.pluginId, request.scope); + const operationKey = getPluginOperationKey( + request.pluginId, + request.scope, + effectiveProjectPath + ); const effectiveRequest = effectiveProjectPath === request.projectPath ? request @@ -931,7 +941,12 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined; const effectiveScope = scope ?? 'user'; - const operationKey = getPluginOperationKey(pluginId, effectiveScope); const effectiveProjectPath = effectiveScope !== 'user' ? (projectPath ?? get().pluginCatalogProjectPath ?? undefined) : projectPath; + const operationKey = getPluginOperationKey(pluginId, effectiveScope, effectiveProjectPath); if (effectiveScope !== 'user' && !effectiveProjectPath) { clearPluginSuccessResetTimer(operationKey); set((prev) => ({ @@ -986,7 +1002,10 @@ export const createExtensionsSlice: StateCreator ({ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ InstallButton: ({ + state, + errorMessage, isInstalled, onInstall, onUninstall, }: { + state?: string; + errorMessage?: string; isInstalled: boolean; onInstall: () => void; onUninstall: () => void; @@ -124,6 +128,8 @@ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ { type: 'button', 'data-testid': 'install-button', + 'data-state': state, + 'data-error-message': errorMessage, onClick: () => (isInstalled ? onUninstall() : onInstall()), }, isInstalled ? 'Uninstall' : 'Install' @@ -150,6 +156,7 @@ vi.mock('lucide-react', () => { }); import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog'; +import { getPluginOperationKey } from '@shared/utils/extensionNormalizers'; const makePlugin = (): EnrichedPlugin => ({ pluginId: 'context7@claude-plugins-official', @@ -270,4 +277,49 @@ describe('PluginDetailDialog project context', () => { await Promise.resolve(); }); }); + + it('reads project-scope action state from the current tab project path', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const plugin = makePlugin(); + + storeState.pluginInstallProgress = { + [getPluginOperationKey(plugin.pluginId, 'project', '/tmp/tab-project')]: 'pending', + }; + storeState.installErrors = { + [getPluginOperationKey(plugin.pluginId, 'project', '/tmp/other-project')]: 'Wrong project', + }; + + await act(async () => { + root.render( + React.createElement(PluginDetailDialog, { + plugin, + open: true, + onClose: vi.fn(), + projectPath: '/tmp/tab-project', + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(scopeSelect).not.toBeNull(); + expect(installButton).not.toBeNull(); + + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(installButton.getAttribute('data-state')).toBe('pending'); + expect(installButton.getAttribute('data-error-message')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 350e074c..065b6555 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -153,8 +153,11 @@ const makeReadyCliStatus = () => ({ providers: [], }); -const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => - getPluginOperationKey(pluginId, scope); +const pluginOperationKey = ( + pluginId: string, + scope: 'user' | 'project' | 'local' = 'user', + projectPath?: string +) => getPluginOperationKey(pluginId, scope, projectPath); const mcpOperationKey = ( registryId: string, scope: 'user' | 'project' | 'local' | 'global' = 'user', @@ -574,6 +577,33 @@ describe('extensionsSlice', () => { }); }); + it('keys project-scope install state by project path and refreshes that same project context', async () => { + store.setState({ + cliStatus: makeReadyCliStatus(), + pluginCatalogProjectPath: '/tmp/project-b', + }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().installPlugin({ + pluginId: 'project@m', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-a') + ] + ).toBe('success'); + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-b') + ] + ).toBeUndefined(); + expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true); + }); + it('fails fast for project scope when there is no active project path', async () => { store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); @@ -673,6 +703,26 @@ describe('extensionsSlice', () => { expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a'); }); + it('keys project-scope uninstall state by project path and refreshes that same project context', async () => { + store.setState({ pluginCatalogProjectPath: '/tmp/project-b' }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('project@m', 'project', '/tmp/project-a'); + + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-a') + ] + ).toBe('success'); + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-b') + ] + ).toBeUndefined(); + expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true); + }); + it('fails fast for project uninstall when there is no active project path', async () => { store.setState({ pluginCatalogProjectPath: null }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 979c318d..7e37218e 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -157,11 +157,17 @@ 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', + it('namespaces user-scope plugin operation keys without a project suffix', () => { + expect(getPluginOperationKey('context7@claude-plugins-official', 'user')).toBe( + 'plugin:context7@claude-plugins-official:user', ); }); + + it('namespaces repo-scoped plugin operation keys by project path', () => { + expect( + getPluginOperationKey('context7@claude-plugins-official', 'local', '/tmp/project'), + ).toBe('plugin:context7@claude-plugins-official:local:/tmp/project'); + }); }); describe('getMcpOperationKey', () => {