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 {
|
import {
|
||||||
getInstallationSummaryLabel,
|
getInstallationSummaryLabel,
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
|
getPluginOperationKey,
|
||||||
hasInstallationInScope,
|
hasInstallationInScope,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
|
|
@ -27,10 +28,11 @@ interface PluginCardProps {
|
||||||
export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => {
|
export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => {
|
||||||
const capabilities = inferCapabilities(plugin);
|
const capabilities = inferCapabilities(plugin);
|
||||||
const category = normalizeCategory(plugin.category);
|
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 installPlugin = useStore((s) => s.installPlugin);
|
||||||
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
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 isUserInstalled = hasInstallationInScope(plugin.installations, 'user');
|
||||||
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
|
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
|
||||||
const baseStriped = index % 2 === 0;
|
const baseStriped = index % 2 === 0;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getInstallationSummaryLabel,
|
getInstallationSummaryLabel,
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
|
getPluginOperationKey,
|
||||||
hasInstallationInScope,
|
hasInstallationInScope,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
|
|
@ -74,10 +75,6 @@ export const PluginDetailDialog = ({
|
||||||
pluginCatalogProjectPath: s.pluginCatalogProjectPath,
|
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 [scope, setScope] = useState<InstallScope>('user');
|
||||||
const projectScopeAvailable = Boolean(pluginCatalogProjectPath);
|
const projectScopeAvailable = Boolean(pluginCatalogProjectPath);
|
||||||
|
|
@ -100,6 +97,12 @@ export const PluginDetailDialog = ({
|
||||||
}
|
}
|
||||||
}, [projectScopeAvailable, scope]);
|
}, [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 <></>;
|
if (!plugin) return <></>;
|
||||||
|
|
||||||
const capabilities = inferCapabilities(plugin);
|
const capabilities = inferCapabilities(plugin);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
|
import { getPluginOperationKey } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
||||||
|
|
||||||
|
|
@ -150,6 +151,10 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
|
||||||
return new Set(catalog.map((plugin) => plugin.pluginId));
|
return new Set(catalog.map((plugin) => plugin.pluginId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPluginOperationKeys(pluginId: string): string[] {
|
||||||
|
return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope));
|
||||||
|
}
|
||||||
|
|
||||||
function clearPluginOperationState(
|
function clearPluginOperationState(
|
||||||
pluginIds: Set<string>,
|
pluginIds: Set<string>,
|
||||||
pluginInstallProgress: Record<string, ExtensionOperationState>,
|
pluginInstallProgress: Record<string, ExtensionOperationState>,
|
||||||
|
|
@ -166,8 +171,10 @@ function clearPluginOperationState(
|
||||||
const nextInstallErrors = { ...installErrors };
|
const nextInstallErrors = { ...installErrors };
|
||||||
|
|
||||||
for (const pluginId of pluginIds) {
|
for (const pluginId of pluginIds) {
|
||||||
delete nextPluginInstallProgress[pluginId];
|
for (const operationKey of buildPluginOperationKeys(pluginId)) {
|
||||||
delete nextInstallErrors[pluginId];
|
delete nextPluginInstallProgress[operationKey];
|
||||||
|
delete nextInstallErrors[operationKey];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -176,40 +183,42 @@ function clearPluginOperationState(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPluginSuccessResetTimer(pluginId: string): void {
|
function clearPluginSuccessResetTimer(operationKey: string): void {
|
||||||
const timer = pluginSuccessResetTimers.get(pluginId);
|
const timer = pluginSuccessResetTimers.get(operationKey);
|
||||||
if (!timer) {
|
if (!timer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
pluginSuccessResetTimers.delete(pluginId);
|
pluginSuccessResetTimers.delete(operationKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
||||||
for (const pluginId of pluginIds) {
|
for (const pluginId of pluginIds) {
|
||||||
clearPluginSuccessResetTimer(pluginId);
|
for (const operationKey of buildPluginOperationKeys(pluginId)) {
|
||||||
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulePluginSuccessReset(
|
function schedulePluginSuccessReset(
|
||||||
pluginId: string,
|
operationKey: string,
|
||||||
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
|
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
|
||||||
): void {
|
): void {
|
||||||
clearPluginSuccessResetTimer(pluginId);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
pluginSuccessResetTimers.delete(pluginId);
|
pluginSuccessResetTimers.delete(operationKey);
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
if (prev.pluginInstallProgress[pluginId] !== 'success') {
|
if (prev.pluginInstallProgress[operationKey] !== 'success') {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'idle' },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, SUCCESS_DISPLAY_MS);
|
}, SUCCESS_DISPLAY_MS);
|
||||||
pluginSuccessResetTimers.set(pluginId, timer);
|
pluginSuccessResetTimers.set(operationKey, timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSkillsCatalogKey(projectPath?: string): string {
|
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.';
|
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
|
||||||
const PROJECT_SCOPE_REQUIRED_MESSAGE =
|
const PROJECT_SCOPE_REQUIRED_MESSAGE =
|
||||||
'Project- and local-scoped plugins require an active project in the Extensions tab.';
|
'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> = (
|
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||||
set,
|
set,
|
||||||
|
|
@ -691,6 +701,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
request.scope !== 'user'
|
request.scope !== 'user'
|
||||||
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||||
: request.projectPath;
|
: request.projectPath;
|
||||||
|
const operationKey = getPluginOperationKey(request.pluginId, request.scope);
|
||||||
const effectiveRequest =
|
const effectiveRequest =
|
||||||
effectiveProjectPath === request.projectPath
|
effectiveProjectPath === request.projectPath
|
||||||
? request
|
? request
|
||||||
|
|
@ -720,47 +731,47 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (preflightError) {
|
if (preflightError) {
|
||||||
clearPluginSuccessResetTimer(request.pluginId);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
installErrors: { ...prev.installErrors, [operationKey]: preflightError },
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPluginSuccessResetTimer(request.pluginId);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
|
||||||
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
installErrors: { ...prev.installErrors, [operationKey]: '' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.plugins.install(effectiveRequest);
|
const result = await api.plugins.install(effectiveRequest);
|
||||||
if (result.state === 'error') {
|
if (result.state === 'error') {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: {
|
installErrors: {
|
||||||
...prev.installErrors,
|
...prev.installErrors,
|
||||||
[request.pluginId]: result.error ?? 'Install failed',
|
[operationKey]: result.error ?? 'Install failed',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'success' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Refresh catalog to pick up new installed state
|
// Refresh catalog to pick up new installed state
|
||||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||||
|
|
||||||
schedulePluginSuccessReset(request.pluginId, set);
|
schedulePluginSuccessReset(operationKey, set);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearPluginSuccessResetTimer(request.pluginId);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
const message = err instanceof Error ? err.message : 'Install failed';
|
const message = err instanceof Error ? err.message : 'Install failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [request.pluginId]: message },
|
installErrors: { ...prev.installErrors, [operationKey]: message },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -769,48 +780,53 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
|
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
|
||||||
|
const effectiveScope = scope ?? 'user';
|
||||||
|
const operationKey = getPluginOperationKey(pluginId, effectiveScope);
|
||||||
const effectiveProjectPath =
|
const effectiveProjectPath =
|
||||||
scope && scope !== 'user'
|
effectiveScope !== 'user'
|
||||||
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||||
: projectPath;
|
: projectPath;
|
||||||
if (scope && scope !== 'user' && !effectiveProjectPath) {
|
if (effectiveScope !== 'user' && !effectiveProjectPath) {
|
||||||
clearPluginSuccessResetTimer(pluginId);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE },
|
installErrors: { ...prev.installErrors, [operationKey]: PROJECT_SCOPE_REQUIRED_MESSAGE },
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPluginSuccessResetTimer(pluginId);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath);
|
const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath);
|
||||||
if (result.state === 'error') {
|
if (result.state === 'error') {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [pluginId]: result.error ?? 'Uninstall failed' },
|
installErrors: {
|
||||||
|
...prev.installErrors,
|
||||||
|
[operationKey]: result.error ?? 'Uninstall failed',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'success' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Refresh catalog
|
// Refresh catalog
|
||||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||||
|
|
||||||
schedulePluginSuccessReset(pluginId, set);
|
schedulePluginSuccessReset(operationKey, set);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearPluginSuccessResetTimer(pluginId);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [pluginId]: message },
|
installErrors: { ...prev.installErrors, [operationKey]: message },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,13 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
|
||||||
return `${pluginName}@${marketplaceName}`;
|
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.
|
* 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 { api } from '../../../src/renderer/api';
|
||||||
|
import { getPluginOperationKey } from '../../../src/shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
EnrichedPlugin,
|
EnrichedPlugin,
|
||||||
|
|
@ -133,6 +134,9 @@ const makeReadyCliStatus = () => ({
|
||||||
providers: [],
|
providers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') =>
|
||||||
|
getPluginOperationKey(pluginId, scope);
|
||||||
|
|
||||||
describe('extensionsSlice', () => {
|
describe('extensionsSlice', () => {
|
||||||
let store: TestStore;
|
let store: TestStore;
|
||||||
|
|
||||||
|
|
@ -187,10 +191,10 @@ describe('extensionsSlice', () => {
|
||||||
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
|
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
|
||||||
pluginCatalogProjectPath: '/tmp/project-a',
|
pluginCatalogProjectPath: '/tmp/project-a',
|
||||||
pluginInstallProgress: {
|
pluginInstallProgress: {
|
||||||
'project-a@m': 'error',
|
[pluginOperationKey('project-a@m', 'project')]: 'error',
|
||||||
},
|
},
|
||||||
installErrors: {
|
installErrors: {
|
||||||
'project-a@m': 'Install failed',
|
[pluginOperationKey('project-a@m', 'project')]: 'Install failed',
|
||||||
'mcp-server': 'Keep me',
|
'mcp-server': 'Keep me',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -201,8 +205,10 @@ describe('extensionsSlice', () => {
|
||||||
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||||
|
|
||||||
expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
|
expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
|
||||||
expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined();
|
expect(
|
||||||
expect(store.getState().installErrors['project-a@m']).toBeUndefined();
|
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');
|
expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -266,10 +272,10 @@ describe('extensionsSlice', () => {
|
||||||
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
|
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
|
||||||
pluginCatalogProjectPath: '/tmp/project-a',
|
pluginCatalogProjectPath: '/tmp/project-a',
|
||||||
pluginInstallProgress: {
|
pluginInstallProgress: {
|
||||||
'project-a@m': 'error',
|
[pluginOperationKey('project-a@m', 'project')]: 'error',
|
||||||
},
|
},
|
||||||
installErrors: {
|
installErrors: {
|
||||||
'project-a@m': 'Install failed',
|
[pluginOperationKey('project-a@m', 'project')]: 'Install failed',
|
||||||
'mcp-server': 'Keep me',
|
'mcp-server': 'Keep me',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -278,8 +284,10 @@ describe('extensionsSlice', () => {
|
||||||
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||||
|
|
||||||
expect(store.getState().pluginCatalog).toEqual([]);
|
expect(store.getState().pluginCatalog).toEqual([]);
|
||||||
expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined();
|
expect(
|
||||||
expect(store.getState().installErrors['project-a@m']).toBeUndefined();
|
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');
|
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' });
|
const promise = store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
|
||||||
|
|
||||||
// During execution, should be pending
|
// During execution, should be pending
|
||||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('pending');
|
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending');
|
||||||
|
|
||||||
await promise;
|
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 () => {
|
it('sets progress to error on failure', async () => {
|
||||||
|
|
@ -463,7 +471,7 @@ describe('extensionsSlice', () => {
|
||||||
|
|
||||||
await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' });
|
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 () => {
|
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' });
|
await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' });
|
||||||
|
|
||||||
expect(api.plugins!.install).not.toHaveBeenCalled();
|
expect(api.plugins!.install).not.toHaveBeenCalled();
|
||||||
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe(
|
||||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
'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 () => {
|
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' });
|
await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' });
|
||||||
|
|
||||||
expect(api.plugins!.install).not.toHaveBeenCalled();
|
expect(api.plugins!.install).not.toHaveBeenCalled();
|
||||||
expect(store.getState().pluginInstallProgress['local@m']).toBe('error');
|
expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe(
|
||||||
expect(store.getState().installErrors['local@m']).toContain('active project');
|
'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 () => {
|
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' });
|
.mockResolvedValueOnce({ state: 'error', error: 'second failure' });
|
||||||
|
|
||||||
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
|
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' });
|
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);
|
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');
|
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;
|
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 () => {
|
it('fills missing projectPath from the active Extensions project context', async () => {
|
||||||
|
|
@ -567,8 +595,12 @@ describe('extensionsSlice', () => {
|
||||||
await store.getState().uninstallPlugin('project@m', 'project');
|
await store.getState().uninstallPlugin('project@m', 'project');
|
||||||
|
|
||||||
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
|
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
|
||||||
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe(
|
||||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
'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 () => {
|
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');
|
await store.getState().uninstallPlugin('local@m', 'local');
|
||||||
|
|
||||||
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
|
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
|
||||||
expect(store.getState().pluginInstallProgress['local@m']).toBe('error');
|
expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe(
|
||||||
expect(store.getState().installErrors['local@m']).toContain('active project');
|
'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 () => {
|
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' });
|
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||||
|
|
||||||
await store.getState().uninstallPlugin('test@m', 'user');
|
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');
|
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);
|
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,
|
getExtensionActionDisableReason,
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
getInstallationSummaryLabel,
|
getInstallationSummaryLabel,
|
||||||
|
getPluginOperationKey,
|
||||||
getPrimaryCapabilityLabel,
|
getPrimaryCapabilityLabel,
|
||||||
hasInstallationInScope,
|
hasInstallationInScope,
|
||||||
inferCapabilities,
|
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', () => {
|
describe('hasInstallationInScope', () => {
|
||||||
it('returns true when the selected scope exists', () => {
|
it('returns true when the selected scope exists', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue