fix(extensions): reset stale plugin action state on project switch

This commit is contained in:
777genius 2026-04-16 22:05:28 +03:00
parent 09b5b4626f
commit 560174d98c
2 changed files with 98 additions and 2 deletions

View file

@ -145,6 +145,36 @@ function getPluginCatalogKey(projectPath?: string): string {
return projectPath ?? '__user__'; return projectPath ?? '__user__';
} }
function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
return new Set(catalog.map((plugin) => plugin.pluginId));
}
function clearPluginOperationState(
pluginIds: Set<string>,
pluginInstallProgress: Record<string, ExtensionOperationState>,
installErrors: Record<string, string>
): {
pluginInstallProgress: Record<string, ExtensionOperationState>;
installErrors: Record<string, string>;
} {
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 { function getSkillsCatalogKey(projectPath?: string): string {
return projectPath ?? USER_SKILLS_CATALOG_KEY; return projectPath ?? USER_SKILLS_CATALOG_KEY;
} }
@ -224,16 +254,29 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const promise = (async () => { const promise = (async () => {
try { try {
const result = await api.plugins!.getAll(projectPath, forceRefresh); const result = await api.plugins!.getAll(projectPath, forceRefresh);
set(() => { set((prev) => {
if (requestSeq !== pluginCatalogRequestSeq) { if (requestSeq !== pluginCatalogRequestSeq) {
return {}; return {};
} }
const nextProjectPath = projectPath ?? null;
const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath;
const pluginIdsToClear = isSameProjectContext
? new Set<string>()
: new Set([...buildPluginIdSet(prev.pluginCatalog), ...buildPluginIdSet(result)]);
const nextOperationState = clearPluginOperationState(
pluginIdsToClear,
prev.pluginInstallProgress,
prev.installErrors
);
return { return {
pluginCatalog: result, pluginCatalog: result,
pluginCatalogLoading: false, pluginCatalogLoading: false,
pluginCatalogError: null, pluginCatalogError: null,
pluginCatalogProjectPath: projectPath ?? null, pluginCatalogProjectPath: nextProjectPath,
pluginInstallProgress: nextOperationState.pluginInstallProgress,
installErrors: nextOperationState.installErrors,
}; };
}); });
} catch (err) { } catch (err) {
@ -244,12 +287,19 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const nextProjectPath = projectPath ?? null; const nextProjectPath = projectPath ?? null;
const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath; const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath;
const nextOperationState = clearPluginOperationState(
isSameProjectContext ? new Set<string>() : buildPluginIdSet(prev.pluginCatalog),
prev.pluginInstallProgress,
prev.installErrors
);
return { return {
pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [], pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [],
pluginCatalogLoading: false, pluginCatalogLoading: false,
pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins', pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins',
pluginCatalogProjectPath: nextProjectPath, pluginCatalogProjectPath: nextProjectPath,
pluginInstallProgress: nextOperationState.pluginInstallProgress,
installErrors: nextOperationState.installErrors,
}; };
}); });
} finally { } finally {

View file

@ -181,6 +181,30 @@ describe('extensionsSlice', () => {
expect(store.getState().pluginCatalogError).toBe('boom'); 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<typeof vi.fn>).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 () => { it('dedups concurrent requests for the same project key', async () => {
let resolveFetch!: (plugins: EnrichedPlugin[]) => void; let resolveFetch!: (plugins: EnrichedPlugin[]) => void;
const inFlight = new Promise<EnrichedPlugin[]>((resolve) => { const inFlight = new Promise<EnrichedPlugin[]>((resolve) => {
@ -235,6 +259,28 @@ describe('extensionsSlice', () => {
'project-b@m', '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<typeof vi.fn>).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', () => { describe('fetchPluginReadme', () => {