From 2b8062dfa3f3f5b35d76b67d417c1c3af596a264 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:07:22 +0300 Subject: [PATCH] fix(extensions): cancel stale plugin success timers --- src/renderer/store/slices/extensionsSlice.ts | 60 ++++++++++++++++---- test/renderer/store/extensionsSlice.test.ts | 42 ++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index cf6a8687..6c207740 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -129,6 +129,7 @@ export interface ExtensionsSlice { let pluginFetchInFlight: { key: string; promise: Promise } | null = null; let pluginCatalogRequestSeq = 0; +const pluginSuccessResetTimers = new Map>(); let mcpDiagnosticsInFlight: Promise | null = null; let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; @@ -175,6 +176,42 @@ function clearPluginOperationState( }; } +function clearPluginSuccessResetTimer(pluginId: string): void { + const timer = pluginSuccessResetTimers.get(pluginId); + if (!timer) { + return; + } + + clearTimeout(timer); + pluginSuccessResetTimers.delete(pluginId); +} + +function clearPluginSuccessResetTimers(pluginIds: Set): void { + for (const pluginId of pluginIds) { + clearPluginSuccessResetTimer(pluginId); + } +} + +function schedulePluginSuccessReset( + pluginId: string, + set: Parameters>[0] +): void { + clearPluginSuccessResetTimer(pluginId); + const timer = setTimeout(() => { + pluginSuccessResetTimers.delete(pluginId); + set((prev) => { + if (prev.pluginInstallProgress[pluginId] !== 'success') { + return {}; + } + + return { + pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, + }; + }); + }, SUCCESS_DISPLAY_MS); + pluginSuccessResetTimers.set(pluginId, timer); +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -269,6 +306,7 @@ export const createExtensionsSlice: StateCreator() : buildPluginIdSet(prev.pluginCatalog) + ); return { pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [], @@ -679,6 +720,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, installErrors: { ...prev.installErrors, [request.pluginId]: preflightError }, @@ -686,6 +728,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, installErrors: { ...prev.installErrors, [request.pluginId]: '' }, @@ -711,13 +754,9 @@ export const createExtensionsSlice: StateCreator { - set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + schedulePluginSuccessReset(request.pluginId, set); } catch (err) { + clearPluginSuccessResetTimer(request.pluginId); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, @@ -735,6 +774,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE }, @@ -742,6 +782,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' }, })); @@ -763,12 +804,9 @@ export const createExtensionsSlice: StateCreator { - set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + schedulePluginSuccessReset(pluginId, set); } catch (err) { + clearPluginSuccessResetTimer(pluginId); const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index aa5eac58..5d9479eb 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -142,6 +142,7 @@ describe('extensionsSlice', () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -490,6 +491,25 @@ describe('extensionsSlice', () => { expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); expect(store.getState().installErrors['project@m']).toContain('active project'); }); + + it('clears older success reset timers before a new operation on the same plugin', async () => { + vi.useFakeTimers(); + store.setState({ cliStatus: makeReadyCliStatus() }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.install as ReturnType) + .mockResolvedValueOnce({ state: 'success' }) + .mockResolvedValueOnce({ state: 'error', error: 'second failure' }); + + await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); + expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + + await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); + expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + }); }); describe('uninstallPlugin', () => { @@ -524,6 +544,28 @@ describe('extensionsSlice', () => { expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); expect(store.getState().installErrors['project@m']).toContain('active project'); }); + + it('does not restore idle state after project switch clears a pending success timer', async () => { + vi.useFakeTimers(); + store.setState({ + pluginCatalogProjectPath: '/tmp/project-a', + pluginCatalog: [makePlugin({ pluginId: 'test@m' })], + }); + (api.plugins!.getAll as ReturnType) + .mockResolvedValueOnce([makePlugin({ pluginId: 'test@m' })]) + .mockResolvedValueOnce([makePlugin({ pluginId: 'other@m' })]); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('test@m', 'user'); + expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + + await store.getState().fetchPluginCatalog('/tmp/project-b'); + expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + }); }); describe('installMcpServer', () => {