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__';
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue