fix(extensions): make plugin actions scope-aware

This commit is contained in:
777genius 2026-04-16 21:56:12 +03:00
parent f2c5d52bdc
commit 847643828d
5 changed files with 94 additions and 10 deletions

View file

@ -38,6 +38,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
fetchSkillsCatalog, fetchSkillsCatalog,
mcpBrowse, mcpBrowse,
mcpFetchInstalled, mcpFetchInstalled,
apiKeysLoading,
pluginCatalogLoading, pluginCatalogLoading,
mcpBrowseLoading, mcpBrowseLoading,
skillsLoading, skillsLoading,
@ -55,6 +56,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
fetchSkillsCatalog: s.fetchSkillsCatalog, fetchSkillsCatalog: s.fetchSkillsCatalog,
mcpBrowse: s.mcpBrowse, mcpBrowse: s.mcpBrowse,
mcpFetchInstalled: s.mcpFetchInstalled, mcpFetchInstalled: s.mcpFetchInstalled,
apiKeysLoading: s.apiKeysLoading,
pluginCatalogLoading: s.pluginCatalogLoading, pluginCatalogLoading: s.pluginCatalogLoading,
mcpBrowseLoading: s.mcpBrowseLoading, mcpBrowseLoading: s.mcpBrowseLoading,
skillsLoading: s.skillsLoading, skillsLoading: s.skillsLoading,
@ -143,13 +145,24 @@ export const ExtensionStoreView = (): React.JSX.Element => {
// Refresh all data (plugins + MCP browse + installed + skills) // Refresh all data (plugins + MCP browse + installed + skills)
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void fetchCliStatus();
void fetchApiKeys();
void fetchPluginCatalog(projectPath ?? undefined, true); void fetchPluginCatalog(projectPath ?? undefined, true);
void mcpBrowse(); // re-fetch first page void mcpBrowse(); // re-fetch first page
void mcpFetchInstalled(projectPath ?? undefined); void mcpFetchInstalled(projectPath ?? undefined);
void fetchSkillsCatalog(projectPath ?? undefined); void fetchSkillsCatalog(projectPath ?? undefined);
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]); }, [
fetchApiKeys,
fetchCliStatus,
fetchPluginCatalog,
fetchSkillsCatalog,
mcpBrowse,
mcpFetchInstalled,
projectPath,
]);
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading; const isRefreshing =
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
const cliStatusBanner = useMemo(() => { const cliStatusBanner = useMemo(() => {
if (cliStatusLoading || cliStatus === null) { if (cliStatusLoading || cliStatus === null) {
return ( return (

View file

@ -6,6 +6,7 @@ import { Badge } from '@renderer/components/ui/badge';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
getCapabilityLabel, getCapabilityLabel,
hasInstallationInScope,
inferCapabilities, inferCapabilities,
normalizeCategory, normalizeCategory,
} from '@shared/utils/extensionNormalizers'; } from '@shared/utils/extensionNormalizers';
@ -29,6 +30,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
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[plugin.pluginId]);
const isUserInstalled = hasInstallationInScope(plugin.installations, 'user');
const baseStriped = index % 2 === 0; const baseStriped = index % 2 === 0;
const smStriped = Math.floor(index / 2) % 2 === 0; const smStriped = Math.floor(index / 2) % 2 === 0;
const xlStriped = Math.floor(index / 3) % 2 === 0; const xlStriped = Math.floor(index / 3) % 2 === 0;
@ -112,9 +114,9 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
<div className="shrink-0" onClick={(e) => e.stopPropagation()}> <div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<InstallButton <InstallButton
state={installProgress} state={installProgress}
isInstalled={plugin.isInstalled} isInstalled={isUserInstalled}
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })} onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
onUninstall={() => uninstallPlugin(plugin.pluginId)} onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
size="sm" size="sm"
errorMessage={installError} errorMessage={installError}
/> />

View file

@ -26,6 +26,7 @@ import {
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
getCapabilityLabel, getCapabilityLabel,
hasInstallationInScope,
inferCapabilities, inferCapabilities,
normalizeCategory, normalizeCategory,
} from '@shared/utils/extensionNormalizers'; } from '@shared/utils/extensionNormalizers';
@ -54,13 +55,21 @@ export const PluginDetailDialog = ({
open, open,
onClose, onClose,
}: PluginDetailDialogProps): React.JSX.Element => { }: PluginDetailDialogProps): React.JSX.Element => {
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore( const {
fetchPluginReadme,
readmes,
readmeLoading,
installPlugin,
uninstallPlugin,
pluginCatalogProjectPath,
} = useStore(
useShallow((s) => ({ useShallow((s) => ({
fetchPluginReadme: s.fetchPluginReadme, fetchPluginReadme: s.fetchPluginReadme,
readmes: s.pluginReadmes, readmes: s.pluginReadmes,
readmeLoading: s.pluginReadmeLoading, readmeLoading: s.pluginReadmeLoading,
installPlugin: s.installPlugin, installPlugin: s.installPlugin,
uninstallPlugin: s.uninstallPlugin, uninstallPlugin: s.uninstallPlugin,
pluginCatalogProjectPath: s.pluginCatalogProjectPath,
})) }))
); );
const installProgress = useStore( const installProgress = useStore(
@ -69,6 +78,7 @@ export const PluginDetailDialog = ({
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined)); 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);
useEffect(() => { useEffect(() => {
if (plugin && open) { if (plugin && open) {
@ -76,12 +86,19 @@ export const PluginDetailDialog = ({
} }
}, [plugin, open, fetchPluginReadme]); }, [plugin, open, fetchPluginReadme]);
useEffect(() => {
if (scope === 'project' && !projectScopeAvailable) {
setScope('user');
}
}, [projectScopeAvailable, scope]);
if (!plugin) return <></>; if (!plugin) return <></>;
const capabilities = inferCapabilities(plugin); const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category); const category = normalizeCategory(plugin.category);
const readme = readmes[plugin.pluginId]; const readme = readmes[plugin.pluginId];
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false; const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
const isInstalledForScope = hasInstallationInScope(plugin.installations, scope);
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@ -158,7 +175,11 @@ export const PluginDetailDialog = ({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{SCOPE_OPTIONS.map((opt) => ( {SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value === 'project' && !projectScopeAvailable}
>
{opt.label} {opt.label}
</SelectItem> </SelectItem>
))} ))}
@ -167,9 +188,23 @@ export const PluginDetailDialog = ({
</div> </div>
<InstallButton <InstallButton
state={installProgress} state={installProgress}
isInstalled={plugin.isInstalled} isInstalled={isInstalledForScope}
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })} onInstall={() =>
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)} installPlugin({
pluginId: plugin.pluginId,
scope,
...(scope === 'project' && pluginCatalogProjectPath
? { projectPath: pluginCatalogProjectPath }
: {}),
})
}
onUninstall={() =>
uninstallPlugin(
plugin.pluginId,
scope,
scope === 'project' ? (pluginCatalogProjectPath ?? undefined) : undefined
)
}
size="default" size="default"
errorMessage={installError} errorMessage={installError}
/> />

View file

@ -2,7 +2,12 @@
* Pure-function normalizers for Extension Store data. * Pure-function normalizers for Extension Store data.
*/ */
import type { PluginCapability, PluginCatalogItem } from '@shared/types/extensions'; import type {
InstallScope,
InstalledPluginEntry,
PluginCapability,
PluginCatalogItem,
} from '@shared/types/extensions';
/** /**
* Normalize a repository URL for dedup comparison. * Normalize a repository URL for dedup comparison.
@ -94,6 +99,16 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
return `${pluginName}@${marketplaceName}`; return `${pluginName}@${marketplaceName}`;
} }
/**
* Check whether a plugin has an installation for the selected scope.
*/
export function hasInstallationInScope(
installations: Pick<InstalledPluginEntry, 'scope'>[],
scope: InstallScope
): boolean {
return installations.some((installation) => installation.scope === scope);
}
/** /**
* Sanitize an MCP server display name into a CLI-safe server name. * Sanitize an MCP server display name into a CLI-safe server name.
* Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService. * Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService.

View file

@ -7,6 +7,7 @@ import {
formatInstallCount, formatInstallCount,
getCapabilityLabel, getCapabilityLabel,
getPrimaryCapabilityLabel, getPrimaryCapabilityLabel,
hasInstallationInScope,
inferCapabilities, inferCapabilities,
normalizeCategory, normalizeCategory,
normalizeRepoUrl, normalizeRepoUrl,
@ -149,6 +150,24 @@ describe('buildPluginId', () => {
}); });
}); });
describe('hasInstallationInScope', () => {
it('returns true when the selected scope exists', () => {
expect(
hasInstallationInScope(
[
{ scope: 'user' },
{ scope: 'project' },
],
'project',
),
).toBe(true);
});
it('returns false when the selected scope is missing', () => {
expect(hasInstallationInScope([{ scope: 'user' }], 'project')).toBe(false);
});
});
describe('sanitizeMcpServerName', () => { describe('sanitizeMcpServerName', () => {
it('lowercases and replaces spaces with dashes', () => { it('lowercases and replaces spaces with dashes', () => {
expect(sanitizeMcpServerName('My Server')).toBe('my-server'); expect(sanitizeMcpServerName('My Server')).toBe('my-server');