fix(extensions): scope plugin operation state by install scope

This commit is contained in:
777genius 2026-04-16 22:16:38 +03:00
parent 4502152427
commit 57f546bab0
6 changed files with 145 additions and 72 deletions

View file

@ -7,6 +7,7 @@ import { useStore } from '@renderer/store';
import {
getInstallationSummaryLabel,
getCapabilityLabel,
getPluginOperationKey,
hasInstallationInScope,
inferCapabilities,
normalizeCategory,
@ -27,10 +28,11 @@ interface PluginCardProps {
export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => {
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle');
const operationKey = getPluginOperationKey(plugin.pluginId, 'user');
const installProgress = useStore((s) => s.pluginInstallProgress[operationKey] ?? 'idle');
const installPlugin = useStore((s) => s.installPlugin);
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
const installError = useStore((s) => s.installErrors[operationKey]);
const isUserInstalled = hasInstallationInScope(plugin.installations, 'user');
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
const baseStriped = index % 2 === 0;

View file

@ -27,6 +27,7 @@ import { useStore } from '@renderer/store';
import {
getInstallationSummaryLabel,
getCapabilityLabel,
getPluginOperationKey,
hasInstallationInScope,
inferCapabilities,
normalizeCategory,
@ -74,10 +75,6 @@ export const PluginDetailDialog = ({
pluginCatalogProjectPath: s.pluginCatalogProjectPath,
}))
);
const installProgress = useStore(
(s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle'
);
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
const [scope, setScope] = useState<InstallScope>('user');
const projectScopeAvailable = Boolean(pluginCatalogProjectPath);
@ -100,6 +97,12 @@ export const PluginDetailDialog = ({
}
}, [projectScopeAvailable, scope]);
const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null;
const installProgress = useStore(
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
);
const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined));
if (!plugin) return <></>;
const capabilities = inferCapabilities(plugin);

View file

@ -5,6 +5,7 @@
import { api } from '@renderer/api';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { getPluginOperationKey } from '@shared/utils/extensionNormalizers';
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
@ -150,6 +151,10 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
return new Set(catalog.map((plugin) => plugin.pluginId));
}
function buildPluginOperationKeys(pluginId: string): string[] {
return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope));
}
function clearPluginOperationState(
pluginIds: Set<string>,
pluginInstallProgress: Record<string, ExtensionOperationState>,
@ -166,8 +171,10 @@ function clearPluginOperationState(
const nextInstallErrors = { ...installErrors };
for (const pluginId of pluginIds) {
delete nextPluginInstallProgress[pluginId];
delete nextInstallErrors[pluginId];
for (const operationKey of buildPluginOperationKeys(pluginId)) {
delete nextPluginInstallProgress[operationKey];
delete nextInstallErrors[operationKey];
}
}
return {
@ -176,40 +183,42 @@ function clearPluginOperationState(
};
}
function clearPluginSuccessResetTimer(pluginId: string): void {
const timer = pluginSuccessResetTimers.get(pluginId);
function clearPluginSuccessResetTimer(operationKey: string): void {
const timer = pluginSuccessResetTimers.get(operationKey);
if (!timer) {
return;
}
clearTimeout(timer);
pluginSuccessResetTimers.delete(pluginId);
pluginSuccessResetTimers.delete(operationKey);
}
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
for (const pluginId of pluginIds) {
clearPluginSuccessResetTimer(pluginId);
for (const operationKey of buildPluginOperationKeys(pluginId)) {
clearPluginSuccessResetTimer(operationKey);
}
}
}
function schedulePluginSuccessReset(
pluginId: string,
operationKey: string,
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
): void {
clearPluginSuccessResetTimer(pluginId);
clearPluginSuccessResetTimer(operationKey);
const timer = setTimeout(() => {
pluginSuccessResetTimers.delete(pluginId);
pluginSuccessResetTimers.delete(operationKey);
set((prev) => {
if (prev.pluginInstallProgress[pluginId] !== 'success') {
if (prev.pluginInstallProgress[operationKey] !== 'success') {
return {};
}
return {
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'idle' },
};
});
}, SUCCESS_DISPLAY_MS);
pluginSuccessResetTimers.set(pluginId, timer);
pluginSuccessResetTimers.set(operationKey, timer);
}
function getSkillsCatalogKey(projectPath?: string): string {
@ -226,6 +235,7 @@ const CLI_STATUS_UNKNOWN_MESSAGE =
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
const PROJECT_SCOPE_REQUIRED_MESSAGE =
'Project- and local-scoped plugins require an active project in the Extensions tab.';
const PLUGIN_OPERATION_SCOPES: InstallScope[] = ['user', 'project', 'local'];
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set,
@ -691,6 +701,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
request.scope !== 'user'
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: request.projectPath;
const operationKey = getPluginOperationKey(request.pluginId, request.scope);
const effectiveRequest =
effectiveProjectPath === request.projectPath
? request
@ -720,47 +731,47 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
: null;
if (preflightError) {
clearPluginSuccessResetTimer(request.pluginId);
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: preflightError },
}));
return;
}
clearPluginSuccessResetTimer(request.pluginId);
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
installErrors: { ...prev.installErrors, [operationKey]: '' },
}));
try {
const result = await api.plugins.install(effectiveRequest);
if (result.state === 'error') {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: {
...prev.installErrors,
[request.pluginId]: result.error ?? 'Install failed',
[operationKey]: result.error ?? 'Install failed',
},
}));
return;
}
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'success' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' },
}));
// Refresh catalog to pick up new installed state
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
schedulePluginSuccessReset(request.pluginId, set);
schedulePluginSuccessReset(operationKey, set);
} catch (err) {
clearPluginSuccessResetTimer(request.pluginId);
clearPluginSuccessResetTimer(operationKey);
const message = err instanceof Error ? err.message : 'Install failed';
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
installErrors: { ...prev.installErrors, [request.pluginId]: message },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: message },
}));
}
},
@ -769,48 +780,53 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
if (!api.plugins) return;
const effectiveScope = scope ?? 'user';
const operationKey = getPluginOperationKey(pluginId, effectiveScope);
const effectiveProjectPath =
scope && scope !== 'user'
effectiveScope !== 'user'
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: projectPath;
if (scope && scope !== 'user' && !effectiveProjectPath) {
clearPluginSuccessResetTimer(pluginId);
if (effectiveScope !== 'user' && !effectiveProjectPath) {
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: PROJECT_SCOPE_REQUIRED_MESSAGE },
}));
return;
}
clearPluginSuccessResetTimer(pluginId);
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
}));
try {
const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath);
if (result.state === 'error') {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
installErrors: { ...prev.installErrors, [pluginId]: result.error ?? 'Uninstall failed' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: {
...prev.installErrors,
[operationKey]: result.error ?? 'Uninstall failed',
},
}));
return;
}
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'success' },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' },
}));
// Refresh catalog
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
schedulePluginSuccessReset(pluginId, set);
schedulePluginSuccessReset(operationKey, set);
} catch (err) {
clearPluginSuccessResetTimer(pluginId);
clearPluginSuccessResetTimer(operationKey);
const message = err instanceof Error ? err.message : 'Uninstall failed';
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
installErrors: { ...prev.installErrors, [pluginId]: message },
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [operationKey]: message },
}));
}
},

View file

@ -100,6 +100,13 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
return `${pluginName}@${marketplaceName}`;
}
/**
* Namespaced operation-state key for plugin install/uninstall UI state.
*/
export function getPluginOperationKey(pluginId: string, scope: InstallScope): string {
return `plugin:${pluginId}:${scope}`;
}
/**
* Check whether a plugin has an installation for the selected scope.
*/

View file

