diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index ec905ee0..7b736687 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -38,6 +38,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, + apiKeysLoading, pluginCatalogLoading, mcpBrowseLoading, skillsLoading, @@ -55,6 +56,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { fetchSkillsCatalog: s.fetchSkillsCatalog, mcpBrowse: s.mcpBrowse, mcpFetchInstalled: s.mcpFetchInstalled, + apiKeysLoading: s.apiKeysLoading, pluginCatalogLoading: s.pluginCatalogLoading, mcpBrowseLoading: s.mcpBrowseLoading, skillsLoading: s.skillsLoading, @@ -143,13 +145,24 @@ export const ExtensionStoreView = (): React.JSX.Element => { // Refresh all data (plugins + MCP browse + installed + skills) const handleRefresh = useCallback(() => { + void fetchCliStatus(); + void fetchApiKeys(); void fetchPluginCatalog(projectPath ?? undefined, true); void mcpBrowse(); // re-fetch first page void mcpFetchInstalled(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(() => { if (cliStatusLoading || cliStatus === null) { return ( diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index 3a2a9bfb..ee73899b 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -6,6 +6,7 @@ import { Badge } from '@renderer/components/ui/badge'; import { useStore } from '@renderer/store'; import { getCapabilityLabel, + hasInstallationInScope, inferCapabilities, normalizeCategory, } from '@shared/utils/extensionNormalizers'; @@ -29,6 +30,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J const installPlugin = useStore((s) => s.installPlugin); const uninstallPlugin = useStore((s) => s.uninstallPlugin); const installError = useStore((s) => s.installErrors[plugin.pluginId]); + const isUserInstalled = hasInstallationInScope(plugin.installations, 'user'); const baseStriped = index % 2 === 0; const smStriped = Math.floor(index / 2) % 2 === 0; const xlStriped = Math.floor(index / 3) % 2 === 0; @@ -112,9 +114,9 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
e.stopPropagation()}> installPlugin({ pluginId: plugin.pluginId, scope: 'user' })} - onUninstall={() => uninstallPlugin(plugin.pluginId)} + onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')} size="sm" errorMessage={installError} /> diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 23721946..1e40866a 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -26,6 +26,7 @@ import { import { useStore } from '@renderer/store'; import { getCapabilityLabel, + hasInstallationInScope, inferCapabilities, normalizeCategory, } from '@shared/utils/extensionNormalizers'; @@ -54,13 +55,21 @@ export const PluginDetailDialog = ({ open, onClose, }: PluginDetailDialogProps): React.JSX.Element => { - const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore( + const { + fetchPluginReadme, + readmes, + readmeLoading, + installPlugin, + uninstallPlugin, + pluginCatalogProjectPath, + } = useStore( useShallow((s) => ({ fetchPluginReadme: s.fetchPluginReadme, readmes: s.pluginReadmes, readmeLoading: s.pluginReadmeLoading, installPlugin: s.installPlugin, uninstallPlugin: s.uninstallPlugin, + pluginCatalogProjectPath: s.pluginCatalogProjectPath, })) ); const installProgress = useStore( @@ -69,6 +78,7 @@ export const PluginDetailDialog = ({ const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined)); const [scope, setScope] = useState('user'); + const projectScopeAvailable = Boolean(pluginCatalogProjectPath); useEffect(() => { if (plugin && open) { @@ -76,12 +86,19 @@ export const PluginDetailDialog = ({ } }, [plugin, open, fetchPluginReadme]); + useEffect(() => { + if (scope === 'project' && !projectScopeAvailable) { + setScope('user'); + } + }, [projectScopeAvailable, scope]); + if (!plugin) return <>; const capabilities = inferCapabilities(plugin); const category = normalizeCategory(plugin.category); const readme = readmes[plugin.pluginId]; const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false; + const isInstalledForScope = hasInstallationInScope(plugin.installations, scope); return ( !o && onClose()}> @@ -158,7 +175,11 @@ export const PluginDetailDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - + {opt.label} ))} @@ -167,9 +188,23 @@ export const PluginDetailDialog = ({
installPlugin({ pluginId: plugin.pluginId, scope })} - onUninstall={() => uninstallPlugin(plugin.pluginId, scope)} + isInstalled={isInstalledForScope} + onInstall={() => + installPlugin({ + pluginId: plugin.pluginId, + scope, + ...(scope === 'project' && pluginCatalogProjectPath + ? { projectPath: pluginCatalogProjectPath } + : {}), + }) + } + onUninstall={() => + uninstallPlugin( + plugin.pluginId, + scope, + scope === 'project' ? (pluginCatalogProjectPath ?? undefined) : undefined + ) + } size="default" errorMessage={installError} /> diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 5f86ca7b..44b0dbc1 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -2,7 +2,12 @@ * 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. @@ -94,6 +99,16 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri return `${pluginName}@${marketplaceName}`; } +/** + * Check whether a plugin has an installation for the selected scope. + */ +export function hasInstallationInScope( + installations: Pick[], + scope: InstallScope +): boolean { + return installations.some((installation) => installation.scope === scope); +} + /** * Sanitize an MCP server display name into a CLI-safe server name. * Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService. diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index ce40a00d..b2cc7347 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -7,6 +7,7 @@ import { formatInstallCount, getCapabilityLabel, getPrimaryCapabilityLabel, + hasInstallationInScope, inferCapabilities, normalizeCategory, 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', () => { it('lowercases and replaces spaces with dashes', () => { expect(sanitizeMcpServerName('My Server')).toBe('my-server');