From 6e875e5a40f8b3b9aff03603c3bb42f5d6c5ced9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 21:59:23 +0300 Subject: [PATCH] fix(extensions): harden plugin action states --- .../extensions/common/InstallButton.tsx | 18 ++---- .../extensions/plugins/PluginCard.tsx | 6 +- .../extensions/plugins/PluginDetailDialog.tsx | 6 +- .../extensions/plugins/PluginsPanel.tsx | 8 ++- src/shared/utils/extensionNormalizers.ts | 58 ++++++++++++++++++- .../shared/utils/extensionNormalizers.test.ts | 57 ++++++++++++++++++ 6 files changed, 135 insertions(+), 18 deletions(-) diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index 93bdf907..2bc48112 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -13,6 +13,7 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { Check, Loader2, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -43,18 +44,11 @@ export const InstallButton = ({ cliStatusLoading: s.cliStatusLoading, })) ); - const cliUnknown = cliStatus === null; - const cliMissing = cliStatus?.installed === false; - const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn; - const disableReason = cliStatusLoading - ? 'Checking Claude CLI status...' - : cliUnknown - ? 'Checking Claude CLI availability...' - : cliMissing - ? 'Claude CLI required. Install it from the Dashboard.' - : authMissing - ? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.' - : null; + const disableReason = getExtensionActionDisableReason({ + isInstalled, + cliStatus, + cliStatusLoading, + }); const isDisabled = disabled || Boolean(disableReason); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index ee73899b..24ca2d87 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -5,6 +5,7 @@ import { Badge } from '@renderer/components/ui/badge'; import { useStore } from '@renderer/store'; import { + getInstallationSummaryLabel, getCapabilityLabel, hasInstallationInScope, inferCapabilities, @@ -31,6 +32,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J const uninstallPlugin = useStore((s) => s.uninstallPlugin); const installError = useStore((s) => s.installErrors[plugin.pluginId]); const isUserInstalled = hasInstallationInScope(plugin.installations, 'user'); + const installSummaryLabel = getInstallationSummaryLabel(plugin.installations); const baseStriped = index % 2 === 0; const smStriped = Math.floor(index / 2) % 2 === 0; const xlStriped = Math.floor(index / 3) % 2 === 0; @@ -83,12 +85,12 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
- {plugin.isInstalled && ( + {installSummaryLabel && ( - Installed + {installSummaryLabel} )}
diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 1e40866a..9d501e81 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -25,6 +25,7 @@ import { } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; import { + getInstallationSummaryLabel, getCapabilityLabel, hasInstallationInScope, inferCapabilities, @@ -99,6 +100,7 @@ export const PluginDetailDialog = ({ const readme = readmes[plugin.pluginId]; const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false; const isInstalledForScope = hasInstallationInScope(plugin.installations, scope); + const installSummaryLabel = getInstallationSummaryLabel(plugin.installations); return ( !o && onClose()}> @@ -110,12 +112,12 @@ export const PluginDetailDialog = ({ {plugin.description}
- {plugin.isInstalled && ( + {installSummaryLabel && ( - Installed + {installSummaryLabel} )} diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index ab8d94e9..7584fa74 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -2,7 +2,7 @@ * PluginsPanel — search, filter, sort and browse the plugin catalog. */ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -142,6 +142,12 @@ export const PluginsPanel = ({ [catalog, selectedPluginId] ); + useEffect(() => { + if (selectedPluginId && !loading && !selectedPlugin) { + setSelectedPluginId(null); + } + }, [loading, selectedPlugin, selectedPluginId, setSelectedPluginId]); + const sortValue = `${pluginSort.field}:${pluginSort.order}`; const activeFilterCount = pluginFilters.categories.length + diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 44b0dbc1..7684925a 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -3,11 +3,12 @@ */ import type { + CliInstallationStatus, InstallScope, InstalledPluginEntry, PluginCapability, PluginCatalogItem, -} from '@shared/types/extensions'; +} from '@shared/types'; /** * Normalize a repository URL for dedup comparison. @@ -109,6 +110,61 @@ export function hasInstallationInScope( return installations.some((installation) => installation.scope === scope); } +/** + * Build a concise install-status label for plugin badges. + */ +export function getInstallationSummaryLabel( + installations: Pick[] +): string | null { + const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); + if (scopes.length === 0) { + return null; + } + + if (scopes.length > 1) { + return `Installed in ${scopes.length} scopes`; + } + + switch (scopes[0]) { + case 'user': + return 'Installed globally'; + case 'project': + return 'Installed in project'; + case 'local': + return 'Installed locally'; + default: + return 'Installed'; + } +} + +/** + * Install actions require Claude auth, but uninstall only requires a working CLI. + */ +export function getExtensionActionDisableReason(options: { + isInstalled: boolean; + cliStatus: Pick | null; + cliStatusLoading: boolean; +}): string | null { + const { isInstalled, cliStatus, cliStatusLoading } = options; + if (cliStatusLoading) { + return 'Checking Claude CLI status...'; + } + + if (cliStatus === null) { + return 'Checking Claude CLI availability...'; + } + + if (cliStatus.installed === false) { + return 'Claude CLI required. Install it from the Dashboard.'; + } + + if (!isInstalled && !cliStatus.authLoggedIn) { + return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.'; + } + + return null; +} + /** * 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 b2cc7347..7905fd99 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -5,7 +5,9 @@ import type { PluginCatalogItem } from '@shared/types/extensions'; import { buildPluginId, formatInstallCount, + getExtensionActionDisableReason, getCapabilityLabel, + getInstallationSummaryLabel, getPrimaryCapabilityLabel, hasInstallationInScope, inferCapabilities, @@ -168,6 +170,61 @@ describe('hasInstallationInScope', () => { }); }); +describe('getInstallationSummaryLabel', () => { + it('returns null when there are no installations', () => { + expect(getInstallationSummaryLabel([])).toBeNull(); + }); + + it('describes a single global installation', () => { + expect(getInstallationSummaryLabel([{ scope: 'user' }])).toBe('Installed globally'); + }); + + it('describes a single project installation', () => { + expect(getInstallationSummaryLabel([{ scope: 'project' }])).toBe('Installed in project'); + }); + + it('summarizes multiple scopes without pretending they are global', () => { + expect( + getInstallationSummaryLabel([ + { scope: 'project' }, + { scope: 'user' }, + ]), + ).toBe('Installed in 2 scopes'); + }); +}); + +describe('getExtensionActionDisableReason', () => { + it('requires auth only for install actions', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus: { installed: true, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toContain('not signed in'); + }); + + it('allows uninstall when CLI is present but auth is missing', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: true, + cliStatus: { installed: true, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toBeNull(); + }); + + it('still blocks actions when the CLI is missing', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: true, + cliStatus: { installed: false, authLoggedIn: false }, + cliStatusLoading: false, + }), + ).toContain('Claude CLI required'); + }); +}); + describe('sanitizeMcpServerName', () => { it('lowercases and replaces spaces with dashes', () => { expect(sanitizeMcpServerName('My Server')).toBe('my-server');