@ -40,6 +40,7 @@ vi.mock('../../../src/renderer/api', () => ({
}));
import { api } from '../../../src/renderer/api';
import { getPluginOperationKey } from '../../../src/shared/utils/extensionNormalizers';
import type {
EnrichedPlugin,
@ -133,6 +134,9 @@ const makeReadyCliStatus = () => ({
providers: [],
});
const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') =>
getPluginOperationKey(pluginId, scope);
describe('extensionsSlice', () => {
let store: TestStore;
@ -187,10 +191,10 @@ describe('extensionsSlice', () => {
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
pluginCatalogProjectPath: '/tmp/project-a',
pluginInstallProgress: {
'project-a@m': 'error',
[pluginOperationKey('project-a@m', 'project')]: 'error',
},
installErrors: {
'project-a@m': 'Install failed',
[pluginOperationKey('project-a@m', 'project')]: 'Install failed',
'mcp-server': 'Keep me',
},
});
@ -201,8 +205,10 @@ describe('extensionsSlice', () => {
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().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')],
).toBeUndefined();
expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined();
expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
});
@ -266,10 +272,10 @@ describe('extensionsSlice', () => {
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
pluginCatalogProjectPath: '/tmp/project-a',
pluginInstallProgress: {
'project-a@m': 'error',
[pluginOperationKey('project-a@m', 'project')]: 'error',
},
installErrors: {
'project-a@m': 'Install failed',
[pluginOperationKey('project-a@m', 'project')]: 'Install failed',
'mcp-server': 'Keep me',
},
});
@ -278,8 +284,10 @@ describe('extensionsSlice', () => {
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().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')],
).toBeUndefined();
expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined();
expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
});
});
@ -448,10 +456,10 @@ describe('extensionsSlice', () => {
const promise = store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
// During execution, should be pending
expect(store.getState().pluginInstallProgress['test@m']).toBe('pending');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending');
await promise;
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
});
it('sets progress to error on failure', async () => {
@ -463,7 +471,7 @@ describe('extensionsSlice', () => {
await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' });
expect(store.getState().pluginInstallProgress['fail@m']).toBe('error');
expect(store.getState().pluginInstallProgress[pluginOperationKey('fail@m')]).toBe('error');
});
it('fills missing projectPath from the active Extensions project context', async () => {
@ -488,8 +496,12 @@ describe('extensionsSlice', () => {
await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' });
expect(api.plugins!.install).not.toHaveBeenCalled();
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
expect(store.getState().installErrors['project@m']).toContain('active project');
expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe(
'error',
);
expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain(
'active project',
);
});
it('fills missing projectPath for local scope from the active Extensions project context', async () => {
@ -514,8 +526,24 @@ describe('extensionsSlice', () => {
await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' });
expect(api.plugins!.install).not.toHaveBeenCalled();
expect(store.getState().pluginInstallProgress['local@m']).toBe('error');
expect(store.getState().installErrors['local@m']).toContain('active project');
expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe(
'error',
);
expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain(
'active project',
);
});
it('keeps user-scope state isolated from local-scope failures', async () => {
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
await store.getState().installPlugin({ pluginId: 'shared@m', scope: 'local' });
expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'local')]).toBe(
'error',
);
expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'user')]).toBeUndefined();
expect(store.getState().installErrors[pluginOperationKey('shared@m', 'user')]).toBeUndefined();
});
it('clears older success reset timers before a new operation on the same plugin', async () => {
@ -527,14 +555,14 @@ describe('extensionsSlice', () => {
.mockResolvedValueOnce({ state: 'error', error: 'second failure' });
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
expect(store.getState().pluginInstallProgress['test@m']).toBe('error');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error');
await vi.advanceTimersByTimeAsync(2_000);
expect(store.getState().pluginInstallProgress['test@m']).toBe('error');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error');
});
});
@ -546,10 +574,10 @@ describe('extensionsSlice', () => {
const promise = store.getState().uninstallPlugin('test@m', 'user');
expect(store.getState().pluginInstallProgress['test@m']).toBe('pending');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending');
await promise;
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
});
it('fills missing projectPath from the active Extensions project context', async () => {
@ -567,8 +595,12 @@ describe('extensionsSlice', () => {
await store.getState().uninstallPlugin('project@m', 'project');
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
expect(store.getState().installErrors['project@m']).toContain('active project');
expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe(
'error',
);
expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain(
'active project',
);
});
it('fills missing projectPath for local uninstall from the active Extensions project context', async () => {
@ -586,8 +618,12 @@ describe('extensionsSlice', () => {
await store.getState().uninstallPlugin('local@m', 'local');
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
expect(store.getState().pluginInstallProgress['local@m']).toBe('error');
expect(store.getState().installErrors['local@m']).toContain('active project');
expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe(
'error',
);
expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain(
'active project',
);
});
it('does not restore idle state after project switch clears a pending success timer', async () => {
@ -602,14 +638,14 @@ describe('extensionsSlice', () => {
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
await store.getState().uninstallPlugin('test@m', 'user');
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
await store.getState().fetchPluginCatalog('/tmp/project-b');
expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined();
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined();
await vi.advanceTimersByTimeAsync(2_000);
expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined();
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined();
});
});

View file

@ -8,6 +8,7 @@ import {
getExtensionActionDisableReason,
getCapabilityLabel,
getInstallationSummaryLabel,
getPluginOperationKey,
getPrimaryCapabilityLabel,
hasInstallationInScope,
inferCapabilities,
@ -152,6 +153,14 @@ describe('buildPluginId', () => {
});
});
describe('getPluginOperationKey', () => {
it('namespaces plugin operation keys by scope', () => {
expect(getPluginOperationKey('context7@claude-plugins-official', 'local')).toBe(
'plugin:context7@claude-plugins-official:local',
);
});
});
describe('hasInstallationInScope', () => {
it('returns true when the selected scope exists', () => {
expect(