diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 9d21f833..cf6a8687 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -145,6 +145,36 @@ function getPluginCatalogKey(projectPath?: string): string { return projectPath ?? '__user__'; } +function buildPluginIdSet(catalog: EnrichedPlugin[]): Set { + return new Set(catalog.map((plugin) => plugin.pluginId)); +} + +function clearPluginOperationState( + pluginIds: Set, + pluginInstallProgress: Record, + installErrors: Record +): { + pluginInstallProgress: Record; + installErrors: Record; +} { + if (pluginIds.size === 0) { + return { pluginInstallProgress, installErrors }; + } + + const nextPluginInstallProgress = { ...pluginInstallProgress }; + const nextInstallErrors = { ...installErrors }; + + for (const pluginId of pluginIds) { + delete nextPluginInstallProgress[pluginId]; + delete nextInstallErrors[pluginId]; + } + + return { + pluginInstallProgress: nextPluginInstallProgress, + installErrors: nextInstallErrors, + }; +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -224,16 +254,29 @@ export const createExtensionsSlice: StateCreator { try { const result = await api.plugins!.getAll(projectPath, forceRefresh); - set(() => { + set((prev) => { if (requestSeq !== pluginCatalogRequestSeq) { return {}; } + const nextProjectPath = projectPath ?? null; + const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath; + const pluginIdsToClear = isSameProjectContext + ? new Set() + : new Set([...buildPluginIdSet(prev.pluginCatalog), ...buildPluginIdSet(result)]); + const nextOperationState = clearPluginOperationState( + pluginIdsToClear, + prev.pluginInstallProgress, + prev.installErrors + ); + return { pluginCatalog: result, pluginCatalogLoading: false, pluginCatalogError: null, - pluginCatalogProjectPath: projectPath ?? null, + pluginCatalogProjectPath: nextProjectPath, + pluginInstallProgress: nextOperationState.pluginInstallProgress, + installErrors: nextOperationState.installErrors, }; }); } catch (err) { @@ -244,12 +287,19 @@ export const createExtensionsSlice: StateCreator() : buildPluginIdSet(prev.pluginCatalog), + prev.pluginInstallProgress, + prev.installErrors + ); return { pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [], pluginCatalogLoading: false, pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', pluginCatalogProjectPath: nextProjectPath, + pluginInstallProgress: nextOperationState.pluginInstallProgress, + installErrors: nextOperationState.installErrors, }; }); } finally { diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 906c89b9..aa5eac58 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -181,6 +181,30 @@ describe('extensionsSlice', () => { expect(store.getState().pluginCatalogError).toBe('boom'); }); + it('clears plugin operation state when switching project context', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + pluginInstallProgress: { + 'project-a@m': 'error', + }, + installErrors: { + 'project-a@m': 'Install failed', + 'mcp-server': 'Keep me', + }, + }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([ + makePlugin({ pluginId: 'project-b@m' }), + ]); + + 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().installErrors['mcp-server']).toBe('Keep me'); + }); + it('dedups concurrent requests for the same project key', async () => { let resolveFetch!: (plugins: EnrichedPlugin[]) => void; const inFlight = new Promise((resolve) => { @@ -235,6 +259,28 @@ describe('extensionsSlice', () => { 'project-b@m', ]); }); + + it('clears plugin operation state when a different project fetch fails', async () => { + store.setState({ + pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], + pluginCatalogProjectPath: '/tmp/project-a', + pluginInstallProgress: { + 'project-a@m': 'error', + }, + installErrors: { + 'project-a@m': 'Install failed', + 'mcp-server': 'Keep me', + }, + }); + (api.plugins!.getAll as ReturnType).mockRejectedValue(new Error('boom')); + + 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().installErrors['mcp-server']).toBe('Keep me'); + }); }); describe('fetchPluginReadme', () => {