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,
|
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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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 +
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue