fix(extensions): harden plugin action states
This commit is contained in:
parent
847643828d
commit
6e875e5a40
6 changed files with 135 additions and 18 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 +
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue