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,
} 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);

View file

@ -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
</div>
<div className="flex shrink-0 items-center gap-2">
<InstallCountBadge count={plugin.installCount} />
{plugin.isInstalled && (
{installSummaryLabel && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
{installSummaryLabel}
</Badge>
)}
</div>

View file

@ -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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@ -110,12 +112,12 @@ export const PluginDetailDialog = ({
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{plugin.isInstalled && (
{installSummaryLabel && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
{installSummaryLabel}
</Badge>
)}
<SourceBadge source={plugin.source} />

View file

@ -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 +

View file

@ -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<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.
* Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService.

View file

@ -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');