fix(extensions): harden plugin action states

This commit is contained in:
777genius 2026-04-16 21:59:23 +03:00
parent 847643828d
commit 6e875e5a40
6 changed files with 135 additions and 18 deletions

View file

@ -13,6 +13,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
import { Check, Loader2, Trash2 } from 'lucide-react'; import { Check, Loader2, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -43,18 +44,11 @@ export const InstallButton = ({
cliStatusLoading: s.cliStatusLoading, cliStatusLoading: s.cliStatusLoading,
})) }))
); );
const cliUnknown = cliStatus === null; const disableReason = getExtensionActionDisableReason({
const cliMissing = cliStatus?.installed === false; isInstalled,
const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn; cliStatus,
const disableReason = cliStatusLoading 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 isDisabled = disabled || Boolean(disableReason); const isDisabled = disabled || Boolean(disableReason);
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);

View file

@ -5,6 +5,7 @@
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
getInstallationSummaryLabel,
getCapabilityLabel, getCapabilityLabel,
hasInstallationInScope, hasInstallationInScope,
inferCapabilities, inferCapabilities,
@ -31,6 +32,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
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 isUserInstalled = hasInstallationInScope(plugin.installations, 'user');
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
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;
@ -83,12 +85,12 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<InstallCountBadge count={plugin.installCount} /> <InstallCountBadge count={plugin.installCount} />
{plugin.isInstalled && ( {installSummaryLabel && (
<Badge <Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400" className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline" variant="outline"
> >
Installed {installSummaryLabel}
</Badge> </Badge>
)} )}
</div> </div>

View file

@ -25,6 +25,7 @@ import {
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
getInstallationSummaryLabel,
getCapabilityLabel, getCapabilityLabel,
hasInstallationInScope, hasInstallationInScope,
inferCapabilities, inferCapabilities,
@ -99,6 +100,7 @@ export const PluginDetailDialog = ({
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); const isInstalledForScope = hasInstallationInScope(plugin.installations, scope);
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@ -110,12 +112,12 @@ export const PluginDetailDialog = ({
<DialogDescription className="mt-1">{plugin.description}</DialogDescription> <DialogDescription className="mt-1">{plugin.description}</DialogDescription>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{plugin.isInstalled && ( {installSummaryLabel && (
<Badge <Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400" className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline" variant="outline"
> >
Installed {installSummaryLabel}
</Badge> </Badge>
)} )}
<SourceBadge source={plugin.source} /> <SourceBadge source={plugin.source} />

View file

@ -2,7 +2,7 @@
* PluginsPanel search, filter, sort and browse the plugin catalog. * 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 { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -142,6 +142,12 @@ export const PluginsPanel = ({
[catalog, selectedPluginId] [catalog, selectedPluginId]
); );
useEffect(() => {
if (selectedPluginId && !loading && !selectedPlugin) {
setSelectedPluginId(null);
}
}, [loading, selectedPlugin, selectedPluginId, setSelectedPluginId]);
const sortValue = `${pluginSort.field}:${pluginSort.order}`; const sortValue = `${pluginSort.field}:${pluginSort.order}`;
const activeFilterCount = const activeFilterCount =
pluginFilters.categories.length + pluginFilters.categories.length +

View file

@ -3,11 +3,12 @@
*/ */
import type { import type {
CliInstallationStatus,
InstallScope, InstallScope,
InstalledPluginEntry, InstalledPluginEntry,
PluginCapability, PluginCapability,
PluginCatalogItem, PluginCatalogItem,
} from '@shared/types/extensions'; } from '@shared/types';
/** /**
* Normalize a repository URL for dedup comparison. * Normalize a repository URL for dedup comparison.
@ -109,6 +110,61 @@ export function hasInstallationInScope(
return installations.some((installation) => installation.scope === scope); return installations.some((installation) => installation.scope === scope);
} }
/**
* Build a concise install-status label for plugin badges.
*/
export function getInstallationSummaryLabel(
installations: Pick<InstalledPluginEntry, 'scope'>[]
): 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<CliInstallationStatus, 'installed' | 'authLoggedIn'> | 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. * 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

@ -5,7 +5,9 @@ import type { PluginCatalogItem } from '@shared/types/extensions';
import { import {
buildPluginId, buildPluginId,
formatInstallCount, formatInstallCount,
getExtensionActionDisableReason,
getCapabilityLabel, getCapabilityLabel,
getInstallationSummaryLabel,
getPrimaryCapabilityLabel, getPrimaryCapabilityLabel,
hasInstallationInScope, hasInstallationInScope,
inferCapabilities, 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', () => { 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');