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]);
|
||||
|
||||
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'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue