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__';
}
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 {
return projectPath ?? USER_SKILLS_CATALOG_KEY;
}
@ -224,16 +254,29 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const promise = (async () => {
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<string>()
: 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<AppState, [], [], ExtensionsSli
const nextProjectPath = projectPath ?? null;
const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath;
const nextOperationState = clearPluginOperationState(
isSameProjectContext ? new Set<string>() : 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 {

View file

@ -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<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 () => {
let resolveFetch!: (plugins: EnrichedPlugin[]) => void;
const inFlight = new Promise<EnrichedPlugin[]>((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<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', () => {