fix(extensions): scope plugin operation state by project
This commit is contained in:
parent
33917a3161
commit
24782411f3
6 changed files with 155 additions and 19 deletions
|
|
@ -91,7 +91,9 @@ export const PluginDetailDialog = ({
|
||||||
}
|
}
|
||||||
}, [projectScopeAvailable, scope]);
|
}, [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(
|
const installProgress = useStore(
|
||||||
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
|
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,8 @@ 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[] {
|
function isPluginOperationKeyForPlugin(operationKey: string, pluginId: string): boolean {
|
||||||
return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope));
|
return operationKey.startsWith(`plugin:${pluginId}:`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPluginOperationState(
|
function clearPluginOperationState(
|
||||||
|
|
@ -181,10 +181,16 @@ function clearPluginOperationState(
|
||||||
|
|
||||||
const nextPluginInstallProgress = { ...pluginInstallProgress };
|
const nextPluginInstallProgress = { ...pluginInstallProgress };
|
||||||
const nextInstallErrors = { ...installErrors };
|
const nextInstallErrors = { ...installErrors };
|
||||||
|
const pluginIdsList = Array.from(pluginIds);
|
||||||
|
|
||||||
for (const pluginId of pluginIds) {
|
for (const operationKey of Object.keys(nextPluginInstallProgress)) {
|
||||||
for (const operationKey of buildPluginOperationKeys(pluginId)) {
|
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
||||||
delete nextPluginInstallProgress[operationKey];
|
delete nextPluginInstallProgress[operationKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const operationKey of Object.keys(nextInstallErrors)) {
|
||||||
|
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
||||||
delete nextInstallErrors[operationKey];
|
delete nextInstallErrors[operationKey];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -206,8 +212,9 @@ function clearPluginSuccessResetTimer(operationKey: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
||||||
for (const pluginId of pluginIds) {
|
const pluginIdsList = Array.from(pluginIds);
|
||||||
for (const operationKey of buildPluginOperationKeys(pluginId)) {
|
for (const operationKey of Array.from(pluginSuccessResetTimers.keys())) {
|
||||||
|
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
||||||
clearPluginSuccessResetTimer(operationKey);
|
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.';
|
'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,
|
||||||
get
|
get
|
||||||
|
|
@ -865,11 +870,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
installPlugin: async (request: PluginInstallRequest) => {
|
installPlugin: async (request: PluginInstallRequest) => {
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
|
||||||
|
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
|
||||||
const effectiveProjectPath =
|
const effectiveProjectPath =
|
||||||
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 operationKey = getPluginOperationKey(
|
||||||
|
request.pluginId,
|
||||||
|
request.scope,
|
||||||
|
effectiveProjectPath
|
||||||
|
);
|
||||||
const effectiveRequest =
|
const effectiveRequest =
|
||||||
effectiveProjectPath === request.projectPath
|
effectiveProjectPath === request.projectPath
|
||||||
? request
|
? request
|
||||||
|
|
@ -931,7 +941,12 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 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(
|
||||||
|
effectiveRequest.scope !== 'user'
|
||||||
|
? effectiveRequest.projectPath
|
||||||
|
: catalogProjectPathAtOperationStart,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
schedulePluginSuccessReset(operationKey, set);
|
schedulePluginSuccessReset(operationKey, set);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -948,12 +963,13 @@ 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 catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
|
||||||
const effectiveScope = scope ?? 'user';
|
const effectiveScope = scope ?? 'user';
|
||||||
const operationKey = getPluginOperationKey(pluginId, effectiveScope);
|
|
||||||
const effectiveProjectPath =
|
const effectiveProjectPath =
|
||||||
effectiveScope !== 'user'
|
effectiveScope !== 'user'
|
||||||
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||||
: projectPath;
|
: projectPath;
|
||||||
|
const operationKey = getPluginOperationKey(pluginId, effectiveScope, effectiveProjectPath);
|
||||||
if (effectiveScope !== 'user' && !effectiveProjectPath) {
|
if (effectiveScope !== 'user' && !effectiveProjectPath) {
|
||||||
clearPluginSuccessResetTimer(operationKey);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
|
|
@ -986,7 +1002,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Refresh catalog
|
// Refresh catalog
|
||||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
void get().fetchPluginCatalog(
|
||||||
|
effectiveScope !== 'user' ? effectiveProjectPath : catalogProjectPathAtOperationStart,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
schedulePluginSuccessReset(operationKey, set);
|
schedulePluginSuccessReset(operationKey, set);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,14 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
|
||||||
/**
|
/**
|
||||||
* Namespaced operation-state key for plugin install/uninstall UI state.
|
* 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}`;
|
return `plugin:${pluginId}:${scope}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,10 +111,14 @@ vi.mock('@renderer/components/ui/select', () => ({
|
||||||
|
|
||||||
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||||
InstallButton: ({
|
InstallButton: ({
|
||||||
|
state,
|
||||||
|
errorMessage,
|
||||||
isInstalled,
|
isInstalled,
|
||||||
onInstall,
|
onInstall,
|
||||||
onUninstall,
|
onUninstall,
|
||||||
}: {
|
}: {
|
||||||
|
state?: string;
|
||||||
|
errorMessage?: string;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
onUninstall: () => void;
|
onUninstall: () => void;
|
||||||
|
|
@ -124,6 +128,8 @@ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||||
{
|
{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
'data-testid': 'install-button',
|
'data-testid': 'install-button',
|
||||||
|
'data-state': state,
|
||||||
|
'data-error-message': errorMessage,
|
||||||
onClick: () => (isInstalled ? onUninstall() : onInstall()),
|
onClick: () => (isInstalled ? onUninstall() : onInstall()),
|
||||||
},
|
},
|
||||||
isInstalled ? 'Uninstall' : 'Install'
|
isInstalled ? 'Uninstall' : 'Install'
|
||||||
|
|
@ -150,6 +156,7 @@ vi.mock('lucide-react', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog';
|
import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog';
|
||||||
|
import { getPluginOperationKey } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
const makePlugin = (): EnrichedPlugin => ({
|
const makePlugin = (): EnrichedPlugin => ({
|
||||||
pluginId: 'context7@claude-plugins-official',
|
pluginId: 'context7@claude-plugins-official',
|
||||||
|
|
@ -270,4 +277,49 @@ describe('PluginDetailDialog project context', () => {
|
||||||
await Promise.resolve();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -153,8 +153,11 @@ const makeReadyCliStatus = () => ({
|
||||||
providers: [],
|
providers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') =>
|
const pluginOperationKey = (
|
||||||
getPluginOperationKey(pluginId, scope);
|
pluginId: string,
|
||||||
|
scope: 'user' | 'project' | 'local' = 'user',
|
||||||
|
projectPath?: string
|
||||||
|
) => getPluginOperationKey(pluginId, scope, projectPath);
|
||||||
const mcpOperationKey = (
|
const mcpOperationKey = (
|
||||||
registryId: string,
|
registryId: string,
|
||||||
scope: 'user' | 'project' | 'local' | 'global' = 'user',
|
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 () => {
|
it('fails fast for project scope when there is no active project path', async () => {
|
||||||
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
|
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
|
||||||
|
|
||||||
|
|
@ -673,6 +703,26 @@ describe('extensionsSlice', () => {
|
||||||
expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a');
|
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 () => {
|
it('fails fast for project uninstall when there is no active project path', async () => {
|
||||||
store.setState({ pluginCatalogProjectPath: null });
|
store.setState({ pluginCatalogProjectPath: null });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,11 +157,17 @@ describe('buildPluginId', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getPluginOperationKey', () => {
|
describe('getPluginOperationKey', () => {
|
||||||
it('namespaces plugin operation keys by scope', () => {
|
it('namespaces user-scope plugin operation keys without a project suffix', () => {
|
||||||
expect(getPluginOperationKey('context7@claude-plugins-official', 'local')).toBe(
|
expect(getPluginOperationKey('context7@claude-plugins-official', 'user')).toBe(
|
||||||
'plugin:context7@claude-plugins-official:local',
|
'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', () => {
|
describe('getMcpOperationKey', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue