fix(extensions): reset stale plugin action state on project switch
This commit is contained in:
parent
09b5b4626f
commit
560174d98c
2 changed files with 98 additions and 2 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue