fix(extensions): scope plugin operation state by project

This commit is contained in:
777genius 2026-04-17 14:39:26 +03:00
parent 33917a3161
commit 24782411f3
6 changed files with 155 additions and 19 deletions

View file

@ -91,7 +91,9 @@ export const PluginDetailDialog = ({
}
}, [projectScopeAvailable, scope]);
const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null;
const operationKey = plugin
? getPluginOperationKey(plugin.pluginId, scope, scope !== 'user' ? projectPath : undefined)
: null;
const installProgress = useStore(
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
);

View file

@ -163,8 +163,8 @@ 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 isPluginOperationKeyForPlugin(operationKey: string, pluginId: string): boolean {
return operationKey.startsWith(`plugin:${pluginId}:`);
}
function clearPluginOperationState(
@ -181,10 +181,16 @@ function clearPluginOperationState(
const nextPluginInstallProgress = { ...pluginInstallProgress };
const nextInstallErrors = { ...installErrors };
const pluginIdsList = Array.from(pluginIds);
for (const pluginId of pluginIds) {
for (const operationKey of buildPluginOperationKeys(pluginId)) {
for (const operationKey of Object.keys(nextPluginInstallProgress)) {
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
delete nextPluginInstallProgress[operationKey];
}
}
for (const operationKey of Object.keys(nextInstallErrors)) {
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
delete nextInstallErrors[operationKey];
}
}
@ -206,8 +212,9 @@ function clearPluginSuccessResetTimer(operationKey: string): void {
}
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
for (const pluginId of pluginIds) {
for (const operationKey of buildPluginOperationKeys(pluginId)) {
const pluginIdsList = Array.from(pluginIds);
for (const operationKey of Array.from(pluginSuccessResetTimers.keys())) {
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
clearPluginSuccessResetTimer(operationKey);
}
}
@ -339,8 +346,6 @@ 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,
get
@ -865,11 +870,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
installPlugin: async (request: PluginInstallRequest) => {
if (!api.plugins) return;
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
const effectiveProjectPath =
request.scope !== 'user'
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: request.projectPath;
const operationKey = getPluginOperationKey(request.pluginId, request.scope);
const operationKey = getPluginOperationKey(
request.pluginId,
request.scope,
effectiveProjectPath
);
const effectiveRequest =
effectiveProjectPath === request.projectPath
? request
@ -931,7 +941,12 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}));
// Refresh catalog to pick up new installed state
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
void get().fetchPluginCatalog(
effectiveRequest.scope !== 'user'
? effectiveRequest.projectPath
: catalogProjectPathAtOperationStart,
true
);
schedulePluginSuccessReset(operationKey, set);
} catch (err) {
@ -948,12 +963,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
if (!api.plugins) return;
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
const effectiveScope = scope ?? 'user';
const operationKey = getPluginOperationKey(pluginId, effectiveScope);
const effectiveProjectPath =
effectiveScope !== 'user'
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
: projectPath;
const operationKey = getPluginOperationKey(pluginId, effectiveScope, effectiveProjectPath);
if (effectiveScope !== 'user' && !effectiveProjectPath) {
clearPluginSuccessResetTimer(operationKey);
set((prev) => ({
@ -986,7 +1002,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}));
// Refresh catalog
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
void get().fetchPluginCatalog(
effectiveScope !== 'user' ? effectiveProjectPath : catalogProjectPathAtOperationStart,
true
);
schedulePluginSuccessReset(operationKey, set);
} catch (err) {

View file

@ -109,7 +109,14 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
/**
* Namespaced operation-state key for plugin install/uninstall UI state.
*/
export function getPluginOperationKey(pluginId: string, scope: InstallScope): string {
export function getPluginOperationKey(
pluginId: string,
scope: InstallScope,
projectPath?: string | null
): string {
if (scope === 'project' || scope === 'local') {
return `plugin:${pluginId}:${scope}:${getMcpProjectStateKey(projectPath)}`;
}
return `plugin:${pluginId}:${scope}`;
}

View file

@ -111,10 +111,14 @@ vi.mock('@renderer/components/ui/select', () => ({
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
InstallButton: ({
state,
errorMessage,
isInstalled,
onInstall,
onUninstall,
}: {
state?: string;
errorMessage?: string;
isInstalled: boolean;
onInstall: () => void;
onUninstall: () => void;
@ -124,6 +128,8 @@ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
{
type: 'button',
'data-testid': 'install-button',
'data-state': state,
'data-error-message': errorMessage,
onClick: () => (isInstalled ? onUninstall() : onInstall()),
},
isInstalled ? 'Uninstall' : 'Install'
@ -150,6 +156,7 @@ vi.mock('lucide-react', () => {
});
import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog';
import { getPluginOperationKey } from '@shared/utils/extensionNormalizers';
const makePlugin = (): EnrichedPlugin => ({
pluginId: 'context7@claude-plugins-official',
@ -270,4 +277,49 @@ describe('PluginDetailDialog project context', () => {
await Promise.resolve();
});
});
it('reads project-scope action state from the current tab project path', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const plugin = makePlugin();
storeState.pluginInstallProgress = {
[getPluginOperationKey(plugin.pluginId, 'project', '/tmp/tab-project')]: 'pending',
};
storeState.installErrors = {
[getPluginOperationKey(plugin.pluginId, 'project', '/tmp/other-project')]: 'Wrong project',
};
await act(async () => {
root.render(
React.createElement(PluginDetailDialog, {
plugin,
open: true,
onClose: vi.fn(),
projectPath: '/tmp/tab-project',
})
);
await Promise.resolve();
});
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
expect(scopeSelect).not.toBeNull();
expect(installButton).not.toBeNull();
await act(async () => {
scopeSelect.value = 'project';
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
});
expect(installButton.getAttribute('data-state')).toBe('pending');
expect(installButton.getAttribute('data-error-message')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -153,8 +153,11 @@ const makeReadyCliStatus = () => ({
providers: [],
});
const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') =>
getPluginOperationKey(pluginId, scope);
const pluginOperationKey = (
pluginId: string,
scope: 'user' | 'project' | 'local' = 'user',
projectPath?: string
) => getPluginOperationKey(pluginId, scope, projectPath);
const mcpOperationKey = (
registryId: string,
scope: 'user' | 'project' | 'local' | 'global' = 'user',
@ -574,6 +577,33 @@ describe('extensionsSlice', () => {
});
});
it('keys project-scope install state by project path and refreshes that same project context', async () => {
store.setState({
cliStatus: makeReadyCliStatus(),
pluginCatalogProjectPath: '/tmp/project-b',
});
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
await store.getState().installPlugin({
pluginId: 'project@m',
scope: 'project',
projectPath: '/tmp/project-a',
});
expect(
store.getState().pluginInstallProgress[
pluginOperationKey('project@m', 'project', '/tmp/project-a')
]
).toBe('success');
expect(
store.getState().pluginInstallProgress[
pluginOperationKey('project@m', 'project', '/tmp/project-b')
]
).toBeUndefined();
expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true);
});
it('fails fast for project scope when there is no active project path', async () => {
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
@ -673,6 +703,26 @@ describe('extensionsSlice', () => {
expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a');
});
it('keys project-scope uninstall state by project path and refreshes that same project context', async () => {
store.setState({ pluginCatalogProjectPath: '/tmp/project-b' });
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
await store.getState().uninstallPlugin('project@m', 'project', '/tmp/project-a');
expect(
store.getState().pluginInstallProgress[
pluginOperationKey('project@m', 'project', '/tmp/project-a')
]
).toBe('success');
expect(
store.getState().pluginInstallProgress[
pluginOperationKey('project@m', 'project', '/tmp/project-b')
]
).toBeUndefined();
expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true);
});
it('fails fast for project uninstall when there is no active project path', async () => {
store.setState({ pluginCatalogProjectPath: null });

View file

@ -157,11 +157,17 @@ 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',
it('namespaces user-scope plugin operation keys without a project suffix', () => {
expect(getPluginOperationKey('context7@claude-plugins-official', 'user')).toBe(
'plugin:context7@claude-plugins-official:user',
);
});
it('namespaces repo-scoped plugin operation keys by project path', () => {
expect(
getPluginOperationKey('context7@claude-plugins-official', 'local', '/tmp/project'),
).toBe('plugin:context7@claude-plugins-official:local:/tmp/project');
});
});
describe('getMcpOperationKey', () => {