fix(extensions): scope plugin operation state by install scope
This commit is contained in:
parent
4502152427
commit
57f546bab0
6 changed files with 145 additions and 72 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue