chore(merge): sync dev into team snapshot split spike
This commit is contained in:
commit
f92b77e3af
37 changed files with 4507 additions and 266 deletions
|
|
@ -37,6 +37,10 @@ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/;
|
|||
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
function scopeRequiresProjectPath(scope?: string): boolean {
|
||||
return scope === 'local' || scope === 'project';
|
||||
}
|
||||
|
||||
export class McpInstallService {
|
||||
constructor(private readonly aggregator: McpCatalogAggregator) {}
|
||||
|
||||
|
|
@ -59,6 +63,13 @@ export class McpInstallService {
|
|||
};
|
||||
}
|
||||
|
||||
if (scopeRequiresProjectPath(scope) && !projectPath) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `projectPath is required for ${scope} scope`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Validate env var keys (prevent command injection)
|
||||
for (const key of Object.keys(envValues)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
|
|
@ -212,6 +223,10 @@ export class McpInstallService {
|
|||
return { state: 'error', error: `Invalid scope: "${scope}".` };
|
||||
}
|
||||
|
||||
if (scopeRequiresProjectPath(scope) && !projectPath) {
|
||||
return { state: 'error', error: `projectPath is required for ${scope} scope` };
|
||||
}
|
||||
|
||||
for (const key of Object.keys(envValues)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
return { state: 'error', error: `Invalid env var name: "${key}".` };
|
||||
|
|
@ -319,6 +334,13 @@ export class McpInstallService {
|
|||
};
|
||||
}
|
||||
|
||||
if (scopeRequiresProjectPath(scope) && !projectPath) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `projectPath is required for ${scope} scope`,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectPath && !path.isAbsolute(projectPath)) {
|
||||
return {
|
||||
state: 'error',
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
*
|
||||
* Sources:
|
||||
* - User scope: ~/.claude.json → mcpServers
|
||||
* - Local scope: ~/.claude.json → projects[projectPath].mcpServers
|
||||
* - Project scope: .mcp.json in project root
|
||||
* - Local scope: determined by Claude CLI (may also be in ~/.claude.json)
|
||||
*
|
||||
* Both files are managed by the Claude CLI. This service is read-only.
|
||||
*/
|
||||
|
|
@ -27,30 +27,30 @@ interface TimedCache<T> {
|
|||
}
|
||||
|
||||
export class McpInstallationStateService {
|
||||
private cache: TimedCache<InstalledMcpEntry[]> | null = null;
|
||||
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
|
||||
|
||||
/**
|
||||
* Get all installed MCP servers across user and project scopes.
|
||||
* Get all installed MCP servers across user, local, and project scopes.
|
||||
*/
|
||||
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||
// Cache is project-path-dependent, so invalidate on path change
|
||||
if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
|
||||
return this.cache.data;
|
||||
const cacheKey = projectPath ?? '__user__';
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const entries: InstalledMcpEntry[] = [];
|
||||
const claudeConfig = await this.readClaudeConfig();
|
||||
|
||||
// User scope: ~/.claude.json
|
||||
const userEntries = await this.readUserMcpServers();
|
||||
entries.push(...userEntries);
|
||||
entries.push(...this.readUserMcpServers(claudeConfig));
|
||||
|
||||
// Project scope: .mcp.json
|
||||
if (projectPath) {
|
||||
const projectEntries = await this.readProjectMcpServers(projectPath);
|
||||
entries.push(...projectEntries);
|
||||
entries.push(...this.readLocalMcpServers(claudeConfig, projectPath));
|
||||
entries.push(...(await this.readProjectMcpServers(projectPath)));
|
||||
}
|
||||
|
||||
this.cache = { data: entries, fetchedAt: Date.now() };
|
||||
this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
|
@ -58,14 +58,42 @@ export class McpInstallationStateService {
|
|||
* Invalidate cache. Call after install/uninstall operations.
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.cache = null;
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
private async readUserMcpServers(): Promise<InstalledMcpEntry[]> {
|
||||
private async readClaudeConfig(): Promise<Record<string, unknown> | null> {
|
||||
const configPath = path.join(getHomeDir(), '.claude.json');
|
||||
return this.readMcpServersFromFile(configPath, 'user');
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, 'utf-8');
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error(`Failed to read MCP servers from ${configPath}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private readUserMcpServers(config: Record<string, unknown> | null): InstalledMcpEntry[] {
|
||||
return this.readMcpServersFromConfig(config?.mcpServers, 'user');
|
||||
}
|
||||
|
||||
private readLocalMcpServers(
|
||||
config: Record<string, unknown> | null,
|
||||
projectPath: string
|
||||
): InstalledMcpEntry[] {
|
||||
const projects =
|
||||
config && typeof config.projects === 'object' && config.projects
|
||||
? (config.projects as Record<string, unknown>)
|
||||
: null;
|
||||
const projectConfig =
|
||||
projects && typeof projects[projectPath] === 'object' && projects[projectPath]
|
||||
? (projects[projectPath] as Record<string, unknown>)
|
||||
: null;
|
||||
return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local');
|
||||
}
|
||||
|
||||
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
|
||||
|
|
@ -73,6 +101,27 @@ export class McpInstallationStateService {
|
|||
return this.readMcpServersFromFile(configPath, 'project');
|
||||
}
|
||||
|
||||
private readMcpServersFromConfig(
|
||||
value: unknown,
|
||||
scope: 'user' | 'project' | 'local'
|
||||
): InstalledMcpEntry[] {
|
||||
const mcpServers =
|
||||
value && typeof value === 'object'
|
||||
? (value as Record<string, { command?: string; url?: string }>)
|
||||
: null;
|
||||
if (!mcpServers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
|
||||
let transport: string | undefined;
|
||||
if (config.command) transport = 'stdio';
|
||||
else if (config.url) transport = 'http';
|
||||
|
||||
return { name, scope, transport };
|
||||
});
|
||||
}
|
||||
|
||||
private async readMcpServersFromFile(
|
||||
filePath: string,
|
||||
scope: 'user' | 'project'
|
||||
|
|
@ -80,21 +129,7 @@ export class McpInstallationStateService {
|
|||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf-8');
|
||||
const json = JSON.parse(raw) as Record<string, unknown>;
|
||||
const mcpServers = json.mcpServers as
|
||||
| Record<string, { command?: string; url?: string }>
|
||||
| undefined;
|
||||
|
||||
if (!mcpServers || typeof mcpServers !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
|
||||
let transport: string | undefined;
|
||||
if (config.command) transport = 'stdio';
|
||||
else if (config.url) transport = 'http';
|
||||
|
||||
return { name, scope, transport };
|
||||
});
|
||||
return this.readMcpServersFromConfig(json.mcpServers, scope);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -323,6 +323,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
|
||||
<TabsContent value="plugins" className="mt-0 pt-4">
|
||||
<PluginsPanel
|
||||
projectPath={projectPath}
|
||||
pluginFilters={tabState.pluginFilters}
|
||||
pluginSort={tabState.pluginSort}
|
||||
selectedPluginId={tabState.selectedPluginId}
|
||||
|
|
@ -339,6 +340,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
|
||||
<TabsContent value="mcp-servers" className="mt-0 pt-4">
|
||||
<McpServersPanel
|
||||
projectPath={projectPath}
|
||||
mcpSearchQuery={tabState.mcpSearchQuery}
|
||||
mcpSearch={tabState.mcpSearch}
|
||||
mcpSearchResults={tabState.mcpSearchResults}
|
||||
|
|
@ -371,6 +373,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
<CustomMcpServerDialog
|
||||
open={customMcpDialogOpen}
|
||||
onClose={() => setCustomMcpDialogOpen(false)}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,14 +37,16 @@ const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
|
|||
interface CustomMcpServerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
projectPath: string | null;
|
||||
}
|
||||
|
||||
type TransportMode = 'stdio' | 'http';
|
||||
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
||||
type Scope = 'local' | 'user';
|
||||
type Scope = 'local' | 'user' | 'project';
|
||||
|
||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||
{ value: 'user', label: 'User (global)' },
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'local', label: 'Local' },
|
||||
];
|
||||
|
||||
|
|
@ -62,6 +64,7 @@ interface EnvEntry {
|
|||
export const CustomMcpServerDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
projectPath,
|
||||
}: CustomMcpServerDialogProps): React.JSX.Element => {
|
||||
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
|
||||
|
||||
|
|
@ -101,6 +104,12 @@ export const CustomMcpServerDialog = ({
|
|||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && scope !== 'user' && !projectPath) {
|
||||
setScope('user');
|
||||
}
|
||||
}, [open, projectPath, scope]);
|
||||
|
||||
// Auto-fill env vars from saved API keys
|
||||
useEffect(() => {
|
||||
if (!open || envVars.length === 0 || !api.apiKeys) return;
|
||||
|
|
@ -168,6 +177,7 @@ export const CustomMcpServerDialog = ({
|
|||
const request: McpCustomInstallRequest = {
|
||||
serverName,
|
||||
scope,
|
||||
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
|
||||
installSpec,
|
||||
envValues,
|
||||
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
|
||||
|
|
@ -197,6 +207,7 @@ export const CustomMcpServerDialog = ({
|
|||
const canSubmit =
|
||||
serverName.trim() &&
|
||||
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
|
||||
!(scope !== 'user' && !projectPath) &&
|
||||
!installing;
|
||||
|
||||
return (
|
||||
|
|
@ -372,7 +383,11 @@ export const CustomMcpServerDialog = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
disabled={opt.value !== 'user' && !projectPath}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,28 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
|
||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||
import {
|
||||
getMcpInstallationSummaryLabel,
|
||||
getMcpOperationKey,
|
||||
sanitizeMcpServerName,
|
||||
} from '@shared/utils/extensionNormalizers';
|
||||
import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
|
||||
import { Github as GithubIcon } from 'lucide-react';
|
||||
|
||||
import { InstallButton } from '../common/InstallButton';
|
||||
import { SourceBadge } from '../common/SourceBadge';
|
||||
|
||||
import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions';
|
||||
import type {
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
McpServerDiagnostic,
|
||||
} from '@shared/types/extensions';
|
||||
|
||||
interface McpServerCardProps {
|
||||
server: McpCatalogItem;
|
||||
isInstalled: boolean;
|
||||
installedEntry?: InstalledMcpEntry | null;
|
||||
installedEntries?: InstalledMcpEntry[];
|
||||
diagnostic?: McpServerDiagnostic | null;
|
||||
diagnosticsLoading?: boolean;
|
||||
onClick: (serverId: string) => void;
|
||||
|
|
@ -31,23 +41,42 @@ interface McpServerCardProps {
|
|||
export const McpServerCard = ({
|
||||
server,
|
||||
isInstalled,
|
||||
installedEntry,
|
||||
installedEntries = [],
|
||||
diagnostic,
|
||||
diagnosticsLoading,
|
||||
onClick,
|
||||
}: McpServerCardProps): React.JSX.Element => {
|
||||
const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle');
|
||||
const operationKey = getMcpOperationKey(server.id, 'user');
|
||||
const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle');
|
||||
const installMcpServer = useStore((s) => s.installMcpServer);
|
||||
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
||||
const installError = useStore((s) => s.installErrors[server.id]);
|
||||
const installError = useStore((s) => s.installErrors[operationKey]);
|
||||
const stars = useStore((s) =>
|
||||
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
||||
);
|
||||
const canAutoInstall = !!server.installSpec;
|
||||
const normalizedInstalledEntries = installedEntries.length
|
||||
? installedEntries
|
||||
: installedEntry
|
||||
? [installedEntry]
|
||||
: [];
|
||||
const requiresConfiguration =
|
||||
server.installSpec?.type === 'http' ||
|
||||
server.envVars.length > 0 ||
|
||||
server.requiresAuth ||
|
||||
(server.authHeaders?.length ?? 0) > 0;
|
||||
const defaultServerName = sanitizeMcpServerName(server.name);
|
||||
const userInstallEntry =
|
||||
normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null;
|
||||
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
|
||||
const supportsDirectInstalledAction =
|
||||
isInstalled &&
|
||||
normalizedInstalledEntries.length === 1 &&
|
||||
userInstallEntry?.name === defaultServerName &&
|
||||
!requiresConfiguration;
|
||||
const shouldShowDirectInstallButton =
|
||||
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const hasIcon = !!server.iconUrl && !imgError;
|
||||
const diagnosticBadgeClass =
|
||||
|
|
@ -103,7 +132,7 @@ export const McpServerCard = ({
|
|||
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||
variant="outline"
|
||||
>
|
||||
Installed
|
||||
{installSummaryLabel ?? 'Installed'}
|
||||
</Badge>
|
||||
)}
|
||||
{isInstalled && diagnosticsLoading && !diagnostic && (
|
||||
|
|
@ -224,7 +253,7 @@ export const McpServerCard = ({
|
|||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{canAutoInstall && !requiresConfiguration && (
|
||||
{shouldShowDirectInstallButton && (
|
||||
<div className="shrink-0">
|
||||
<InstallButton
|
||||
state={installProgress}
|
||||
|
|
@ -232,19 +261,21 @@ export const McpServerCard = ({
|
|||
onInstall={() =>
|
||||
installMcpServer({
|
||||
registryId: server.id,
|
||||
serverName: sanitizeMcpServerName(server.name),
|
||||
serverName: defaultServerName,
|
||||
scope: 'user',
|
||||
envValues: {},
|
||||
headers: [],
|
||||
})
|
||||
}
|
||||
onUninstall={() => uninstallMcpServer(server.id, sanitizeMcpServerName(server.name))}
|
||||
onUninstall={() =>
|
||||
uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user')
|
||||
}
|
||||
size="sm"
|
||||
errorMessage={installError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canAutoInstall && requiresConfiguration && (
|
||||
{canAutoInstall && (!shouldShowDirectInstallButton || requiresConfiguration) && (
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -25,54 +25,81 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||
import {
|
||||
getMcpInstallationSummaryLabel,
|
||||
getMcpOperationKey,
|
||||
getPreferredMcpInstallationEntry,
|
||||
sanitizeMcpServerName,
|
||||
} from '@shared/utils/extensionNormalizers';
|
||||
import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
|
||||
|
||||
import { InstallButton } from '../common/InstallButton';
|
||||
import { SourceBadge } from '../common/SourceBadge';
|
||||
|
||||
import type { McpCatalogItem, McpHeaderDef, McpServerDiagnostic } from '@shared/types/extensions';
|
||||
import type {
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
McpHeaderDef,
|
||||
McpServerDiagnostic,
|
||||
} from '@shared/types/extensions';
|
||||
|
||||
interface McpServerDetailDialogProps {
|
||||
server: McpCatalogItem | null;
|
||||
isInstalled: boolean;
|
||||
installedEntry?: InstalledMcpEntry | null;
|
||||
installedEntries?: InstalledMcpEntry[];
|
||||
diagnostic?: McpServerDiagnostic | null;
|
||||
diagnosticsLoading?: boolean;
|
||||
projectPath: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Scope = 'local' | 'user';
|
||||
type Scope = 'local' | 'user' | 'project';
|
||||
|
||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||
{ value: 'user', label: 'User (global)' },
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'local', label: 'Local' },
|
||||
];
|
||||
|
||||
export const McpServerDetailDialog = ({
|
||||
server,
|
||||
isInstalled,
|
||||
installedEntry,
|
||||
installedEntries = [],
|
||||
diagnostic,
|
||||
diagnosticsLoading,
|
||||
projectPath,
|
||||
open,
|
||||
onClose,
|
||||
}: McpServerDetailDialogProps): React.JSX.Element => {
|
||||
const [scope, setScope] = useState<Scope>('user');
|
||||
const operationKey = server ? getMcpOperationKey(server.id, scope) : null;
|
||||
const installProgress = useStore(
|
||||
(s) => (server ? s.mcpInstallProgress[server.id] : undefined) ?? 'idle'
|
||||
(s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle'
|
||||
);
|
||||
const installMcpServer = useStore((s) => s.installMcpServer);
|
||||
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
||||
const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined));
|
||||
const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined));
|
||||
const stars = useStore((s) =>
|
||||
server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
||||
);
|
||||
|
||||
const [scope, setScope] = useState<Scope>('user');
|
||||
const [serverName, setServerName] = useState('');
|
||||
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
||||
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
||||
const normalizedInstalledEntries = installedEntries.length
|
||||
? installedEntries
|
||||
: installedEntry
|
||||
? [installedEntry]
|
||||
: [];
|
||||
const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries);
|
||||
const selectedInstalledEntry =
|
||||
normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null;
|
||||
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
|
||||
|
||||
// Initialize form when dialog opens or server changes
|
||||
useEffect(() => {
|
||||
|
|
@ -80,7 +107,6 @@ export const McpServerDetailDialog = ({
|
|||
return;
|
||||
}
|
||||
|
||||
setServerName(sanitizeMcpServerName(server.name));
|
||||
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
|
||||
setHeaders(
|
||||
(server.authHeaders ?? []).map((header) => ({
|
||||
|
|
@ -93,10 +119,25 @@ export const McpServerDetailDialog = ({
|
|||
locked: true,
|
||||
}))
|
||||
);
|
||||
setScope('user');
|
||||
setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
|
||||
setScope(preferredInstalledEntry?.scope ?? 'user');
|
||||
setImgError(false);
|
||||
setAutoFilledFields(new Set());
|
||||
}, [server?.id, open]);
|
||||
}, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!server || !open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
|
||||
}, [open, scope, selectedInstalledEntry?.name, server]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && scope !== 'user' && !projectPath) {
|
||||
setScope('user');
|
||||
}
|
||||
}, [open, projectPath, scope]);
|
||||
|
||||
// Auto-fill env values from saved API keys
|
||||
useEffect(() => {
|
||||
|
|
@ -121,38 +162,6 @@ export const McpServerDetailDialog = ({
|
|||
);
|
||||
}, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Auto-fill env vars from saved API keys
|
||||
useEffect(() => {
|
||||
if (!server || server.envVars.length === 0 || !api.apiKeys) return;
|
||||
|
||||
const envVarNames = server.envVars.map((env) => env.name);
|
||||
void api.apiKeys.lookup(envVarNames).then(
|
||||
(results) => {
|
||||
if (results.length === 0) return;
|
||||
const filled = new Set<string>();
|
||||
const updates: Record<string, string> = {};
|
||||
for (const r of results) {
|
||||
updates[r.envVarName] = r.value;
|
||||
filled.add(r.envVarName);
|
||||
}
|
||||
setEnvValues((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
// Only auto-fill if the field is empty
|
||||
if (!next[k]) {
|
||||
next[k] = v;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setAutoFilledFields(filled);
|
||||
},
|
||||
() => {
|
||||
// Silently ignore lookup failures
|
||||
}
|
||||
);
|
||||
}, [server?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!server) return <></>;
|
||||
|
||||
const canAutoInstall = !!server.installSpec;
|
||||
|
|
@ -169,7 +178,15 @@ export const McpServerDetailDialog = ({
|
|||
const missingRequiredHeaders = headers.some(
|
||||
(header) => header.isRequired && !header.value.trim()
|
||||
);
|
||||
const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders;
|
||||
const isInstalledForScope = selectedInstalledEntry !== null;
|
||||
const uninstallServerName = selectedInstalledEntry?.name ?? serverName;
|
||||
const uninstallScope = selectedInstalledEntry?.scope ?? scope;
|
||||
const scopeRequiresProjectPath = scope !== 'user' && !projectPath;
|
||||
const installDisabled =
|
||||
!serverName.trim() ||
|
||||
missingRequiredEnvVars ||
|
||||
missingRequiredHeaders ||
|
||||
scopeRequiresProjectPath;
|
||||
const diagnosticBadgeClass =
|
||||
diagnostic?.status === 'connected'
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
||||
|
|
@ -184,13 +201,19 @@ export const McpServerDetailDialog = ({
|
|||
registryId: server.id,
|
||||
serverName,
|
||||
scope,
|
||||
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
|
||||
envValues,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUninstall = () => {
|
||||
uninstallMcpServer(server.id, serverName, scope);
|
||||
uninstallMcpServer(
|
||||
server.id,
|
||||
uninstallServerName,
|
||||
uninstallScope,
|
||||
uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined
|
||||
);
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
|
|
@ -233,7 +256,7 @@ export const McpServerDetailDialog = ({
|
|||
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||
variant="outline"
|
||||
>
|
||||
Installed
|
||||
{installSummaryLabel ?? 'Installed'}
|
||||
</Badge>
|
||||
)}
|
||||
{server.source !== 'official' && <SourceBadge source={server.source} />}
|
||||
|
|
@ -327,7 +350,7 @@ export const McpServerDetailDialog = ({
|
|||
does not describe them. If connection fails after install, check the provider docs.
|
||||
</div>
|
||||
)}
|
||||
{(isInstalled || diagnosticsLoading) && (
|
||||
{isInstalledForScope && (
|
||||
<div className="space-y-2 rounded-md border border-border bg-surface-raised px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-text">Claude Status</span>
|
||||
|
|
@ -366,7 +389,7 @@ export const McpServerDetailDialog = ({
|
|||
{canAutoInstall && (
|
||||
<div className="space-y-3 rounded-md border border-border bg-surface-raised p-4">
|
||||
<h4 className="text-sm font-medium text-text">
|
||||
{isInstalled ? 'Manage Installation' : 'Install Server'}
|
||||
{isInstalledForScope ? 'Manage Installation' : 'Install Server'}
|
||||
</h4>
|
||||
|
||||
{/* Server name */}
|
||||
|
|
@ -380,6 +403,7 @@ export const McpServerDetailDialog = ({
|
|||
onChange={(e) => setServerName(e.target.value)}
|
||||
placeholder="my-server"
|
||||
className="h-8 text-sm"
|
||||
disabled={isInstalledForScope}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -392,7 +416,11 @@ export const McpServerDetailDialog = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
disabled={opt.value !== 'user' && !projectPath}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -499,7 +527,7 @@ export const McpServerDetailDialog = ({
|
|||
<div className="flex justify-end pt-1">
|
||||
<InstallButton
|
||||
state={installProgress}
|
||||
isInstalled={isInstalled}
|
||||
isInstalled={isInstalledForScope}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
disabled={installDisabled}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ import {
|
|||
import { useStore } from '@renderer/store';
|
||||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||
import {
|
||||
getPreferredMcpInstallationEntry,
|
||||
sanitizeMcpServerName,
|
||||
} from '@shared/utils/extensionNormalizers';
|
||||
import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -55,6 +58,7 @@ function sortMcpServers(servers: McpCatalogItem[], sort: McpSortValue): McpCatal
|
|||
}
|
||||
|
||||
interface McpServersPanelProps {
|
||||
projectPath: string | null;
|
||||
mcpSearchQuery: string;
|
||||
mcpSearch: (query: string) => void;
|
||||
mcpSearchResults: McpCatalogItem[];
|
||||
|
|
@ -65,6 +69,7 @@ interface McpServersPanelProps {
|
|||
}
|
||||
|
||||
export const McpServersPanel = ({
|
||||
projectPath,
|
||||
mcpSearchQuery,
|
||||
mcpSearch,
|
||||
mcpSearchResults,
|
||||
|
|
@ -107,10 +112,10 @@ export const McpServersPanel = ({
|
|||
|
||||
// Load initial browse data
|
||||
useEffect(() => {
|
||||
if (browseCatalog.length === 0 && !browseLoading) {
|
||||
if (browseCatalog.length === 0 && !browseLoading && !browseError) {
|
||||
void mcpBrowse();
|
||||
}
|
||||
}, [browseCatalog.length, browseLoading, mcpBrowse]);
|
||||
}, [browseCatalog.length, browseError, browseLoading, mcpBrowse]);
|
||||
|
||||
useEffect(() => {
|
||||
void runMcpDiagnostics();
|
||||
|
|
@ -136,17 +141,24 @@ export const McpServersPanel = ({
|
|||
[installedServers]
|
||||
);
|
||||
|
||||
const installedEntriesByName = useMemo(
|
||||
() => new Map(installedServers.map((entry) => [entry.name.toLowerCase(), entry] as const)),
|
||||
[installedServers]
|
||||
);
|
||||
const installedEntriesByName = useMemo(() => {
|
||||
const entriesByName = new Map<string, InstalledMcpEntry[]>();
|
||||
for (const entry of installedServers) {
|
||||
const key = entry.name.toLowerCase();
|
||||
entriesByName.set(key, [...(entriesByName.get(key) ?? []), entry]);
|
||||
}
|
||||
return entriesByName;
|
||||
}, [installedServers]);
|
||||
|
||||
/** Check if a catalog server is installed by comparing sanitized names */
|
||||
const isServerInstalled = (server: McpCatalogItem): boolean =>
|
||||
installedNames.has(sanitizeMcpServerName(server.name));
|
||||
|
||||
const getInstalledEntries = (server: McpCatalogItem): InstalledMcpEntry[] =>
|
||||
installedEntriesByName.get(sanitizeMcpServerName(server.name)) ?? [];
|
||||
|
||||
const getInstalledEntry = (server: McpCatalogItem): InstalledMcpEntry | null =>
|
||||
installedEntriesByName.get(sanitizeMcpServerName(server.name)) ?? null;
|
||||
getPreferredMcpInstallationEntry(getInstalledEntries(server));
|
||||
|
||||
const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => {
|
||||
const installedEntry = getInstalledEntry(server);
|
||||
|
|
@ -374,6 +386,8 @@ export const McpServersPanel = ({
|
|||
key={server.id}
|
||||
server={server}
|
||||
isInstalled={isServerInstalled(server)}
|
||||
installedEntry={getInstalledEntry(server)}
|
||||
installedEntries={getInstalledEntries(server)}
|
||||
diagnostic={getDiagnostic(server)}
|
||||
diagnosticsLoading={mcpDiagnosticsLoading}
|
||||
onClick={setSelectedMcpServerId}
|
||||
|
|
@ -400,8 +414,11 @@ export const McpServersPanel = ({
|
|||
<McpServerDetailDialog
|
||||
server={selectedServer}
|
||||
isInstalled={selectedServer ? isServerInstalled(selectedServer) : false}
|
||||
installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null}
|
||||
installedEntries={selectedServer ? getInstalledEntries(selectedServer) : []}
|
||||
diagnostic={selectedServer ? getDiagnostic(selectedServer) : null}
|
||||
diagnosticsLoading={mcpDiagnosticsLoading}
|
||||
projectPath={projectPath}
|
||||
open={selectedMcpServerId !== null}
|
||||
onClose={() => setSelectedMcpServerId(null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useStore } from '@renderer/store';
|
|||
import {
|
||||
getCapabilityLabel,
|
||||
getInstallationSummaryLabel,
|
||||
getPluginOperationKey,
|
||||
hasInstallationInScope,
|
||||
inferCapabilities,
|
||||
normalizeCategory,
|
||||
|
|
@ -27,10 +28,11 @@ interface PluginCardProps {
|
|||
export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => {
|
||||
const capabilities = inferCapabilities(plugin);
|
||||
const category = normalizeCategory(plugin.category);
|
||||
const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle');
|
||||
const operationKey = getPluginOperationKey(plugin.pluginId, 'user');
|
||||
const installProgress = useStore((s) => s.pluginInstallProgress[operationKey] ?? 'idle');
|
||||
const installPlugin = useStore((s) => s.installPlugin);
|
||||
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
||||
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
|
||||
const installError = useStore((s) => s.installErrors[operationKey]);
|
||||
const isUserInstalled = hasInstallationInScope(plugin.installations, 'user');
|
||||
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
|
||||
const baseStriped = index % 2 === 0;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { useStore } from '@renderer/store';
|
|||
import {
|
||||
getCapabilityLabel,
|
||||
getInstallationSummaryLabel,
|
||||
getPluginOperationKey,
|
||||
hasInstallationInScope,
|
||||
inferCapabilities,
|
||||
normalizeCategory,
|
||||
|
|
@ -44,6 +45,7 @@ interface PluginDetailDialogProps {
|
|||
plugin: EnrichedPlugin | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
projectPath: string | null;
|
||||
}
|
||||
|
||||
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
|
||||
|
|
@ -56,31 +58,20 @@ export const PluginDetailDialog = ({
|
|||
plugin,
|
||||
open,
|
||||
onClose,
|
||||
projectPath,
|
||||
}: PluginDetailDialogProps): React.JSX.Element => {
|
||||
const {
|
||||
fetchPluginReadme,
|
||||
readmes,
|
||||
readmeLoading,
|
||||
installPlugin,
|
||||
uninstallPlugin,
|
||||
pluginCatalogProjectPath,
|
||||
} = useStore(
|
||||
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
|
||||
useShallow((s) => ({
|
||||
fetchPluginReadme: s.fetchPluginReadme,
|
||||
readmes: s.pluginReadmes,
|
||||
readmeLoading: s.pluginReadmeLoading,
|
||||
installPlugin: s.installPlugin,
|
||||
uninstallPlugin: s.uninstallPlugin,
|
||||
pluginCatalogProjectPath: s.pluginCatalogProjectPath,
|
||||
}))
|
||||
);
|
||||
const installProgress = useStore(
|
||||
(s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle'
|
||||
);
|
||||
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
|
||||
|
||||
const [scope, setScope] = useState<InstallScope>('user');
|
||||
const projectScopeAvailable = Boolean(pluginCatalogProjectPath);
|
||||
const projectScopeAvailable = Boolean(projectPath);
|
||||
|
||||
useEffect(() => {
|
||||
if (plugin && open) {
|
||||
|
|
@ -100,6 +91,12 @@ export const PluginDetailDialog = ({
|
|||
}
|
||||
}, [projectScopeAvailable, scope]);
|
||||
|
||||
const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null;
|
||||
const installProgress = useStore(
|
||||
(s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle'
|
||||
);
|
||||
const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined));
|
||||
|
||||
if (!plugin) return <></>;
|
||||
|
||||
const capabilities = inferCapabilities(plugin);
|
||||
|
|
@ -202,16 +199,14 @@ export const PluginDetailDialog = ({
|
|||
installPlugin({
|
||||
pluginId: plugin.pluginId,
|
||||
scope,
|
||||
...(scope !== 'user' && pluginCatalogProjectPath
|
||||
? { projectPath: pluginCatalogProjectPath }
|
||||
: {}),
|
||||
...(scope !== 'user' && projectPath ? { projectPath } : {}),
|
||||
})
|
||||
}
|
||||
onUninstall={() =>
|
||||
uninstallPlugin(
|
||||
plugin.pluginId,
|
||||
scope,
|
||||
scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined
|
||||
scope !== 'user' ? (projectPath ?? undefined) : undefined
|
||||
)
|
||||
}
|
||||
size="default"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import type {
|
|||
} from '@shared/types/extensions';
|
||||
|
||||
interface PluginsPanelProps {
|
||||
projectPath: string | null;
|
||||
pluginFilters: PluginFilters;
|
||||
pluginSort: { field: PluginSortField; order: 'asc' | 'desc' };
|
||||
selectedPluginId: string | null;
|
||||
|
|
@ -111,6 +112,7 @@ function selectFilteredPlugins(
|
|||
}
|
||||
|
||||
export const PluginsPanel = ({
|
||||
projectPath,
|
||||
pluginFilters,
|
||||
pluginSort,
|
||||
selectedPluginId,
|
||||
|
|
@ -395,6 +397,7 @@ export const PluginsPanel = ({
|
|||
plugin={selectedPlugin}
|
||||
open={selectedPluginId !== null}
|
||||
onClose={() => setSelectedPluginId(null)}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import { useStore } from '@renderer/store';
|
|||
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||
|
||||
interface SkillDetailDialogProps {
|
||||
skillId: string | null;
|
||||
open: boolean;
|
||||
|
|
@ -58,8 +60,13 @@ export const SkillDetailDialog = ({
|
|||
|
||||
useEffect(() => {
|
||||
if (!open || !skillId) return;
|
||||
void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined);
|
||||
}, [fetchSkillDetail, open, projectPath, skillId]);
|
||||
void fetchSkillDetail(
|
||||
skillId,
|
||||
detail?.item.scope
|
||||
? resolveSkillProjectPath(detail.item.scope, projectPath, detail.item.projectRoot)
|
||||
: (projectPath ?? undefined)
|
||||
).catch(() => undefined);
|
||||
}, [detail?.item.projectRoot, detail?.item.scope, fetchSkillDetail, open, projectPath, skillId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -70,6 +77,9 @@ export const SkillDetailDialog = ({
|
|||
}, [open]);
|
||||
|
||||
const item = detail?.item;
|
||||
const effectiveProjectPath = item
|
||||
? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot)
|
||||
: (projectPath ?? undefined);
|
||||
|
||||
function formatRootKind(rootKind: 'claude' | 'cursor' | 'agents'): string {
|
||||
return `.${rootKind}`;
|
||||
|
|
@ -92,7 +102,7 @@ export const SkillDetailDialog = ({
|
|||
try {
|
||||
await deleteSkill({
|
||||
skillId: item.id,
|
||||
projectPath: projectPath ?? undefined,
|
||||
projectPath: effectiveProjectPath,
|
||||
});
|
||||
setDeleteConfirmOpen(false);
|
||||
onDeleted();
|
||||
|
|
@ -125,7 +135,7 @@ export const SkillDetailDialog = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined);
|
||||
void fetchSkillDetail(skillId, effectiveProjectPath).catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
|
|
@ -288,7 +298,7 @@ export const SkillDetailDialog = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void api.openPath(item.skillFile, projectPath ?? undefined)}
|
||||
onClick={() => void api.openPath(item.skillFile, effectiveProjectPath)}
|
||||
>
|
||||
<ExternalLink className="mr-1.5 size-3.5" />
|
||||
Open SKILL.md
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@ import {
|
|||
readSkillTemplateContent,
|
||||
updateSkillTemplateFrontmatter,
|
||||
} from './skillDraftUtils';
|
||||
import { toSuggestedSkillFolderName } from './skillFolderNameUtils';
|
||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||
import { validateSkillFolderName } from './skillValidationUtils';
|
||||
|
||||
import type {
|
||||
SkillDetail,
|
||||
|
|
@ -60,16 +63,6 @@ function parseInitialDescription(detail: SkillDetail | null): string {
|
|||
return detail?.item.description ?? '';
|
||||
}
|
||||
|
||||
function toSuggestedFolderName(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\x00-\x7F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
export const SkillEditorDialog = ({
|
||||
open,
|
||||
mode,
|
||||
|
|
@ -191,7 +184,7 @@ export const SkillEditorDialog = ({
|
|||
notes: nextNotes,
|
||||
});
|
||||
const rawInput = readSkillTemplateContent(nextRawContent);
|
||||
const suggestedFolderName = toSuggestedFolderName(nextName || 'New Skill');
|
||||
const suggestedFolderName = toSuggestedSkillFolderName(nextName || 'New Skill');
|
||||
const hasCustomMarkdown = mode === 'edit' && rawInput.hasUnstructuredBody;
|
||||
|
||||
setScope(nextScope);
|
||||
|
|
@ -227,15 +220,43 @@ export const SkillEditorDialog = ({
|
|||
setMutationError(null);
|
||||
}, [detail, mode, open, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewPreview(null);
|
||||
setReviewOpen(false);
|
||||
setReviewLoading(false);
|
||||
setSaveLoading(false);
|
||||
setMutationError(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && mode === 'create' && scope === 'project' && !projectPath) {
|
||||
setScope('user');
|
||||
}
|
||||
}, [mode, open, projectPath, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
rawContentRef.current = rawContent;
|
||||
}, [rawContent]);
|
||||
|
||||
const effectiveProjectPath = useMemo(
|
||||
() =>
|
||||
resolveSkillProjectPath(
|
||||
scope,
|
||||
projectPath,
|
||||
mode === 'edit' ? detail?.item.projectRoot : undefined
|
||||
),
|
||||
[detail?.item.projectRoot, mode, projectPath, scope]
|
||||
);
|
||||
|
||||
const request = useMemo(
|
||||
() => ({
|
||||
scope,
|
||||
rootKind,
|
||||
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
|
||||
projectPath: effectiveProjectPath,
|
||||
folderName,
|
||||
existingSkillId: mode === 'edit' ? detail?.item.id : undefined,
|
||||
files: buildSkillDraftFiles({
|
||||
|
|
@ -252,10 +273,10 @@ export const SkillEditorDialog = ({
|
|||
includeReferences,
|
||||
includeScripts,
|
||||
mode,
|
||||
projectPath,
|
||||
rawContent,
|
||||
rootKind,
|
||||
scope,
|
||||
effectiveProjectPath,
|
||||
]
|
||||
);
|
||||
const draftFilePaths = useMemo(
|
||||
|
|
@ -285,7 +306,11 @@ export const SkillEditorDialog = ({
|
|||
if (!folderName.trim()) {
|
||||
return 'Choose a folder name for this skill.';
|
||||
}
|
||||
if (scope === 'project' && !projectPath) {
|
||||
const folderNameError = validateSkillFolderName(folderName);
|
||||
if (folderNameError) {
|
||||
return folderNameError;
|
||||
}
|
||||
if (scope === 'project' && !effectiveProjectPath) {
|
||||
return 'Project skills need an active project.';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -468,7 +493,7 @@ export const SkillEditorDialog = ({
|
|||
const nextValue = event.target.value;
|
||||
setName(nextValue);
|
||||
if (mode === 'create' && !folderNameEdited) {
|
||||
setFolderName(toSuggestedFolderName(nextValue || 'New Skill'));
|
||||
setFolderName(toSuggestedSkillFolderName(nextValue || 'New Skill'));
|
||||
}
|
||||
applyFormToRawContent({ name: nextValue });
|
||||
}}
|
||||
|
|
@ -713,6 +738,7 @@ export const SkillEditorDialog = ({
|
|||
size="sm"
|
||||
onClick={() => {
|
||||
setManualRawEdit(false);
|
||||
setCustomMarkdownDetected(false);
|
||||
const nextRawContent = buildSkillTemplate({
|
||||
name,
|
||||
description,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ import {
|
|||
import { useStore } from '@renderer/store';
|
||||
import { FileSearch, FolderOpen, X } from 'lucide-react';
|
||||
|
||||
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
|
||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||
import { validateSkillFolderName, validateSkillImportSourceDir } from './skillValidationUtils';
|
||||
|
||||
import type { SkillReviewPreview } from '@shared/types/extensions';
|
||||
|
||||
|
|
@ -68,6 +71,7 @@ export const SkillImportDialog = ({
|
|||
|
||||
const [sourceDir, setSourceDir] = useState('');
|
||||
const [folderName, setFolderName] = useState('');
|
||||
const [folderNameEdited, setFolderNameEdited] = useState(false);
|
||||
const [scope, setScope] = useState<'user' | 'project'>('user');
|
||||
const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude');
|
||||
const [preview, setPreview] = useState<SkillReviewPreview | null>(null);
|
||||
|
|
@ -80,6 +84,7 @@ export const SkillImportDialog = ({
|
|||
if (!open) return;
|
||||
setSourceDir('');
|
||||
setFolderName('');
|
||||
setFolderNameEdited(false);
|
||||
setScope(projectPath ? 'project' : 'user');
|
||||
setRootKind('claude');
|
||||
setPreview(null);
|
||||
|
|
@ -89,27 +94,63 @@ export const SkillImportDialog = ({
|
|||
setMutationError(null);
|
||||
}, [open, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview(null);
|
||||
setReviewOpen(false);
|
||||
setReviewLoading(false);
|
||||
setImportLoading(false);
|
||||
setMutationError(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || folderNameEdited) {
|
||||
return;
|
||||
}
|
||||
setFolderName(sourceDir.trim() ? getSuggestedSkillFolderNameFromPath(sourceDir) : '');
|
||||
}, [folderNameEdited, open, sourceDir]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && scope === 'project' && !projectPath) {
|
||||
setScope('user');
|
||||
}
|
||||
}, [open, projectPath, scope]);
|
||||
|
||||
async function handleChooseFolder(): Promise<void> {
|
||||
const selected = await api.config.selectFolders();
|
||||
const first = selected[0];
|
||||
if (!first) return;
|
||||
setSourceDir(first);
|
||||
if (!folderName) {
|
||||
const segments = first.split(/[\\/]/u).filter(Boolean);
|
||||
setFolderName(segments.at(-1) ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReview(): Promise<void> {
|
||||
const normalizedSourceDir = sourceDir.trim();
|
||||
const normalizedFolderName = folderName.trim();
|
||||
const sourceDirError = validateSkillImportSourceDir(sourceDir);
|
||||
if (sourceDirError) {
|
||||
setMutationError(sourceDirError);
|
||||
return;
|
||||
}
|
||||
|
||||
const folderNameError =
|
||||
normalizedFolderName.length > 0 ? validateSkillFolderName(normalizedFolderName) : null;
|
||||
if (folderNameError) {
|
||||
setMutationError(folderNameError);
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewLoading(true);
|
||||
setMutationError(null);
|
||||
try {
|
||||
const nextPreview = await previewSkillImport({
|
||||
sourceDir,
|
||||
folderName: folderName || undefined,
|
||||
sourceDir: normalizedSourceDir,
|
||||
folderName: normalizedFolderName || undefined,
|
||||
scope,
|
||||
rootKind,
|
||||
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
|
||||
projectPath: resolveSkillProjectPath(scope, projectPath),
|
||||
});
|
||||
setPreview(nextPreview);
|
||||
setReviewOpen(true);
|
||||
|
|
@ -125,15 +166,18 @@ export const SkillImportDialog = ({
|
|||
}
|
||||
|
||||
async function handleConfirmImport(): Promise<void> {
|
||||
const normalizedSourceDir = sourceDir.trim();
|
||||
const normalizedFolderName = folderName.trim();
|
||||
|
||||
setImportLoading(true);
|
||||
setMutationError(null);
|
||||
try {
|
||||
const detail = await applySkillImport({
|
||||
sourceDir,
|
||||
folderName: folderName || undefined,
|
||||
sourceDir: normalizedSourceDir,
|
||||
folderName: normalizedFolderName || undefined,
|
||||
scope,
|
||||
rootKind,
|
||||
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
|
||||
projectPath: resolveSkillProjectPath(scope, projectPath),
|
||||
reviewPlanId: preview?.planId,
|
||||
});
|
||||
setReviewOpen(false);
|
||||
|
|
@ -190,7 +234,10 @@ export const SkillImportDialog = ({
|
|||
<Input
|
||||
id="skill-import-folder"
|
||||
value={folderName}
|
||||
onChange={(event) => setFolderName(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setFolderNameEdited(true);
|
||||
setFolderName(event.target.value);
|
||||
}}
|
||||
placeholder="Defaults to source folder name"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -260,7 +307,7 @@ export const SkillImportDialog = ({
|
|||
</p>
|
||||
<Button
|
||||
onClick={() => void handleReview()}
|
||||
disabled={!sourceDir || reviewLoading || importLoading}
|
||||
disabled={!sourceDir.trim() || reviewLoading || importLoading}
|
||||
>
|
||||
<FileSearch className="mr-1.5 size-3.5" />
|
||||
{reviewLoading ? 'Preparing...' : 'Review And Import'}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { SearchInput } from '../common/SearchInput';
|
|||
import { SkillDetailDialog } from './SkillDetailDialog';
|
||||
import { SkillEditorDialog } from './SkillEditorDialog';
|
||||
import { SkillImportDialog } from './SkillImportDialog';
|
||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||
|
||||
import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
||||
|
|
@ -109,6 +110,7 @@ export const SkillsPanel = ({
|
|||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [highlightedSkillId, setHighlightedSkillId] = useState<string | null>(null);
|
||||
const selectedSkillIdRef = useRef<string | null>(selectedSkillId);
|
||||
const selectedSkillItemRef = useRef<SkillCatalogItem | SkillDetail['item'] | null>(null);
|
||||
selectedSkillIdRef.current = selectedSkillId;
|
||||
|
||||
const mergedSkills = useMemo(
|
||||
|
|
@ -116,6 +118,9 @@ export const SkillsPanel = ({
|
|||
[projectSkills, userSkills]
|
||||
);
|
||||
const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null;
|
||||
selectedSkillItemRef.current = selectedSkillId
|
||||
? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkillId) return;
|
||||
|
|
@ -155,10 +160,19 @@ export const SkillsPanel = ({
|
|||
if (!shouldRefresh) return;
|
||||
|
||||
void fetchSkillsCatalog(projectPath ?? undefined);
|
||||
if (selectedSkillIdRef.current) {
|
||||
void fetchSkillDetail(selectedSkillIdRef.current, projectPath ?? undefined).catch(
|
||||
() => undefined
|
||||
);
|
||||
const selectedSkillId = selectedSkillIdRef.current;
|
||||
const selectedSkillItem = selectedSkillItemRef.current;
|
||||
if (selectedSkillId) {
|
||||
void fetchSkillDetail(
|
||||
selectedSkillId,
|
||||
selectedSkillItem
|
||||
? resolveSkillProjectPath(
|
||||
selectedSkillItem.scope,
|
||||
projectPath,
|
||||
selectedSkillItem.projectRoot
|
||||
)
|
||||
: (projectPath ?? undefined)
|
||||
).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
export function toSuggestedSkillFolderName(value: string, fallback = 'new-skill'): string {
|
||||
const normalized = value
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\x00-\x7F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export function getSuggestedSkillFolderNameFromPath(
|
||||
value: string,
|
||||
fallback = 'imported-skill'
|
||||
): string {
|
||||
const segments = value.split(/[\\/]/u).filter(Boolean);
|
||||
return toSuggestedSkillFolderName(segments.at(-1) ?? '', fallback);
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import type { SkillScope } from '@shared/types/extensions';
|
||||
|
||||
export function resolveSkillProjectPath(
|
||||
scope: SkillScope,
|
||||
currentProjectPath: string | null,
|
||||
itemProjectRoot?: string | null
|
||||
): string | undefined {
|
||||
if (scope !== 'project') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return itemProjectRoot ?? currentProjectPath ?? undefined;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
const MAX_SKILL_FOLDER_NAME_LENGTH = 255;
|
||||
const INVALID_SKILL_FOLDER_NAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/u;
|
||||
|
||||
export function validateSkillImportSourceDir(value: string): string | null {
|
||||
if (value.trim().length === 0) {
|
||||
return 'Choose a skill folder to import.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateSkillFolderName(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return 'Choose a folder name for this skill.';
|
||||
}
|
||||
|
||||
if (trimmed.length > MAX_SKILL_FOLDER_NAME_LENGTH) {
|
||||
return `Folder name must be ${MAX_SKILL_FOLDER_NAME_LENGTH} characters or fewer.`;
|
||||
}
|
||||
|
||||
if (trimmed === '.' || trimmed === '..') {
|
||||
return 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.';
|
||||
}
|
||||
|
||||
if (INVALID_SKILL_FOLDER_NAME_CHARS.test(trimmed)) {
|
||||
return 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ export function useExtensionsTabState() {
|
|||
|
||||
// ── Debounced MCP search ──
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mcpSearchRequestSeqRef = useRef(0);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
|
|
@ -65,11 +66,25 @@ export function useExtensionsTabState() {
|
|||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
mcpSearchRequestSeqRef.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSubTab !== 'plugins' && selectedPluginId !== null) {
|
||||
setSelectedPluginId(null);
|
||||
}
|
||||
if (activeSubTab !== 'mcp-servers' && selectedMcpServerId !== null) {
|
||||
setSelectedMcpServerId(null);
|
||||
}
|
||||
if (activeSubTab !== 'skills' && selectedSkillId !== null) {
|
||||
setSelectedSkillId(null);
|
||||
}
|
||||
}, [activeSubTab, selectedMcpServerId, selectedPluginId, selectedSkillId]);
|
||||
|
||||
const mcpSearch = useCallback((query: string) => {
|
||||
setMcpSearchQuery(query);
|
||||
const requestId = ++mcpSearchRequestSeqRef.current;
|
||||
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
|
|
@ -86,17 +101,25 @@ export function useExtensionsTabState() {
|
|||
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
if (!api.mcpRegistry) {
|
||||
setMcpSearchLoading(false);
|
||||
if (mcpSearchRequestSeqRef.current === requestId) {
|
||||
setMcpSearchLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void api.mcpRegistry.search(query).then(
|
||||
(result: McpSearchResult) => {
|
||||
if (mcpSearchRequestSeqRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setMcpSearchResults(result.servers);
|
||||
setMcpSearchWarnings(result.warnings);
|
||||
setMcpSearchLoading(false);
|
||||
},
|
||||
() => {
|
||||
if (mcpSearchRequestSeqRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setMcpSearchLoading(false);
|
||||
setMcpSearchWarnings(['Search failed']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||
import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers';
|
||||
|
||||
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
||||
|
||||
|
|
@ -59,7 +60,7 @@ export interface ExtensionsSlice {
|
|||
// ── Install progress ──
|
||||
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
||||
mcpInstallProgress: Record<string, ExtensionOperationState>;
|
||||
installErrors: Record<string, string>; // keyed by pluginId or registryId
|
||||
installErrors: Record<string, string>; // keyed by scoped operation key
|
||||
|
||||
// ── API Keys ──
|
||||
apiKeys: ApiKeyEntry[];
|
||||
|
|
@ -130,6 +131,7 @@ export interface ExtensionsSlice {
|
|||
let pluginFetchInFlight: { key: string; promise: Promise<void>; token: symbol } | null = null;
|
||||
let pluginCatalogRequestSeq = 0;
|
||||
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
||||
let skillsCatalogRequestSeq = 0;
|
||||
let skillsDetailRequestSeq = 0;
|
||||
|
|
@ -150,6 +152,10 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
|
|||
return new Set(catalog.map((plugin) => plugin.pluginId));
|
||||
}
|
||||
|
||||
function buildPluginOperationKeys(pluginId: string): string[] {
|
||||
return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope));
|
||||
}
|
||||
|
||||
function clearPluginOperationState(
|
||||
pluginIds: Set<string>,
|
||||
pluginInstallProgress: Record<string, ExtensionOperationState>,
|
||||
|
|
@ -166,8 +172,10 @@ function clearPluginOperationState(
|
|||
const nextInstallErrors = { ...installErrors };
|
||||
|
||||
for (const pluginId of pluginIds) {
|
||||
delete nextPluginInstallProgress[pluginId];
|
||||
delete nextInstallErrors[pluginId];
|
||||
for (const operationKey of buildPluginOperationKeys(pluginId)) {
|
||||
delete nextPluginInstallProgress[operationKey];
|
||||
delete nextInstallErrors[operationKey];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -176,40 +184,118 @@ function clearPluginOperationState(
|
|||
};
|
||||
}
|
||||
|
||||
function clearPluginSuccessResetTimer(pluginId: string): void {
|
||||
const timer = pluginSuccessResetTimers.get(pluginId);
|
||||
function clearPluginSuccessResetTimer(operationKey: string): void {
|
||||
const timer = pluginSuccessResetTimers.get(operationKey);
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
pluginSuccessResetTimers.delete(pluginId);
|
||||
pluginSuccessResetTimers.delete(operationKey);
|
||||
}
|
||||
|
||||
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
||||
for (const pluginId of pluginIds) {
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
for (const operationKey of buildPluginOperationKeys(pluginId)) {
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePluginSuccessReset(
|
||||
pluginId: string,
|
||||
operationKey: string,
|
||||
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
|
||||
): void {
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
const timer = setTimeout(() => {
|
||||
pluginSuccessResetTimers.delete(pluginId);
|
||||
pluginSuccessResetTimers.delete(operationKey);
|
||||
set((prev) => {
|
||||
if (prev.pluginInstallProgress[pluginId] !== 'success') {
|
||||
if (prev.pluginInstallProgress[operationKey] !== 'success') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'idle' },
|
||||
};
|
||||
});
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
pluginSuccessResetTimers.set(pluginId, timer);
|
||||
pluginSuccessResetTimers.set(operationKey, timer);
|
||||
}
|
||||
|
||||
function getCustomMcpOperationKey(serverName: string, scope: InstallScope): string {
|
||||
return `mcp-custom:${serverName}:${scope}`;
|
||||
}
|
||||
|
||||
function clearMcpSuccessResetTimer(operationKey: string): void {
|
||||
const timer = mcpSuccessResetTimers.get(operationKey);
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
mcpSuccessResetTimers.delete(operationKey);
|
||||
}
|
||||
|
||||
function scheduleMcpSuccessReset(
|
||||
operationKey: string,
|
||||
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
|
||||
): void {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
const timer = setTimeout(() => {
|
||||
mcpSuccessResetTimers.delete(operationKey);
|
||||
set((prev) => {
|
||||
if (prev.mcpInstallProgress[operationKey] !== 'success') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'idle' },
|
||||
};
|
||||
});
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
mcpSuccessResetTimers.set(operationKey, timer);
|
||||
}
|
||||
|
||||
function clearMcpProjectScopedOperationState(
|
||||
mcpInstallProgress: Record<string, ExtensionOperationState>,
|
||||
installErrors: Record<string, string>
|
||||
): {
|
||||
mcpInstallProgress: Record<string, ExtensionOperationState>;
|
||||
installErrors: Record<string, string>;
|
||||
} {
|
||||
const nextMcpInstallProgress = { ...mcpInstallProgress };
|
||||
const nextInstallErrors = { ...installErrors };
|
||||
|
||||
for (const operationKey of Object.keys(nextMcpInstallProgress)) {
|
||||
if (
|
||||
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
||||
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
|
||||
) {
|
||||
delete nextMcpInstallProgress[operationKey];
|
||||
}
|
||||
}
|
||||
|
||||
for (const operationKey of Object.keys(nextInstallErrors)) {
|
||||
if (
|
||||
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
||||
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
|
||||
) {
|
||||
delete nextInstallErrors[operationKey];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mcpInstallProgress: nextMcpInstallProgress,
|
||||
installErrors: nextInstallErrors,
|
||||
};
|
||||
}
|
||||
|
||||
function clearMcpProjectScopedSuccessResetTimers(): void {
|
||||
for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) {
|
||||
if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSkillsCatalogKey(projectPath?: string): string {
|
||||
|
|
@ -226,6 +312,7 @@ const CLI_STATUS_UNKNOWN_MESSAGE =
|
|||
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
|
||||
const PROJECT_SCOPE_REQUIRED_MESSAGE =
|
||||
'Project- and local-scoped plugins require an active project in the Extensions tab.';
|
||||
const PLUGIN_OPERATION_SCOPES: InstallScope[] = ['user', 'project', 'local'];
|
||||
|
||||
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||
set,
|
||||
|
|
@ -425,9 +512,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
try {
|
||||
const installed = await api.mcpRegistry.getInstalled(projectPath);
|
||||
set({
|
||||
mcpInstalledServers: installed,
|
||||
mcpInstalledProjectPath: projectPath ?? null,
|
||||
set((prev) => {
|
||||
const nextProjectPath = projectPath ?? null;
|
||||
const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath;
|
||||
const nextOperationState = isSameProjectContext
|
||||
? {
|
||||
mcpInstallProgress: prev.mcpInstallProgress,
|
||||
installErrors: prev.installErrors,
|
||||
}
|
||||
: clearMcpProjectScopedOperationState(prev.mcpInstallProgress, prev.installErrors);
|
||||
|
||||
if (!isSameProjectContext) {
|
||||
clearMcpProjectScopedSuccessResetTimers();
|
||||
}
|
||||
|
||||
return {
|
||||
mcpInstalledServers: installed,
|
||||
mcpInstalledProjectPath: nextProjectPath,
|
||||
mcpInstallProgress: nextOperationState.mcpInstallProgress,
|
||||
installErrors: nextOperationState.installErrors,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Silently fail — installed state is supplementary
|
||||
|
|
@ -692,6 +796,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
request.scope !== 'user'
|
||||
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||
: request.projectPath;
|
||||
const operationKey = getPluginOperationKey(request.pluginId, request.scope);
|
||||
const effectiveRequest =
|
||||
effectiveProjectPath === request.projectPath
|
||||
? request
|
||||
|
|
@ -721,47 +826,47 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
: null;
|
||||
|
||||
if (preflightError) {
|
||||
clearPluginSuccessResetTimer(request.pluginId);
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: preflightError },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
clearPluginSuccessResetTimer(request.pluginId);
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: '' },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await api.plugins.install(effectiveRequest);
|
||||
if (result.state === 'error') {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: {
|
||||
...prev.installErrors,
|
||||
[request.pluginId]: result.error ?? 'Install failed',
|
||||
[operationKey]: result.error ?? 'Install failed',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'success' },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' },
|
||||
}));
|
||||
|
||||
// Refresh catalog to pick up new installed state
|
||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||
|
||||
schedulePluginSuccessReset(request.pluginId, set);
|
||||
schedulePluginSuccessReset(operationKey, set);
|
||||
} catch (err) {
|
||||
clearPluginSuccessResetTimer(request.pluginId);
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
const message = err instanceof Error ? err.message : 'Install failed';
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: message },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: message },
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
|
@ -770,77 +875,85 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
|
||||
if (!api.plugins) return;
|
||||
|
||||
const effectiveScope = scope ?? 'user';
|
||||
const operationKey = getPluginOperationKey(pluginId, effectiveScope);
|
||||
const effectiveProjectPath =
|
||||
scope && scope !== 'user'
|
||||
effectiveScope !== 'user'
|
||||
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||
: projectPath;
|
||||
if (scope && scope !== 'user' && !effectiveProjectPath) {
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
if (effectiveScope !== 'user' && !effectiveProjectPath) {
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: PROJECT_SCOPE_REQUIRED_MESSAGE },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath);
|
||||
if (result.state === 'error') {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [pluginId]: result.error ?? 'Uninstall failed' },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: {
|
||||
...prev.installErrors,
|
||||
[operationKey]: result.error ?? 'Uninstall failed',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'success' },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' },
|
||||
}));
|
||||
|
||||
// Refresh catalog
|
||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||
|
||||
schedulePluginSuccessReset(pluginId, set);
|
||||
schedulePluginSuccessReset(operationKey, set);
|
||||
} catch (err) {
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
clearPluginSuccessResetTimer(operationKey);
|
||||
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [pluginId]: message },
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: message },
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// ── MCP install ──
|
||||
installMcpServer: async (request: McpInstallRequest) => {
|
||||
const operationKey = getMcpOperationKey(request.registryId, request.scope);
|
||||
if (!api.mcpRegistry) {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: {
|
||||
...prev.installErrors,
|
||||
[request.registryId]: 'MCP Registry not available',
|
||||
[operationKey]: 'MCP Registry not available',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await api.mcpRegistry.install(request);
|
||||
if (result.state === 'error') {
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: {
|
||||
...prev.installErrors,
|
||||
[request.registryId]: result.error ?? 'Install failed',
|
||||
[operationKey]: result.error ?? 'Install failed',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
|
|
@ -852,27 +965,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
]);
|
||||
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' },
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' },
|
||||
}));
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
scheduleMcpSuccessReset(operationKey, set);
|
||||
} catch (err) {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
const message = err instanceof Error ? err.message : 'Install failed';
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [request.registryId]: message },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: message },
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// ── MCP custom install ──
|
||||
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
|
||||
const operationScope = request.scope;
|
||||
const progressKey = getCustomMcpOperationKey(request.serverName, operationScope);
|
||||
if (!api.mcpRegistry) {
|
||||
const progressKey = `custom:${request.serverName}`;
|
||||
clearMcpSuccessResetTimer(progressKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
|
||||
|
|
@ -880,7 +992,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
return;
|
||||
}
|
||||
|
||||
const progressKey = `custom:${request.serverName}`;
|
||||
clearMcpSuccessResetTimer(progressKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
||||
}));
|
||||
|
|
@ -904,12 +1016,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' },
|
||||
}));
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
scheduleMcpSuccessReset(progressKey, set);
|
||||
} catch (err) {
|
||||
clearMcpSuccessResetTimer(progressKey);
|
||||
const message = err instanceof Error ? err.message : 'Install failed';
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||
|
|
@ -925,26 +1034,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
scope?: string,
|
||||
projectPath?: string
|
||||
) => {
|
||||
const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user';
|
||||
const operationKey = getMcpOperationKey(registryId, operationScope);
|
||||
if (!api.mcpRegistry) {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [registryId]: 'MCP Registry not available' },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: 'MCP Registry not available' },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await api.mcpRegistry.uninstall(name, scope, projectPath);
|
||||
if (result.state === 'error') {
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: {
|
||||
...prev.installErrors,
|
||||
[registryId]: result.error ?? 'Uninstall failed',
|
||||
[operationKey]: result.error ?? 'Uninstall failed',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
|
|
@ -956,19 +1069,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
]);
|
||||
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' },
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },
|
||||
}));
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
scheduleMcpSuccessReset(operationKey, set);
|
||||
} catch (err) {
|
||||
clearMcpSuccessResetTimer(operationKey);
|
||||
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [registryId]: message },
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [operationKey]: message },
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
InstalledMcpEntry,
|
||||
InstalledPluginEntry,
|
||||
InstallScope,
|
||||
PluginCapability,
|
||||
|
|
@ -100,6 +101,20 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
|
|||
return `${pluginName}@${marketplaceName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespaced operation-state key for plugin install/uninstall UI state.
|
||||
*/
|
||||
export function getPluginOperationKey(pluginId: string, scope: InstallScope): string {
|
||||
return `plugin:${pluginId}:${scope}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespaced operation-state key for MCP install/uninstall UI state.
|
||||
*/
|
||||
export function getMcpOperationKey(registryId: string, scope: InstallScope): string {
|
||||
return `mcp:${registryId}:${scope}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a plugin has an installation for the selected scope.
|
||||
*/
|
||||
|
|
@ -137,12 +152,64 @@ export function getInstallationSummaryLabel(
|
|||
}
|
||||
}
|
||||
|
||||
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
|
||||
local: 0,
|
||||
project: 1,
|
||||
user: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick the MCP installation entry that Claude will actually use.
|
||||
* Scope precedence matches Claude Code: local > project > user.
|
||||
*/
|
||||
export function getPreferredMcpInstallationEntry(
|
||||
installations: InstalledMcpEntry[]
|
||||
): InstalledMcpEntry | null {
|
||||
if (installations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [...installations].sort(
|
||||
(left, right) => MCP_SCOPE_PRIORITY[left.scope] - MCP_SCOPE_PRIORITY[right.scope]
|
||||
)[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a concise install-status label for MCP badges.
|
||||
*/
|
||||
export function getMcpInstallationSummaryLabel(
|
||||
installations: Pick<InstalledMcpEntry, '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;
|
||||
cliStatus: Pick<
|
||||
CliInstallationStatus,
|
||||
'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError'
|
||||
> | null;
|
||||
cliStatusLoading: boolean;
|
||||
}): string | null {
|
||||
const { isInstalled, cliStatus, cliStatusLoading } = options;
|
||||
|
|
@ -155,6 +222,9 @@ export function getExtensionActionDisableReason(options: {
|
|||
}
|
||||
|
||||
if (cliStatus.installed === false) {
|
||||
if (cliStatus.binaryPath && cliStatus.launchError) {
|
||||
return 'Claude CLI was found but failed to start. Open the Dashboard to repair or reinstall it.';
|
||||
}
|
||||
return 'Claude CLI required. Install it from the Dashboard.';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ describe('McpInstallService', () => {
|
|||
registryId: 'upstash/context7-mcp',
|
||||
serverName: 'context7',
|
||||
scope: 'local',
|
||||
projectPath: '/tmp/test',
|
||||
envValues: {},
|
||||
headers: [],
|
||||
});
|
||||
|
|
@ -216,6 +217,34 @@ describe('McpInstallService', () => {
|
|||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('Manual setup required');
|
||||
});
|
||||
|
||||
it('rejects project scope install without project path', async () => {
|
||||
const result = await service.install({
|
||||
registryId: 'upstash/context7-mcp',
|
||||
serverName: 'context7',
|
||||
scope: 'project',
|
||||
envValues: {},
|
||||
headers: [],
|
||||
});
|
||||
|
||||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('projectPath is required');
|
||||
expect(mockExecCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects local scope install without project path', async () => {
|
||||
const result = await service.install({
|
||||
registryId: 'upstash/context7-mcp',
|
||||
serverName: 'context7',
|
||||
scope: 'local',
|
||||
envValues: {},
|
||||
headers: [],
|
||||
});
|
||||
|
||||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('projectPath is required');
|
||||
expect(mockExecCli).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── install: error masking ──────────────────────────────────────────────────
|
||||
|
|
@ -259,6 +288,42 @@ describe('McpInstallService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('installCustom (validation)', () => {
|
||||
it('rejects project scope custom install without project path', async () => {
|
||||
const result = await service.installCustom({
|
||||
serverName: 'custom-context7',
|
||||
scope: 'project',
|
||||
installSpec: {
|
||||
type: 'stdio',
|
||||
npmPackage: '@upstash/context7-mcp',
|
||||
},
|
||||
envValues: {},
|
||||
headers: [],
|
||||
});
|
||||
|
||||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('projectPath is required');
|
||||
expect(mockExecCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects local scope custom install without project path', async () => {
|
||||
const result = await service.installCustom({
|
||||
serverName: 'custom-context7',
|
||||
scope: 'local',
|
||||
installSpec: {
|
||||
type: 'stdio',
|
||||
npmPackage: '@upstash/context7-mcp',
|
||||
},
|
||||
envValues: {},
|
||||
headers: [],
|
||||
});
|
||||
|
||||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('projectPath is required');
|
||||
expect(mockExecCli).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── uninstall ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('uninstall', () => {
|
||||
|
|
@ -293,6 +358,22 @@ describe('McpInstallService', () => {
|
|||
expect(mockExecCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects project scope uninstall without project path', async () => {
|
||||
const result = await service.uninstall('context7', 'project');
|
||||
|
||||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('projectPath is required');
|
||||
expect(mockExecCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects local scope uninstall without project path', async () => {
|
||||
const result = await service.uninstall('context7', 'local');
|
||||
|
||||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('projectPath is required');
|
||||
expect(mockExecCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error on CLI failure', async () => {
|
||||
mockExecCli.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
|
||||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getHomeDir: () => '/tmp/mock-home',
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
describe('McpInstallationStateService', () => {
|
||||
let service: McpInstallationStateService;
|
||||
const mockedFs = vi.mocked(fs);
|
||||
|
||||
beforeEach(() => {
|
||||
service = new McpInstallationStateService();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getInstalled', () => {
|
||||
it('includes local scope from the current project entry in ~/.claude.json', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (normalizedPath === '/tmp/mock-home/.claude.json') {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
context7: { command: 'npx -y @upstash/context7-mcp' },
|
||||
},
|
||||
projects: {
|
||||
'/tmp/project-a': {
|
||||
mcpServers: {
|
||||
stripe: { url: 'https://mcp.stripe.com' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-a/.mcp.json') {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
paypal: { url: 'https://mcp.paypal.com/mcp' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const entries = await service.getInstalled('/tmp/project-a');
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
||||
{ name: 'stripe', scope: 'local', transport: 'http' },
|
||||
{ name: 'paypal', scope: 'project', transport: 'http' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('caches results within TTL for the same project path', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (normalizedPath === '/tmp/mock-home/.claude.json') {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
context7: { command: 'npx -y @upstash/context7-mcp' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-a/.mcp.json') {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
await service.getInstalled('/tmp/project-a');
|
||||
await service.getInstalled('/tmp/project-a');
|
||||
|
||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caches results independently per project path', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (normalizedPath === '/tmp/mock-home/.claude.json') {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
context7: { command: 'npx -y @upstash/context7-mcp' },
|
||||
},
|
||||
projects: {
|
||||
'/tmp/project-a': {
|
||||
mcpServers: {
|
||||
stripe: { url: 'https://mcp.stripe.com' },
|
||||
},
|
||||
},
|
||||
'/tmp/project-b': {
|
||||
mcpServers: {
|
||||
github: { command: 'uvx github-mcp' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-a/.mcp.json') {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-b/.mcp.json') {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
'repo-b-server': { command: 'uvx repo-b-mcp' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const projectAEntries = await service.getInstalled('/tmp/project-a');
|
||||
const projectBEntries = await service.getInstalled('/tmp/project-b');
|
||||
|
||||
expect(projectAEntries).toEqual([
|
||||
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
||||
{ name: 'stripe', scope: 'local', transport: 'http' },
|
||||
{ name: 'repo-a-server', scope: 'project', transport: 'http' },
|
||||
]);
|
||||
expect(projectBEntries).toEqual([
|
||||
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
||||
{ name: 'github', scope: 'local', transport: 'stdio' },
|
||||
{ name: 'repo-b-server', scope: 'project', transport: 'stdio' },
|
||||
]);
|
||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface StoreState {
|
||||
installCustomMcpServer: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
const lookupMock = vi.fn();
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
apiKeys: {
|
||||
lookup: (...args: unknown[]) => lookupMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
}>) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type,
|
||||
disabled,
|
||||
onClick,
|
||||
},
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('p', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/input', () => ({
|
||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
|
||||
React.createElement('input', props),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/label', () => ({
|
||||
Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/select', () => ({
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onValueChange,
|
||||
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
|
||||
React.createElement(
|
||||
'select',
|
||||
{
|
||||
'data-testid': 'scope-select',
|
||||
value,
|
||||
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
|
||||
},
|
||||
children
|
||||
),
|
||||
SelectTrigger: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
|
||||
React.createElement('option', { value, disabled }, children),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||
return {
|
||||
Plus: Icon,
|
||||
Server: Icon,
|
||||
Trash2: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
import { CustomMcpServerDialog } from '@renderer/components/extensions/mcp/CustomMcpServerDialog';
|
||||
|
||||
function setNativeValue(
|
||||
element: HTMLInputElement | HTMLSelectElement,
|
||||
value: string,
|
||||
eventName: 'input' | 'change'
|
||||
): void {
|
||||
const prototype = element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
||||
descriptor?.set?.call(element, value);
|
||||
element.dispatchEvent(new Event(eventName, { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('CustomMcpServerDialog project scope', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined);
|
||||
lookupMock.mockReset();
|
||||
lookupMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('disables non-user scopes without an active project', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(CustomMcpServerDialog, {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
projectPath: null,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement;
|
||||
const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement;
|
||||
expect(projectOption.disabled).toBe(true);
|
||||
expect(localOption.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes projectPath for project-scoped custom installs', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onClose = vi.fn();
|
||||
const projectPath = '/tmp/custom-mcp-project';
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(CustomMcpServerDialog, {
|
||||
open: true,
|
||||
onClose,
|
||||
projectPath,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const nameInput = host.querySelector('#custom-name') as HTMLInputElement;
|
||||
const packageInput = host.querySelector('#custom-npm') as HTMLInputElement;
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
|
||||
await act(async () => {
|
||||
setNativeValue(nameInput, 'custom-context7', 'input');
|
||||
setNativeValue(packageInput, '@upstash/context7-mcp', 'input');
|
||||
setNativeValue(scopeSelect, 'project', 'change');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const installButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Install'
|
||||
) as HTMLButtonElement;
|
||||
expect(installButton.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
installButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.installCustomMcpServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serverName: 'custom-context7',
|
||||
scope: 'project',
|
||||
projectPath,
|
||||
})
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes projectPath for local-scoped custom installs', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onClose = vi.fn();
|
||||
const projectPath = '/tmp/custom-mcp-project';
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(CustomMcpServerDialog, {
|
||||
open: true,
|
||||
onClose,
|
||||
projectPath,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const nameInput = host.querySelector('#custom-name') as HTMLInputElement;
|
||||
const packageInput = host.querySelector('#custom-npm') as HTMLInputElement;
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
|
||||
await act(async () => {
|
||||
setNativeValue(nameInput, 'local-context7', 'input');
|
||||
setNativeValue(packageInput, '@upstash/context7-mcp', 'input');
|
||||
setNativeValue(scopeSelect, 'local', 'change');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const installButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Install'
|
||||
) as HTMLButtonElement;
|
||||
expect(installButton.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
installButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.installCustomMcpServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serverName: 'local-context7',
|
||||
scope: 'local',
|
||||
projectPath,
|
||||
})
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
288
test/renderer/components/extensions/mcp/McpServerCard.test.ts
Normal file
288
test/renderer/components/extensions/mcp/McpServerCard.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getMcpOperationKey } from '@shared/utils/extensionNormalizers';
|
||||
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
|
||||
|
||||
interface StoreState {
|
||||
mcpInstallProgress: Record<string, string>;
|
||||
installMcpServer: ReturnType<typeof vi.fn>;
|
||||
uninstallMcpServer: ReturnType<typeof vi.fn>;
|
||||
installErrors: Record<string, string>;
|
||||
mcpGitHubStars: Record<string, number>;
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
openExternal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
}: React.PropsWithChildren<{
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type,
|
||||
onClick,
|
||||
},
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
|
||||
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||
InstallButton: ({
|
||||
state,
|
||||
errorMessage,
|
||||
}: {
|
||||
state?: string;
|
||||
errorMessage?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
'data-testid': 'install-button',
|
||||
'data-state': state,
|
||||
'data-error': errorMessage,
|
||||
},
|
||||
'Install'
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
|
||||
SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/utils/formatters', () => ({
|
||||
formatCompactNumber: (value: number) => String(value),
|
||||
formatRelativeTime: () => 'recently',
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||
return {
|
||||
Clock: Icon,
|
||||
Cloud: Icon,
|
||||
Globe: Icon,
|
||||
KeyRound: Icon,
|
||||
Lock: Icon,
|
||||
Monitor: Icon,
|
||||
Star: Icon,
|
||||
Tag: Icon,
|
||||
Wrench: Icon,
|
||||
Github: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
import { McpServerCard } from '@renderer/components/extensions/mcp/McpServerCard';
|
||||
|
||||
function makeServer(): McpCatalogItem {
|
||||
return {
|
||||
id: 'io.github.upstash/context7',
|
||||
name: 'Context7',
|
||||
description: 'Docs server',
|
||||
source: 'official',
|
||||
installSpec: {
|
||||
type: 'stdio',
|
||||
npmPackage: '@upstash/context7-mcp',
|
||||
},
|
||||
envVars: [],
|
||||
tools: [],
|
||||
requiresAuth: false,
|
||||
authHeaders: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('McpServerCard direct action safety', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.mcpInstallProgress = {};
|
||||
storeState.installMcpServer = vi.fn();
|
||||
storeState.uninstallMcpServer = vi.fn();
|
||||
storeState.installErrors = {};
|
||||
storeState.mcpGitHubStars = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('falls back to Manage for installed entries that cannot be safely uninstalled directly', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onClick = vi.fn();
|
||||
const installedEntry: InstalledMcpEntry = {
|
||||
name: 'context7-local',
|
||||
scope: 'local',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerCard, {
|
||||
server: makeServer(),
|
||||
isInstalled: true,
|
||||
installedEntry,
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
onClick,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="install-button"]')).toBeNull();
|
||||
const manageButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Manage'
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(manageButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
manageButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith('io.github.upstash/context7');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps direct actions for standard user-scope installs', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const installedEntry: InstalledMcpEntry = {
|
||||
name: 'context7',
|
||||
scope: 'user',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerCard, {
|
||||
server: makeServer(),
|
||||
isInstalled: true,
|
||||
installedEntry,
|
||||
installedEntries: [installedEntry],
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
onClick: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to Manage when the same server is installed in multiple scopes', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const installedEntries: InstalledMcpEntry[] = [
|
||||
{ name: 'context7', scope: 'user' },
|
||||
{ name: 'context7', scope: 'project' },
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerCard, {
|
||||
server: makeServer(),
|
||||
isInstalled: true,
|
||||
installedEntry: installedEntries[1],
|
||||
installedEntries,
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
onClick: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Installed in 2 scopes');
|
||||
expect(host.querySelector('[data-testid="install-button"]')).toBeNull();
|
||||
expect(host.textContent).toContain('Manage');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('reads direct-action state from the user-scope operation key', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const installedEntry: InstalledMcpEntry = {
|
||||
name: 'context7',
|
||||
scope: 'user',
|
||||
};
|
||||
|
||||
storeState.mcpInstallProgress = {
|
||||
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error',
|
||||
[getMcpOperationKey('io.github.upstash/context7', 'user')]: 'pending',
|
||||
};
|
||||
storeState.installErrors = {
|
||||
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed',
|
||||
[getMcpOperationKey('io.github.upstash/context7', 'user')]: 'User failed',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerCard, {
|
||||
server: makeServer(),
|
||||
isInstalled: true,
|
||||
installedEntry,
|
||||
installedEntries: [installedEntry],
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
onClick: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||
expect(installButton.dataset.state).toBe('pending');
|
||||
expect(installButton.dataset.error).toBe('User failed');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,592 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getMcpOperationKey } from '@shared/utils/extensionNormalizers';
|
||||
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
|
||||
|
||||
interface StoreState {
|
||||
mcpInstallProgress: Record<string, string>;
|
||||
installMcpServer: ReturnType<typeof vi.fn>;
|
||||
uninstallMcpServer: ReturnType<typeof vi.fn>;
|
||||
installErrors: Record<string, string>;
|
||||
mcpGitHubStars: Record<string, number>;
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
const lookupMock = vi.fn();
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
openExternal: vi.fn(),
|
||||
apiKeys: {
|
||||
lookup: (...args: unknown[]) => lookupMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
}>) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type,
|
||||
disabled,
|
||||
onClick,
|
||||
},
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('p', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/input', () => ({
|
||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
|
||||
React.createElement('input', props),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/label', () => ({
|
||||
Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/select', () => ({
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onValueChange,
|
||||
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
|
||||
React.createElement(
|
||||
'select',
|
||||
{
|
||||
'data-testid': 'scope-select',
|
||||
value,
|
||||
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
|
||||
},
|
||||
children
|
||||
),
|
||||
SelectTrigger: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
|
||||
React.createElement('option', { value, disabled }, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||
InstallButton: ({
|
||||
isInstalled,
|
||||
state,
|
||||
errorMessage,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
}: {
|
||||
isInstalled: boolean;
|
||||
state?: string;
|
||||
errorMessage?: string;
|
||||
onInstall: () => void;
|
||||
onUninstall: () => void;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
'data-testid': 'install-button',
|
||||
'data-state': state,
|
||||
'data-error': errorMessage,
|
||||
onClick: () => (isInstalled ? onUninstall() : onInstall()),
|
||||
},
|
||||
isInstalled ? 'Uninstall' : 'Install'
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
|
||||
SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||
return {
|
||||
ExternalLink: Icon,
|
||||
Lock: Icon,
|
||||
Plus: Icon,
|
||||
Star: Icon,
|
||||
Trash2: Icon,
|
||||
Wrench: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
import { McpServerDetailDialog } from '@renderer/components/extensions/mcp/McpServerDetailDialog';
|
||||
|
||||
function makeServer(): McpCatalogItem {
|
||||
return {
|
||||
id: 'io.github.upstash/context7',
|
||||
name: 'Context7',
|
||||
description: 'Docs server',
|
||||
source: 'official',
|
||||
installSpec: {
|
||||
type: 'stdio',
|
||||
npmPackage: '@upstash/context7-mcp',
|
||||
},
|
||||
envVars: [],
|
||||
tools: [],
|
||||
requiresAuth: false,
|
||||
authHeaders: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('McpServerDetailDialog installed entry handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.mcpInstallProgress = {};
|
||||
storeState.installMcpServer = vi.fn();
|
||||
storeState.uninstallMcpServer = vi.fn();
|
||||
storeState.installErrors = {};
|
||||
storeState.mcpGitHubStars = {};
|
||||
lookupMock.mockReset();
|
||||
lookupMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uninstalls using the real installed server name and scope', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const installedEntry: InstalledMcpEntry = {
|
||||
name: 'context7-local',
|
||||
scope: 'local',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerDetailDialog, {
|
||||
server: makeServer(),
|
||||
isInstalled: true,
|
||||
installedEntry,
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
projectPath: '/tmp/project',
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const serverNameInput = host.querySelector('#server-name') as HTMLInputElement;
|
||||
expect(serverNameInput).not.toBeNull();
|
||||
expect(serverNameInput.value).toBe('context7-local');
|
||||
expect(serverNameInput.disabled).toBe(true);
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
expect(scopeSelect.value).toBe('local');
|
||||
|
||||
const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
uninstallButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.uninstallMcpServer).toHaveBeenCalledWith(
|
||||
'io.github.upstash/context7',
|
||||
'context7-local',
|
||||
'local',
|
||||
'/tmp/project'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('looks up saved API keys only once per dialog open', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const server = makeServer();
|
||||
server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }];
|
||||
lookupMock.mockResolvedValue([
|
||||
{
|
||||
envVarName: 'CONTEXT7_API_KEY',
|
||||
value: 'secret',
|
||||
},
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerDetailDialog, {
|
||||
server,
|
||||
isInstalled: false,
|
||||
installedEntry: null,
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
projectPath: null,
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(lookupMock).toHaveBeenCalledTimes(1);
|
||||
expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY']);
|
||||
const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement;
|
||||
const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement;
|
||||
expect(projectOption.disabled).toBe(true);
|
||||
expect(localOption.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes project path for project-scoped installs and uninstalls', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const projectPath = '/tmp/project-context7';
|
||||
const installedEntry: InstalledMcpEntry = {
|
||||
name: 'context7-project',
|
||||
scope: 'project',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerDetailDialog, {
|
||||
server: makeServer(),
|
||||
isInstalled: true,
|
||||
installedEntry,
|
||||
installedEntries: [installedEntry],
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
projectPath,
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
expect(scopeSelect.value).toBe('project');
|
||||
|
||||
const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
uninstallButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.uninstallMcpServer).toHaveBeenCalledWith(
|
||||
'io.github.upstash/context7',
|
||||
'context7-project',
|
||||
'project',
|
||||
projectPath
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerDetailDialog, {
|
||||
server: makeServer(),
|
||||
isInstalled: false,
|
||||
installedEntry: null,
|
||||
installedEntries: [],
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
projectPath,
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const installScopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
installScopeSelect.value = 'project';
|
||||
installScopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
installButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.installMcpServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
registryId: 'io.github.upstash/context7',
|
||||
scope: 'project',
|
||||
projectPath,
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes project path for local-scoped installs and uninstalls', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const projectPath = '/tmp/local-context7';
|
||||
const installedEntry: InstalledMcpEntry = {
|
||||
name: 'context7-local',
|
||||
scope: 'local',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerDetailDialog, {
|
||||
server: makeServer(),
|
||||
isInstalled: true,
|
||||
installedEntry,
|
||||
installedEntries: [installedEntry],
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
projectPath,
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
uninstallButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.uninstallMcpServer).toHaveBeenCalledWith(
|
||||
'io.github.upstash/context7',
|
||||
'context7-local',
|
||||
'local',
|
||||
projectPath
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerDetailDialog, {
|
||||
server: makeServer(),
|
||||
isInstalled: false,
|
||||
installedEntry: null,
|
||||
installedEntries: [],
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
projectPath,
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
scopeSelect.value = 'local';
|
||||
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
installButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.installMcpServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
registryId: 'io.github.upstash/context7',
|
||||
scope: 'local',
|
||||
projectPath,
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses selected scope instead of aggregated installed state', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const installedEntry: InstalledMcpEntry = {
|
||||
name: 'context7',
|
||||
scope: 'user',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerDetailDialog, {
|
||||
server: makeServer(),
|
||||
isInstalled: true,
|
||||
installedEntry,
|
||||
installedEntries: [installedEntry],
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
projectPath: '/tmp/project',
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
scopeSelect.value = 'project';
|
||||
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const actionButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||
expect(actionButton.textContent).toBe('Install');
|
||||
|
||||
await act(async () => {
|
||||
actionButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.installMcpServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
registryId: 'io.github.upstash/context7',
|
||||
scope: 'project',
|
||||
projectPath: '/tmp/project',
|
||||
})
|
||||
);
|
||||
expect(storeState.uninstallMcpServer).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to the highest-precedence installed scope', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const installedEntries: InstalledMcpEntry[] = [
|
||||
{ name: 'context7', scope: 'user' },
|
||||
{ name: 'context7-shared', scope: 'project' },
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerDetailDialog, {
|
||||
server: makeServer(),
|
||||
isInstalled: true,
|
||||
installedEntry: installedEntries[0],
|
||||
installedEntries,
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
projectPath: '/tmp/project',
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
const serverNameInput = host.querySelector('#server-name') as HTMLInputElement;
|
||||
|
||||
expect(scopeSelect.value).toBe('project');
|
||||
expect(serverNameInput.value).toBe('context7-shared');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('reads install state from the selected scope operation key', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
storeState.mcpInstallProgress = {
|
||||
[getMcpOperationKey('io.github.upstash/context7', 'user')]: 'success',
|
||||
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error',
|
||||
};
|
||||
storeState.installErrors = {
|
||||
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerDetailDialog, {
|
||||
server: makeServer(),
|
||||
isInstalled: false,
|
||||
installedEntry: null,
|
||||
installedEntries: [],
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
projectPath: '/tmp/project',
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||
expect(installButton.dataset.state).toBe('success');
|
||||
expect(installButton.dataset.error ?? '').toBe('');
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
scopeSelect.value = 'project';
|
||||
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(installButton.dataset.state).toBe('error');
|
||||
expect(installButton.dataset.error).toBe('Project failed');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
214
test/renderer/components/extensions/mcp/McpServersPanel.test.ts
Normal file
214
test/renderer/components/extensions/mcp/McpServersPanel.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface StoreState {
|
||||
mcpBrowseCatalog: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source: 'official' | 'glama';
|
||||
installSpec: null;
|
||||
envVars: [];
|
||||
tools: [];
|
||||
requiresAuth: boolean;
|
||||
}>;
|
||||
mcpBrowseNextCursor?: string;
|
||||
mcpBrowseLoading: boolean;
|
||||
mcpBrowseError: string | null;
|
||||
mcpBrowse: ReturnType<typeof vi.fn>;
|
||||
mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>;
|
||||
fetchMcpGitHubStars: ReturnType<typeof vi.fn>;
|
||||
mcpDiagnostics: Record<string, never>;
|
||||
mcpDiagnosticsLoading: boolean;
|
||||
mcpDiagnosticsError: string | null;
|
||||
mcpDiagnosticsLastCheckedAt: number | null;
|
||||
runMcpDiagnostics: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('zustand/react/shallow', () => ({
|
||||
useShallow: (selector: unknown) => selector,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
}>) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type,
|
||||
disabled,
|
||||
onClick,
|
||||
},
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/select', () => ({
|
||||
Select: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
SelectTrigger: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('button', { type: 'button' }, children),
|
||||
SelectValue: () => React.createElement('span', null, 'select-value'),
|
||||
SelectContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
SelectItem: ({ children }: React.PropsWithChildren<{ value: string }>) =>
|
||||
React.createElement('button', { type: 'button' }, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/common/SearchInput', () => ({
|
||||
SearchInput: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) =>
|
||||
React.createElement('input', {
|
||||
value,
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.value),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/mcp/McpServerCard', () => ({
|
||||
McpServerCard: ({ server }: { server: { id: string; name: string } }) =>
|
||||
React.createElement('div', { 'data-testid': 'mcp-card', 'data-server-id': server.id }, server.name),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/mcp/McpServerDetailDialog', () => ({
|
||||
McpServerDetailDialog: ({ open }: { open: boolean }) =>
|
||||
open ? React.createElement('div', { 'data-testid': 'mcp-detail' }) : null,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/utils/formatters', () => ({
|
||||
formatRelativeTime: () => 'just now',
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||
return {
|
||||
AlertTriangle: Icon,
|
||||
RefreshCw: Icon,
|
||||
Search: Icon,
|
||||
Server: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
import { McpServersPanel } from '@renderer/components/extensions/mcp/McpServersPanel';
|
||||
|
||||
describe('McpServersPanel initial browse loading', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.mcpBrowseCatalog = [];
|
||||
storeState.mcpBrowseNextCursor = undefined;
|
||||
storeState.mcpBrowseLoading = false;
|
||||
storeState.mcpBrowseError = null;
|
||||
storeState.mcpBrowse = vi.fn();
|
||||
storeState.mcpInstalledServers = [];
|
||||
storeState.fetchMcpGitHubStars = vi.fn();
|
||||
storeState.mcpDiagnostics = {};
|
||||
storeState.mcpDiagnosticsLoading = false;
|
||||
storeState.mcpDiagnosticsError = null;
|
||||
storeState.mcpDiagnosticsLastCheckedAt = null;
|
||||
storeState.runMcpDiagnostics = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads the catalog once on first mount when browse state is empty', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServersPanel, {
|
||||
projectPath: null,
|
||||
mcpSearchQuery: '',
|
||||
mcpSearch: vi.fn(),
|
||||
mcpSearchResults: [],
|
||||
mcpSearchLoading: false,
|
||||
mcpSearchWarnings: [],
|
||||
selectedMcpServerId: null,
|
||||
setSelectedMcpServerId: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1);
|
||||
expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not auto-retry browse after an error with an empty catalog', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServersPanel, {
|
||||
projectPath: null,
|
||||
mcpSearchQuery: '',
|
||||
mcpSearch: vi.fn(),
|
||||
mcpSearchResults: [],
|
||||
mcpSearchLoading: false,
|
||||
mcpSearchWarnings: [],
|
||||
selectedMcpServerId: null,
|
||||
setSelectedMcpServerId: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1);
|
||||
|
||||
storeState.mcpBrowseError = 'Registry unavailable';
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServersPanel, {
|
||||
projectPath: null,
|
||||
mcpSearchQuery: '',
|
||||
mcpSearch: vi.fn(),
|
||||
mcpSearchResults: [],
|
||||
mcpSearchLoading: false,
|
||||
mcpSearchWarnings: [],
|
||||
selectedMcpServerId: null,
|
||||
setSelectedMcpServerId: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EnrichedPlugin } from '@shared/types/extensions';
|
||||
|
||||
interface StoreState {
|
||||
fetchPluginReadme: ReturnType<typeof vi.fn>;
|
||||
pluginReadmes: Record<string, string | null>;
|
||||
pluginReadmeLoading: Record<string, boolean>;
|
||||
installPlugin: ReturnType<typeof vi.fn>;
|
||||
uninstallPlugin: ReturnType<typeof vi.fn>;
|
||||
pluginCatalogProjectPath: string | null;
|
||||
pluginInstallProgress: Record<string, string>;
|
||||
installErrors: Record<string, string>;
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('zustand/react/shallow', () => ({
|
||||
useShallow: (selector: unknown) => selector,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
openExternal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
|
||||
MarkdownViewer: ({ content }: { content: string }) =>
|
||||
React.createElement('div', { 'data-testid': 'markdown' }, content),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
}: React.PropsWithChildren<{
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type,
|
||||
onClick,
|
||||
},
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
|
||||
open ? React.createElement('div', { 'data-testid': 'dialog' }, children) : null,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('div', { 'data-testid': 'dialog-content' }, children),
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('p', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/label', () => ({
|
||||
Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/select', () => ({
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onValueChange,
|
||||
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
|
||||
React.createElement(
|
||||
'select',
|
||||
{
|
||||
'data-testid': 'scope-select',
|
||||
value,
|
||||
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
|
||||
},
|
||||
children
|
||||
),
|
||||
SelectTrigger: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
|
||||
React.createElement(
|
||||
'option',
|
||||
{
|
||||
value,
|
||||
disabled,
|
||||
},
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||
InstallButton: ({
|
||||
isInstalled,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
}: {
|
||||
isInstalled: boolean;
|
||||
onInstall: () => void;
|
||||
onUninstall: () => void;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
'data-testid': 'install-button',
|
||||
onClick: () => (isInstalled ? onUninstall() : onInstall()),
|
||||
},
|
||||
isInstalled ? 'Uninstall' : 'Install'
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/common/InstallCountBadge', () => ({
|
||||
InstallCountBadge: ({ count }: { count: number }) =>
|
||||
React.createElement('span', { 'data-testid': 'install-count' }, String(count)),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
|
||||
SourceBadge: ({ source }: { source: string }) =>
|
||||
React.createElement('span', { 'data-testid': 'source-badge' }, source),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||
return {
|
||||
ExternalLink: Icon,
|
||||
Loader2: Icon,
|
||||
Mail: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog';
|
||||
|
||||
const makePlugin = (): EnrichedPlugin => ({
|
||||
pluginId: 'context7@claude-plugins-official',
|
||||
marketplaceId: 'context7@claude-plugins-official',
|
||||
qualifiedName: 'context7@claude-plugins-official',
|
||||
name: 'Context7',
|
||||
source: 'official',
|
||||
description: 'Fresh docs in Claude',
|
||||
category: 'docs',
|
||||
author: { name: 'Anthropic', email: 'help@example.com' },
|
||||
version: '1.0.0',
|
||||
homepage: 'https://example.com/context7',
|
||||
tags: [],
|
||||
hasLspServers: false,
|
||||
hasMcpServers: true,
|
||||
hasAgents: false,
|
||||
hasCommands: false,
|
||||
hasHooks: false,
|
||||
isExternal: true,
|
||||
installCount: 42,
|
||||
isInstalled: false,
|
||||
installations: [],
|
||||
});
|
||||
|
||||
describe('PluginDetailDialog project context', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.fetchPluginReadme = vi.fn();
|
||||
storeState.pluginReadmes = {};
|
||||
storeState.pluginReadmeLoading = {};
|
||||
storeState.installPlugin = vi.fn();
|
||||
storeState.uninstallPlugin = vi.fn();
|
||||
storeState.pluginCatalogProjectPath = '/tmp/global-project';
|
||||
storeState.pluginInstallProgress = {};
|
||||
storeState.installErrors = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses the current tab project path for project-scope installs', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const plugin = makePlugin();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(PluginDetailDialog, {
|
||||
plugin,
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
projectPath: '/tmp/tab-project',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
expect(scopeSelect).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
scopeSelect.value = 'project';
|
||||
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||
expect(installButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
installButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.installPlugin).toHaveBeenCalledWith({
|
||||
pluginId: plugin.pluginId,
|
||||
scope: 'project',
|
||||
projectPath: '/tmp/tab-project',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables project and local scopes when the current tab has no project', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(PluginDetailDialog, {
|
||||
plugin: makePlugin(),
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
projectPath: null,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
expect(scopeSelect).not.toBeNull();
|
||||
expect(scopeSelect.querySelector('option[value="project"]')?.disabled).toBe(true);
|
||||
expect(scopeSelect.querySelector('option[value="local"]')?.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SkillDetail } from '@shared/types/extensions';
|
||||
|
||||
interface StoreState {
|
||||
fetchSkillDetail: ReturnType<typeof vi.fn>;
|
||||
deleteSkill: ReturnType<typeof vi.fn>;
|
||||
skillsDetailsById: Record<string, SkillDetail | null | undefined>;
|
||||
skillsDetailLoadingById: Record<string, boolean>;
|
||||
skillsDetailErrorById: Record<string, string | null>;
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
const openPathMock = vi.fn();
|
||||
const showInFolderMock = vi.fn();
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('zustand/react/shallow', () => ({
|
||||
useShallow: <T,>(selector: T) => selector,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
openPath: (...args: unknown[]) => openPathMock(...args),
|
||||
showInFolder: (...args: unknown[]) => showInFolderMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/chat/viewers/CodeBlockViewer', () => ({
|
||||
CodeBlockViewer: () => React.createElement('div', null, 'Code'),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
|
||||
MarkdownViewer: () => React.createElement('div', null, 'Markdown'),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/alert-dialog', () => ({
|
||||
AlertDialog: ({
|
||||
open,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
open: boolean;
|
||||
onOpenChange?: (next: boolean) => void;
|
||||
}>) => (open ? React.createElement('div', null, children) : null),
|
||||
AlertDialogAction: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{ onClick?: () => void; disabled?: boolean }>) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
disabled,
|
||||
onClick,
|
||||
},
|
||||
children
|
||||
),
|
||||
AlertDialogCancel: ({ children }: React.PropsWithChildren<{ disabled?: boolean }>) =>
|
||||
React.createElement('button', { type: 'button' }, children),
|
||||
AlertDialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
AlertDialogDescription: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('p', null, children),
|
||||
AlertDialogFooter: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
AlertDialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
AlertDialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h3', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
}>) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type,
|
||||
disabled,
|
||||
onClick,
|
||||
},
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('p', null, children),
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||
return {
|
||||
AlertTriangle: Icon,
|
||||
ExternalLink: Icon,
|
||||
FolderOpen: Icon,
|
||||
Pencil: Icon,
|
||||
Trash2: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
import { SkillDetailDialog } from '@renderer/components/extensions/skills/SkillDetailDialog';
|
||||
|
||||
function makeDetail(overrides: Partial<SkillDetail['item']>): SkillDetail {
|
||||
return {
|
||||
item: {
|
||||
id: '/tmp/project-a/.claude/skills/review-helper',
|
||||
sourceType: 'filesystem',
|
||||
name: 'Review Helper',
|
||||
description: 'Helps with code review',
|
||||
folderName: 'review-helper',
|
||||
scope: 'project',
|
||||
rootKind: 'claude',
|
||||
projectRoot: '/tmp/project-a',
|
||||
discoveryRoot: '/tmp/project-a/.claude/skills',
|
||||
skillDir: '/tmp/project-a/.claude/skills/review-helper',
|
||||
skillFile: '/tmp/project-a/.claude/skills/review-helper/SKILL.md',
|
||||
metadata: {},
|
||||
invocationMode: 'auto',
|
||||
flags: {
|
||||
hasScripts: false,
|
||||
hasReferences: false,
|
||||
hasAssets: false,
|
||||
},
|
||||
isValid: true,
|
||||
issues: [],
|
||||
modifiedAt: 1,
|
||||
...overrides,
|
||||
},
|
||||
body: 'body',
|
||||
rawContent: '# Review Helper',
|
||||
rawFrontmatter: null,
|
||||
referencesFiles: [],
|
||||
scriptFiles: [],
|
||||
assetFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('SkillDetailDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.fetchSkillDetail = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.deleteSkill = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.skillsDetailsById = {};
|
||||
storeState.skillsDetailLoadingById = {};
|
||||
storeState.skillsDetailErrorById = {};
|
||||
openPathMock.mockReset();
|
||||
showInFolderMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses the skill project root for project-scoped open and delete actions', async () => {
|
||||
const detail = makeDetail({});
|
||||
storeState.skillsDetailsById[detail.item.id] = detail;
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onDeleted = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillDetailDialog, {
|
||||
skillId: detail.item.id,
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
projectPath: '/tmp/project-b',
|
||||
onEdit: vi.fn(),
|
||||
onDeleted,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const openButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Open SKILL.md')
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
openButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openPathMock).toHaveBeenCalledWith(detail.item.skillFile, '/tmp/project-a');
|
||||
|
||||
const deleteButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent === 'Delete'
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
deleteButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const confirmDeleteButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent === 'Delete Skill'
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
confirmDeleteButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.deleteSkill).toHaveBeenCalledWith({
|
||||
skillId: detail.item.id,
|
||||
projectPath: '/tmp/project-a',
|
||||
});
|
||||
expect(onDeleted).toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not forward the current project path for personal skills', async () => {
|
||||
const detail = makeDetail({
|
||||
id: '/Users/me/.claude/skills/review-helper',
|
||||
scope: 'user',
|
||||
projectRoot: null,
|
||||
discoveryRoot: '/Users/me/.claude/skills',
|
||||
skillDir: '/Users/me/.claude/skills/review-helper',
|
||||
skillFile: '/Users/me/.claude/skills/review-helper/SKILL.md',
|
||||
});
|
||||
storeState.skillsDetailsById[detail.item.id] = detail;
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillDetailDialog, {
|
||||
skillId: detail.item.id,
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
projectPath: '/tmp/project-b',
|
||||
onEdit: vi.fn(),
|
||||
onDeleted: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const openButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Open SKILL.md')
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
openButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openPathMock).toHaveBeenCalledWith(detail.item.skillFile, undefined);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SkillDetail } from '@shared/types/extensions';
|
||||
|
||||
interface StoreState {
|
||||
previewSkillUpsert: ReturnType<typeof vi.fn>;
|
||||
applySkillUpsert: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/hooks/useMarkdownScrollSync', () => ({
|
||||
useMarkdownScrollSync: () => ({
|
||||
handleCodeScroll: vi.fn(),
|
||||
handlePreviewScroll: vi.fn(),
|
||||
previewScrollRef: { current: null },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/editor/MarkdownPreviewPane', () => ({
|
||||
MarkdownPreviewPane: () => React.createElement('div', null, 'Preview'),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
}>) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type,
|
||||
disabled,
|
||||
onClick,
|
||||
},
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/checkbox', () => ({
|
||||
Checkbox: ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
className?: string;
|
||||
}) =>
|
||||
React.createElement('input', {
|
||||
type: 'checkbox',
|
||||
checked,
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onCheckedChange?.(event.target.checked),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('p', null, children),
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/input', () => ({
|
||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
|
||||
React.createElement('input', props),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/label', () => ({
|
||||
Label: ({ children, htmlFor }: React.PropsWithChildren<{ htmlFor?: string }>) =>
|
||||
React.createElement('label', { htmlFor }, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/select', () => ({
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}>) =>
|
||||
React.createElement(
|
||||
'select',
|
||||
{
|
||||
value,
|
||||
disabled,
|
||||
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
|
||||
},
|
||||
children
|
||||
),
|
||||
SelectTrigger: () => null,
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
|
||||
React.createElement('option', { value, disabled }, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/textarea', () => ({
|
||||
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) =>
|
||||
React.createElement('textarea', props),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/skills/SkillCodeEditor', () => ({
|
||||
SkillCodeEditor: () => React.createElement('div', null, 'Editor'),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/skills/SkillReviewDialog', () => ({
|
||||
SkillReviewDialog: ({ open }: { open: boolean }) =>
|
||||
open ? React.createElement('div', { 'data-testid': 'skill-review-dialog' }, 'Review') : null,
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||
return {
|
||||
FileSearch: Icon,
|
||||
RotateCcw: Icon,
|
||||
X: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
import { SkillEditorDialog } from '@renderer/components/extensions/skills/SkillEditorDialog';
|
||||
|
||||
function makeDetail(rawContent: string): SkillDetail {
|
||||
return {
|
||||
item: {
|
||||
id: '/tmp/project/.claude/skills/custom-skill',
|
||||
sourceType: 'filesystem',
|
||||
name: 'Custom Skill',
|
||||
description: 'Custom markdown skill',
|
||||
folderName: 'custom-skill',
|
||||
scope: 'project',
|
||||
rootKind: 'claude',
|
||||
projectRoot: '/tmp/project',
|
||||
discoveryRoot: '/tmp/project/.claude/skills',
|
||||
skillDir: '/tmp/project/.claude/skills/custom-skill',
|
||||
skillFile: '/tmp/project/.claude/skills/custom-skill/SKILL.md',
|
||||
metadata: {},
|
||||
invocationMode: 'auto',
|
||||
flags: {
|
||||
hasScripts: false,
|
||||
hasReferences: false,
|
||||
hasAssets: false,
|
||||
},
|
||||
isValid: true,
|
||||
issues: [],
|
||||
modifiedAt: 1,
|
||||
},
|
||||
body: rawContent,
|
||||
rawContent,
|
||||
rawFrontmatter: null,
|
||||
referencesFiles: [],
|
||||
scriptFiles: [],
|
||||
assetFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('SkillEditorDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.previewSkillUpsert = vi.fn();
|
||||
storeState.applySkillUpsert = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('unlocks structured editing after resetting a custom markdown skill', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const detail = makeDetail(`---
|
||||
name: Custom Skill
|
||||
description: Custom markdown skill
|
||||
---
|
||||
|
||||
# Custom Skill
|
||||
|
||||
This file uses a freeform layout without generated sections.
|
||||
`);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillEditorDialog, {
|
||||
open: true,
|
||||
mode: 'edit',
|
||||
projectPath: '/tmp/project',
|
||||
projectLabel: 'Project',
|
||||
detail,
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('#skill-when-to-use')).toBeNull();
|
||||
|
||||
const resetButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Reset From Structured Fields')
|
||||
) as HTMLButtonElement;
|
||||
expect(resetButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
resetButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const whenToUseField = host.querySelector('#skill-when-to-use') as HTMLTextAreaElement;
|
||||
expect(whenToUseField).not.toBeNull();
|
||||
expect(whenToUseField.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears review state when the editor closes externally', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
storeState.previewSkillUpsert.mockResolvedValue({
|
||||
planId: 'plan-1',
|
||||
targetSkillDir: '/tmp/project/.claude/skills/new-skill',
|
||||
changes: [
|
||||
{
|
||||
relativePath: 'SKILL.md',
|
||||
absolutePath: '/tmp/project/.claude/skills/new-skill/SKILL.md',
|
||||
action: 'create',
|
||||
oldContent: null,
|
||||
newContent: '# Skill',
|
||||
isBinary: false,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
summary: { created: 1, updated: 0, deleted: 0, binary: 0 },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillEditorDialog, {
|
||||
open: true,
|
||||
mode: 'create',
|
||||
projectPath: '/tmp/project',
|
||||
projectLabel: 'Project',
|
||||
detail: null,
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Review And Create')
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
reviewButton.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="skill-review-dialog"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillEditorDialog, {
|
||||
open: false,
|
||||
mode: 'create',
|
||||
projectPath: '/tmp/project',
|
||||
projectLabel: 'Project',
|
||||
detail: null,
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="skill-review-dialog"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks review locally when the folder name is invalid', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillEditorDialog, {
|
||||
open: true,
|
||||
mode: 'create',
|
||||
projectPath: '/tmp/project',
|
||||
projectLabel: 'Project',
|
||||
detail: null,
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const folderInput = host.querySelector('#skill-folder') as HTMLInputElement;
|
||||
await act(async () => {
|
||||
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
setValue?.call(folderInput, 'bad/name');
|
||||
folderInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Review And Create')
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
reviewButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.previewSkillUpsert).not.toHaveBeenCalled();
|
||||
expect(host.textContent).toContain(
|
||||
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface StoreState {
|
||||
previewSkillImport: ReturnType<typeof vi.fn>;
|
||||
applySkillImport: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
const selectFoldersMock = vi.fn();
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
config: {
|
||||
selectFolders: (...args: unknown[]) => selectFoldersMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
}>) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type,
|
||||
disabled,
|
||||
onClick,
|
||||
},
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('p', null, children),
|
||||
DialogFooter: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/input', () => ({
|
||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
|
||||
React.createElement('input', props),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/label', () => ({
|
||||
Label: ({ children, htmlFor }: React.PropsWithChildren<{ htmlFor?: string }>) =>
|
||||
React.createElement('label', { htmlFor }, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/select', () => ({
|
||||
Select: ({
|
||||
children,
|
||||
value,
|
||||
onValueChange,
|
||||
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
|
||||
React.createElement(
|
||||
'select',
|
||||
{
|
||||
'data-testid': 'select',
|
||||
value,
|
||||
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
|
||||
},
|
||||
children
|
||||
),
|
||||
SelectTrigger: () => null,
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
disabled,
|
||||
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
|
||||
React.createElement('option', { value, disabled }, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/skills/SkillReviewDialog', () => ({
|
||||
SkillReviewDialog: ({ open }: { open: boolean }) =>
|
||||
open ? React.createElement('div', { 'data-testid': 'skill-review-dialog' }, 'Review') : null,
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||
return {
|
||||
FileSearch: Icon,
|
||||
FolderOpen: Icon,
|
||||
X: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
import { SkillImportDialog } from '@renderer/components/extensions/skills/SkillImportDialog';
|
||||
|
||||
describe('SkillImportDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.previewSkillImport = vi.fn();
|
||||
storeState.applySkillImport = vi.fn();
|
||||
selectFoldersMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps destination folder empty until a source folder is chosen', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillImportDialog, {
|
||||
open: true,
|
||||
projectPath: null,
|
||||
projectLabel: null,
|
||||
onClose: vi.fn(),
|
||||
onImported: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
|
||||
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
|
||||
|
||||
expect(sourceInput.value).toBe('');
|
||||
expect(folderInput.value).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps destination folder name synced with the chosen source until edited manually', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
selectFoldersMock
|
||||
.mockResolvedValueOnce(['/tmp/first-skill'])
|
||||
.mockResolvedValueOnce(['/tmp/second-skill'])
|
||||
.mockResolvedValueOnce(['/tmp/third-skill']);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillImportDialog, {
|
||||
open: true,
|
||||
projectPath: null,
|
||||
projectLabel: null,
|
||||
onClose: vi.fn(),
|
||||
onImported: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const browseButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.includes('Browse')
|
||||
) as HTMLButtonElement;
|
||||
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
|
||||
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
browseButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(sourceInput.value).toBe('/tmp/first-skill');
|
||||
expect(folderInput.value).toBe('first-skill');
|
||||
|
||||
await act(async () => {
|
||||
browseButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(sourceInput.value).toBe('/tmp/second-skill');
|
||||
expect(folderInput.value).toBe('second-skill');
|
||||
|
||||
await act(async () => {
|
||||
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
setValue?.call(folderInput, 'custom-name');
|
||||
folderInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(folderInput.value).toBe('custom-name');
|
||||
|
||||
await act(async () => {
|
||||
browseButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(sourceInput.value).toBe('/tmp/third-skill');
|
||||
expect(folderInput.value).toBe('custom-name');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes the suggested destination folder when the source folder name is not CLI-safe', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
selectFoldersMock.mockResolvedValueOnce(['/tmp/My Skill Folder']);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillImportDialog, {
|
||||
open: true,
|
||||
projectPath: null,
|
||||
projectLabel: null,
|
||||
onClose: vi.fn(),
|
||||
onImported: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const browseButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.includes('Browse')
|
||||
) as HTMLButtonElement;
|
||||
|
||||
await act(async () => {
|
||||
browseButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
|
||||
expect(folderInput.value).toBe('my-skill-folder');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to user scope when the project context disappears mid-dialog', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillImportDialog, {
|
||||
open: true,
|
||||
projectPath: '/tmp/project-a',
|
||||
projectLabel: 'Project A',
|
||||
onClose: vi.fn(),
|
||||
onImported: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const scopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement;
|
||||
expect(scopeSelect.value).toBe('project');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillImportDialog, {
|
||||
open: true,
|
||||
projectPath: null,
|
||||
projectLabel: null,
|
||||
onClose: vi.fn(),
|
||||
onImported: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const updatedScopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement;
|
||||
expect(updatedScopeSelect.value).toBe('user');
|
||||
const projectOption = Array.from(updatedScopeSelect.options).find(
|
||||
(option) => option.value === 'project'
|
||||
) as HTMLOptionElement;
|
||||
expect(projectOption.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears review state when the import dialog closes externally', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
storeState.previewSkillImport.mockResolvedValue({
|
||||
planId: 'plan-1',
|
||||
targetSkillDir: '/tmp/imported-skill',
|
||||
changes: [
|
||||
{
|
||||
relativePath: 'SKILL.md',
|
||||
absolutePath: '/tmp/imported-skill/SKILL.md',
|
||||
action: 'create',
|
||||
oldContent: null,
|
||||
newContent: '# Skill',
|
||||
isBinary: false,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
summary: { created: 1, updated: 0, deleted: 0, binary: 0 },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillImportDialog, {
|
||||
open: true,
|
||||
projectPath: null,
|
||||
projectLabel: null,
|
||||
onClose: vi.fn(),
|
||||
onImported: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
|
||||
await act(async () => {
|
||||
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
setValue?.call(sourceInput, '/tmp/source-skill');
|
||||
sourceInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Review And Import')
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
reviewButton.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="skill-review-dialog"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillImportDialog, {
|
||||
open: false,
|
||||
projectPath: null,
|
||||
projectLabel: null,
|
||||
onClose: vi.fn(),
|
||||
onImported: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="skill-review-dialog"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks import review locally when the folder name is invalid', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillImportDialog, {
|
||||
open: true,
|
||||
projectPath: null,
|
||||
projectLabel: null,
|
||||
onClose: vi.fn(),
|
||||
onImported: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
|
||||
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
|
||||
await act(async () => {
|
||||
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
setValue?.call(sourceInput, '/tmp/source-skill');
|
||||
sourceInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
setValue?.call(folderInput, 'bad/name');
|
||||
folderInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Review And Import')
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
reviewButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.previewSkillImport).not.toHaveBeenCalled();
|
||||
expect(host.textContent).toContain(
|
||||
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps review disabled for whitespace-only source folders', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillImportDialog, {
|
||||
open: true,
|
||||
projectPath: null,
|
||||
projectLabel: null,
|
||||
onClose: vi.fn(),
|
||||
onImported: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
|
||||
await act(async () => {
|
||||
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
setValue?.call(sourceInput, ' ');
|
||||
sourceInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Review And Import')
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(reviewButton.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
reviewButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.previewSkillImport).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
235
test/renderer/components/extensions/skills/SkillsPanel.test.ts
Normal file
235
test/renderer/components/extensions/skills/SkillsPanel.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||
|
||||
interface StoreState {
|
||||
fetchSkillsCatalog: ReturnType<typeof vi.fn>;
|
||||
fetchSkillDetail: ReturnType<typeof vi.fn>;
|
||||
skillsCatalogLoadingByProjectPath: Record<string, boolean>;
|
||||
skillsCatalogErrorByProjectPath: Record<string, string | null>;
|
||||
skillsDetailsById: Record<string, unknown>;
|
||||
skillsUserCatalog: SkillCatalogItem[];
|
||||
skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>;
|
||||
}
|
||||
|
||||
const storeState = {} as StoreState;
|
||||
const startWatchingMock = vi.fn();
|
||||
const stopWatchingMock = vi.fn();
|
||||
const onChangedMock = vi.fn();
|
||||
let skillsChangedHandler: ((event: {
|
||||
scope: 'user' | 'project';
|
||||
projectPath: string | null;
|
||||
path: string;
|
||||
type: 'create' | 'change' | 'delete';
|
||||
}) => void) | null = null;
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('zustand/react/shallow', () => ({
|
||||
useShallow: <T,>(selector: T) => selector,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
skills: {
|
||||
startWatching: (...args: unknown[]) => startWatchingMock(...args),
|
||||
stopWatching: (...args: unknown[]) => stopWatchingMock(...args),
|
||||
onChanged: (...args: unknown[]) => onChangedMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
}: React.PropsWithChildren<{
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
variant?: string;
|
||||
size?: string;
|
||||
className?: string;
|
||||
}>) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type,
|
||||
onClick,
|
||||
},
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/popover', () => ({
|
||||
Popover: ({ children }: React.PropsWithChildren<{ open?: boolean; onOpenChange?: (open: boolean) => void }>) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
PopoverTrigger: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
PopoverContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('div', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
|
||||
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/common/SearchInput', () => ({
|
||||
SearchInput: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) =>
|
||||
React.createElement('input', {
|
||||
value,
|
||||
placeholder,
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.value),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/skills/SkillDetailDialog', () => ({
|
||||
SkillDetailDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/skills/SkillEditorDialog', () => ({
|
||||
SkillEditorDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/skills/SkillImportDialog', () => ({
|
||||
SkillImportDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||
return {
|
||||
AlertTriangle: Icon,
|
||||
ArrowUpAZ: Icon,
|
||||
ArrowUpDown: Icon,
|
||||
BookOpen: Icon,
|
||||
Check: Icon,
|
||||
CheckCircle2: Icon,
|
||||
Clock3: Icon,
|
||||
Download: Icon,
|
||||
Plus: Icon,
|
||||
Search: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
import { SkillsPanel } from '@renderer/components/extensions/skills/SkillsPanel';
|
||||
|
||||
function makeUserSkill(): SkillCatalogItem {
|
||||
return {
|
||||
id: '/Users/me/.claude/skills/review-helper',
|
||||
sourceType: 'filesystem',
|
||||
name: 'Review Helper',
|
||||
description: 'Helps with code review',
|
||||
folderName: 'review-helper',
|
||||
scope: 'user',
|
||||
rootKind: 'claude',
|
||||
projectRoot: null,
|
||||
discoveryRoot: '/Users/me/.claude/skills',
|
||||
skillDir: '/Users/me/.claude/skills/review-helper',
|
||||
skillFile: '/Users/me/.claude/skills/review-helper/SKILL.md',
|
||||
metadata: {},
|
||||
invocationMode: 'auto',
|
||||
flags: {
|
||||
hasScripts: false,
|
||||
hasReferences: false,
|
||||
hasAssets: false,
|
||||
},
|
||||
isValid: true,
|
||||
issues: [],
|
||||
modifiedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SkillsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.fetchSkillsCatalog = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.fetchSkillDetail = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.skillsCatalogLoadingByProjectPath = {};
|
||||
storeState.skillsCatalogErrorByProjectPath = {};
|
||||
storeState.skillsDetailsById = {};
|
||||
storeState.skillsUserCatalog = [makeUserSkill()];
|
||||
storeState.skillsProjectCatalogByProjectPath = {
|
||||
'/tmp/project-a': [],
|
||||
};
|
||||
startWatchingMock.mockReset();
|
||||
stopWatchingMock.mockReset();
|
||||
onChangedMock.mockReset();
|
||||
skillsChangedHandler = null;
|
||||
startWatchingMock.mockResolvedValue('watch-1');
|
||||
onChangedMock.mockImplementation((handler: typeof skillsChangedHandler) => {
|
||||
skillsChangedHandler = handler;
|
||||
return () => {
|
||||
skillsChangedHandler = null;
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('refetches personal skill details without forcing the current project path', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const skill = storeState.skillsUserCatalog[0]!;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SkillsPanel, {
|
||||
projectPath: '/tmp/project-a',
|
||||
projectLabel: 'Project A',
|
||||
skillsSearchQuery: '',
|
||||
setSkillsSearchQuery: vi.fn(),
|
||||
skillsSort: 'name-asc',
|
||||
setSkillsSort: vi.fn(),
|
||||
selectedSkillId: skill.id,
|
||||
setSelectedSkillId: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(startWatchingMock).toHaveBeenCalledWith('/tmp/project-a');
|
||||
expect(skillsChangedHandler).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
skillsChangedHandler?.({
|
||||
scope: 'user',
|
||||
projectPath: null,
|
||||
path: `${skill.skillDir}/SKILL.md`,
|
||||
type: 'change',
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.fetchSkillsCatalog).toHaveBeenCalledWith('/tmp/project-a');
|
||||
expect(storeState.fetchSkillDetail).toHaveBeenCalledWith(skill.id, undefined);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getSuggestedSkillFolderNameFromPath,
|
||||
toSuggestedSkillFolderName,
|
||||
} from '../../../../../src/renderer/components/extensions/skills/skillFolderNameUtils';
|
||||
|
||||
describe('skillFolderNameUtils', () => {
|
||||
it('creates a safe slug from a human-readable skill name', () => {
|
||||
expect(toSuggestedSkillFolderName('Review Helper 2')).toBe('review-helper-2');
|
||||
});
|
||||
|
||||
it('falls back to a safe default when the name cannot be slugged', () => {
|
||||
expect(toSuggestedSkillFolderName('Привет мир')).toBe('new-skill');
|
||||
});
|
||||
|
||||
it('sanitizes imported folder names from the selected source path', () => {
|
||||
expect(getSuggestedSkillFolderNameFromPath('/tmp/My Skill Folder')).toBe(
|
||||
'my-skill-folder'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses an import-specific fallback when the source folder name is unusable', () => {
|
||||
expect(getSuggestedSkillFolderNameFromPath('/tmp/技能')).toBe('imported-skill');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveSkillProjectPath } from '../../../../../src/renderer/components/extensions/skills/skillProjectUtils';
|
||||
|
||||
describe('resolveSkillProjectPath', () => {
|
||||
it('returns undefined for user-scoped skills', () => {
|
||||
expect(resolveSkillProjectPath('user', '/tmp/project-a', '/tmp/project-a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers the skill project root over the current tab project for project-scoped skills', () => {
|
||||
expect(resolveSkillProjectPath('project', '/tmp/project-b', '/tmp/project-a')).toBe(
|
||||
'/tmp/project-a'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the current tab project when a project skill has no embedded root', () => {
|
||||
expect(resolveSkillProjectPath('project', '/tmp/project-a', null)).toBe('/tmp/project-a');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
validateSkillFolderName,
|
||||
validateSkillImportSourceDir,
|
||||
} from '../../../../../src/renderer/components/extensions/skills/skillValidationUtils';
|
||||
|
||||
describe('skillValidationUtils', () => {
|
||||
it('rejects empty import source folders', () => {
|
||||
expect(validateSkillImportSourceDir(' ')).toBe('Choose a skill folder to import.');
|
||||
});
|
||||
|
||||
it('accepts non-empty import source folders', () => {
|
||||
expect(validateSkillImportSourceDir('/tmp/source-skill')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts normal folder names', () => {
|
||||
expect(validateSkillFolderName('review-helper')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects empty folder names', () => {
|
||||
expect(validateSkillFolderName(' ')).toBe('Choose a folder name for this skill.');
|
||||
});
|
||||
|
||||
it('rejects invalid filesystem characters', () => {
|
||||
expect(validateSkillFolderName('bad/name')).toBe(
|
||||
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects dot segments', () => {
|
||||
expect(validateSkillFolderName('..')).toBe(
|
||||
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
|
||||
);
|
||||
});
|
||||
});
|
||||
249
test/renderer/hooks/useExtensionsTabState.test.ts
Normal file
249
test/renderer/hooks/useExtensionsTabState.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useExtensionsTabState } from '../../../src/renderer/hooks/useExtensionsTabState';
|
||||
|
||||
import type { McpCatalogItem } from '@shared/types/extensions';
|
||||
|
||||
type ExtensionsTabState = ReturnType<typeof useExtensionsTabState>;
|
||||
|
||||
let capturedState: ExtensionsTabState | null = null;
|
||||
const mcpSearchMock = vi.fn();
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
mcpRegistry: {
|
||||
search: (...args: unknown[]) => mcpSearchMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function Harness(): null {
|
||||
capturedState = useExtensionsTabState();
|
||||
return null;
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeMcpServer(id: string): McpCatalogItem {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: `${id} description`,
|
||||
source: 'official',
|
||||
installSpec: null,
|
||||
envVars: [],
|
||||
tools: [],
|
||||
requiresAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useExtensionsTabState', () => {
|
||||
beforeEach(() => {
|
||||
mcpSearchMock.mockReset();
|
||||
mcpSearchMock.mockResolvedValue({ servers: [], warnings: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
capturedState = null;
|
||||
document.body.innerHTML = '';
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('clears selected plugin when leaving the plugins sub-tab', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedState?.setSelectedPluginId('context7@claude-plugins-official');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedState?.selectedPluginId).toBe('context7@claude-plugins-official');
|
||||
|
||||
await act(async () => {
|
||||
capturedState?.setActiveSubTab('mcp-servers');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedState?.selectedPluginId).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears selected MCP server when leaving the MCP sub-tab', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedState?.setActiveSubTab('mcp-servers');
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
capturedState?.setSelectedMcpServerId('server-1');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedState?.selectedMcpServerId).toBe('server-1');
|
||||
|
||||
await act(async () => {
|
||||
capturedState?.setActiveSubTab('skills');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedState?.selectedMcpServerId).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears selected skill when leaving the skills sub-tab', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedState?.setActiveSubTab('skills');
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
capturedState?.setSelectedSkillId('skill-1');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedState?.selectedSkillId).toBe('skill-1');
|
||||
|
||||
await act(async () => {
|
||||
capturedState?.setActiveSubTab('api-keys');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedState?.selectedSkillId).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stale MCP search responses that resolve out of order', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.useFakeTimers();
|
||||
const first = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>();
|
||||
const second = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>();
|
||||
|
||||
mcpSearchMock
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedState?.mcpSearch('first');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedState?.mcpSearch('second');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
second.resolve({ servers: [makeMcpServer('second-result')], warnings: ['new warning'] });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedState?.mcpSearchResults.map((server) => server.id)).toEqual(['second-result']);
|
||||
expect(capturedState?.mcpSearchWarnings).toEqual(['new warning']);
|
||||
|
||||
await act(async () => {
|
||||
first.resolve({ servers: [makeMcpServer('first-result')], warnings: ['old warning'] });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedState?.mcpSearchResults.map((server) => server.id)).toEqual(['second-result']);
|
||||
expect(capturedState?.mcpSearchWarnings).toEqual(['new warning']);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('drops in-flight MCP search results after clearing the query', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.useFakeTimers();
|
||||
const pending = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>();
|
||||
mcpSearchMock.mockReturnValueOnce(pending.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedState?.mcpSearch('context7');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
expect(capturedState?.mcpSearchLoading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
capturedState?.mcpSearch('');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedState?.mcpSearchQuery).toBe('');
|
||||
expect(capturedState?.mcpSearchResults).toEqual([]);
|
||||
expect(capturedState?.mcpSearchWarnings).toEqual([]);
|
||||
expect(capturedState?.mcpSearchLoading).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve({ servers: [makeMcpServer('stale-result')], warnings: ['stale warning'] });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(capturedState?.mcpSearchResults).toEqual([]);
|
||||
expect(capturedState?.mcpSearchWarnings).toEqual([]);
|
||||
expect(capturedState?.mcpSearchLoading).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -40,6 +40,10 @@ vi.mock('../../../src/renderer/api', () => ({
|
|||
}));
|
||||
|
||||
import { api } from '../../../src/renderer/api';
|
||||
import {
|
||||
getMcpOperationKey,
|
||||
getPluginOperationKey,
|
||||
} from '../../../src/shared/utils/extensionNormalizers';
|
||||
|
||||
import type {
|
||||
EnrichedPlugin,
|
||||
|
|
@ -133,6 +137,11 @@ const makeReadyCliStatus = () => ({
|
|||
providers: [],
|
||||
});
|
||||
|
||||
const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') =>
|
||||
getPluginOperationKey(pluginId, scope);
|
||||
const mcpOperationKey = (registryId: string, scope: 'user' | 'project' | 'local' = 'user') =>
|
||||
getMcpOperationKey(registryId, scope);
|
||||
|
||||
describe('extensionsSlice', () => {
|
||||
let store: TestStore;
|
||||
|
||||
|
|
@ -187,10 +196,10 @@ describe('extensionsSlice', () => {
|
|||
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
|
||||
pluginCatalogProjectPath: '/tmp/project-a',
|
||||
pluginInstallProgress: {
|
||||
'project-a@m': 'error',
|
||||
[pluginOperationKey('project-a@m', 'project')]: 'error',
|
||||
},
|
||||
installErrors: {
|
||||
'project-a@m': 'Install failed',
|
||||
[pluginOperationKey('project-a@m', 'project')]: 'Install failed',
|
||||
'mcp-server': 'Keep me',
|
||||
},
|
||||
});
|
||||
|
|
@ -201,8 +210,10 @@ describe('extensionsSlice', () => {
|
|||
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||
|
||||
expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
|
||||
expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined();
|
||||
expect(store.getState().installErrors['project-a@m']).toBeUndefined();
|
||||
expect(
|
||||
store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')],
|
||||
).toBeUndefined();
|
||||
expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined();
|
||||
expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
|
||||
});
|
||||
|
||||
|
|
@ -266,10 +277,10 @@ describe('extensionsSlice', () => {
|
|||
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
|
||||
pluginCatalogProjectPath: '/tmp/project-a',
|
||||
pluginInstallProgress: {
|
||||
'project-a@m': 'error',
|
||||
[pluginOperationKey('project-a@m', 'project')]: 'error',
|
||||
},
|
||||
installErrors: {
|
||||
'project-a@m': 'Install failed',
|
||||
[pluginOperationKey('project-a@m', 'project')]: 'Install failed',
|
||||
'mcp-server': 'Keep me',
|
||||
},
|
||||
});
|
||||
|
|
@ -278,8 +289,10 @@ describe('extensionsSlice', () => {
|
|||
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||
|
||||
expect(store.getState().pluginCatalog).toEqual([]);
|
||||
expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined();
|
||||
expect(store.getState().installErrors['project-a@m']).toBeUndefined();
|
||||
expect(
|
||||
store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')],
|
||||
).toBeUndefined();
|
||||
expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined();
|
||||
expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
|
||||
});
|
||||
});
|
||||
|
|
@ -363,6 +376,43 @@ describe('extensionsSlice', () => {
|
|||
|
||||
expect(store.getState().mcpInstalledServers).toEqual(installed);
|
||||
});
|
||||
|
||||
it('clears stale project- and local-scoped MCP operation state when project changes', async () => {
|
||||
store.setState({
|
||||
mcpInstalledProjectPath: '/tmp/project-a',
|
||||
mcpInstallProgress: {
|
||||
[mcpOperationKey('project-server', 'project')]: 'error',
|
||||
[mcpOperationKey('local-server', 'local')]: 'success',
|
||||
[mcpOperationKey('user-server', 'user')]: 'pending',
|
||||
},
|
||||
installErrors: {
|
||||
[mcpOperationKey('project-server', 'project')]: 'Project failed',
|
||||
[mcpOperationKey('local-server', 'local')]: 'Local failed',
|
||||
[mcpOperationKey('user-server', 'user')]: 'Keep user state',
|
||||
'plugin:test@marketplace:user': 'Keep plugin state',
|
||||
'mcp-custom:custom-server:project': 'Clear custom project state',
|
||||
},
|
||||
});
|
||||
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
await store.getState().mcpFetchInstalled('/tmp/project-b');
|
||||
|
||||
expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b');
|
||||
expect(store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project')]).toBeUndefined();
|
||||
expect(store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local')]).toBeUndefined();
|
||||
expect(store.getState().mcpInstallProgress[mcpOperationKey('user-server', 'user')]).toBe(
|
||||
'pending',
|
||||
);
|
||||
expect(store.getState().installErrors[mcpOperationKey('project-server', 'project')]).toBeUndefined();
|
||||
expect(store.getState().installErrors[mcpOperationKey('local-server', 'local')]).toBeUndefined();
|
||||
expect(store.getState().installErrors[mcpOperationKey('user-server', 'user')]).toBe(
|
||||
'Keep user state',
|
||||
);
|
||||
expect(store.getState().installErrors['mcp-custom:custom-server:project']).toBeUndefined();
|
||||
expect(store.getState().installErrors['plugin:test@marketplace:user']).toBe(
|
||||
'Keep plugin state',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openExtensionsTab', () => {
|
||||
|
|
@ -448,10 +498,10 @@ describe('extensionsSlice', () => {
|
|||
const promise = store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
|
||||
|
||||
// During execution, should be pending
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('pending');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending');
|
||||
|
||||
await promise;
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
|
||||
});
|
||||
|
||||
it('sets progress to error on failure', async () => {
|
||||
|
|
@ -463,7 +513,7 @@ describe('extensionsSlice', () => {
|
|||
|
||||
await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' });
|
||||
|
||||
expect(store.getState().pluginInstallProgress['fail@m']).toBe('error');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('fail@m')]).toBe('error');
|
||||
});
|
||||
|
||||
it('fills missing projectPath from the active Extensions project context', async () => {
|
||||
|
|
@ -488,8 +538,12 @@ describe('extensionsSlice', () => {
|
|||
await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' });
|
||||
|
||||
expect(api.plugins!.install).not.toHaveBeenCalled();
|
||||
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe(
|
||||
'error',
|
||||
);
|
||||
expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain(
|
||||
'active project',
|
||||
);
|
||||
});
|
||||
|
||||
it('fills missing projectPath for local scope from the active Extensions project context', async () => {
|
||||
|
|
@ -514,8 +568,24 @@ describe('extensionsSlice', () => {
|
|||
await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' });
|
||||
|
||||
expect(api.plugins!.install).not.toHaveBeenCalled();
|
||||
expect(store.getState().pluginInstallProgress['local@m']).toBe('error');
|
||||
expect(store.getState().installErrors['local@m']).toContain('active project');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe(
|
||||
'error',
|
||||
);
|
||||
expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain(
|
||||
'active project',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps user-scope state isolated from local-scope failures', async () => {
|
||||
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
|
||||
|
||||
await store.getState().installPlugin({ pluginId: 'shared@m', scope: 'local' });
|
||||
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'local')]).toBe(
|
||||
'error',
|
||||
);
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'user')]).toBeUndefined();
|
||||
expect(store.getState().installErrors[pluginOperationKey('shared@m', 'user')]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears older success reset timers before a new operation on the same plugin', async () => {
|
||||
|
|
@ -527,14 +597,14 @@ describe('extensionsSlice', () => {
|
|||
.mockResolvedValueOnce({ state: 'error', error: 'second failure' });
|
||||
|
||||
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
|
||||
|
||||
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('error');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('error');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -546,10 +616,10 @@ describe('extensionsSlice', () => {
|
|||
|
||||
const promise = store.getState().uninstallPlugin('test@m', 'user');
|
||||
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('pending');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending');
|
||||
|
||||
await promise;
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
|
||||
});
|
||||
|
||||
it('fills missing projectPath from the active Extensions project context', async () => {
|
||||
|
|
@ -567,8 +637,12 @@ describe('extensionsSlice', () => {
|
|||
await store.getState().uninstallPlugin('project@m', 'project');
|
||||
|
||||
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
|
||||
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe(
|
||||
'error',
|
||||
);
|
||||
expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain(
|
||||
'active project',
|
||||
);
|
||||
});
|
||||
|
||||
it('fills missing projectPath for local uninstall from the active Extensions project context', async () => {
|
||||
|
|
@ -586,8 +660,12 @@ describe('extensionsSlice', () => {
|
|||
await store.getState().uninstallPlugin('local@m', 'local');
|
||||
|
||||
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
|
||||
expect(store.getState().pluginInstallProgress['local@m']).toBe('error');
|
||||
expect(store.getState().installErrors['local@m']).toContain('active project');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe(
|
||||
'error',
|
||||
);
|
||||
expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain(
|
||||
'active project',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not restore idle state after project switch clears a pending success timer', async () => {
|
||||
|
|
@ -602,14 +680,14 @@ describe('extensionsSlice', () => {
|
|||
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||
|
||||
await store.getState().uninstallPlugin('test@m', 'user');
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success');
|
||||
|
||||
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined();
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined();
|
||||
expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -627,10 +705,44 @@ describe('extensionsSlice', () => {
|
|||
headers: [],
|
||||
});
|
||||
|
||||
expect(store.getState().mcpInstallProgress['test-id']).toBe('pending');
|
||||
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
|
||||
'pending',
|
||||
);
|
||||
|
||||
await promise;
|
||||
expect(store.getState().mcpInstallProgress['test-id']).toBe('success');
|
||||
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
|
||||
'success',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not restore idle state after project switch clears a pending project-scope success timer', async () => {
|
||||
vi.useFakeTimers();
|
||||
store.setState({
|
||||
mcpInstalledProjectPath: '/tmp/project-a',
|
||||
});
|
||||
(api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
await store.getState().installMcpServer({
|
||||
registryId: 'test-id',
|
||||
serverName: 'test-server',
|
||||
scope: 'project',
|
||||
projectPath: '/tmp/project-a',
|
||||
envValues: {},
|
||||
headers: [],
|
||||
});
|
||||
|
||||
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBe(
|
||||
'success',
|
||||
);
|
||||
|
||||
await store.getState().mcpFetchInstalled('/tmp/project-b');
|
||||
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -642,10 +754,14 @@ describe('extensionsSlice', () => {
|
|||
|
||||
const promise = store.getState().uninstallMcpServer('test-id', 'test-server', 'user');
|
||||
|
||||
expect(store.getState().mcpInstallProgress['test-id']).toBe('pending');
|
||||
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
|
||||
'pending',
|
||||
);
|
||||
|
||||
await promise;
|
||||
expect(store.getState().mcpInstallProgress['test-id']).toBe('success');
|
||||
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
|
||||
'success',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PluginCatalogItem } from '@shared/types/extensions';
|
||||
import type { InstalledMcpEntry, PluginCatalogItem } from '@shared/types/extensions';
|
||||
|
||||
import {
|
||||
buildPluginId,
|
||||
|
|
@ -8,6 +8,10 @@ import {
|
|||
getExtensionActionDisableReason,
|
||||
getCapabilityLabel,
|
||||
getInstallationSummaryLabel,
|
||||
getMcpInstallationSummaryLabel,
|
||||
getMcpOperationKey,
|
||||
getPreferredMcpInstallationEntry,
|
||||
getPluginOperationKey,
|
||||
getPrimaryCapabilityLabel,
|
||||
hasInstallationInScope,
|
||||
inferCapabilities,
|
||||
|
|
@ -152,6 +156,22 @@ describe('buildPluginId', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getPluginOperationKey', () => {
|
||||
it('namespaces plugin operation keys by scope', () => {
|
||||
expect(getPluginOperationKey('context7@claude-plugins-official', 'local')).toBe(
|
||||
'plugin:context7@claude-plugins-official:local',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMcpOperationKey', () => {
|
||||
it('namespaces MCP operation keys by scope', () => {
|
||||
expect(getMcpOperationKey('io.github.upstash/context7', 'project')).toBe(
|
||||
'mcp:io.github.upstash/context7:project',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasInstallationInScope', () => {
|
||||
it('returns true when the selected scope exists', () => {
|
||||
expect(
|
||||
|
|
@ -193,12 +213,50 @@ describe('getInstallationSummaryLabel', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getPreferredMcpInstallationEntry', () => {
|
||||
it('returns null when there are no MCP installs', () => {
|
||||
expect(getPreferredMcpInstallationEntry([])).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers local scope over project and user', () => {
|
||||
const installations: InstalledMcpEntry[] = [
|
||||
{ name: 'context7', scope: 'user' },
|
||||
{ name: 'context7', scope: 'project' },
|
||||
{ name: 'context7', scope: 'local' },
|
||||
];
|
||||
|
||||
expect(getPreferredMcpInstallationEntry(installations)).toEqual({
|
||||
name: 'context7',
|
||||
scope: 'local',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMcpInstallationSummaryLabel', () => {
|
||||
it('returns null when there are no MCP installations', () => {
|
||||
expect(getMcpInstallationSummaryLabel([])).toBeNull();
|
||||
});
|
||||
|
||||
it('describes a single local MCP installation', () => {
|
||||
expect(getMcpInstallationSummaryLabel([{ scope: 'local' }])).toBe('Installed locally');
|
||||
});
|
||||
|
||||
it('summarizes multiple MCP scopes', () => {
|
||||
expect(
|
||||
getMcpInstallationSummaryLabel([
|
||||
{ scope: 'user' },
|
||||
{ scope: 'project' },
|
||||
])
|
||||
).toBe('Installed in 2 scopes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtensionActionDisableReason', () => {
|
||||
it('requires auth only for install actions', () => {
|
||||
expect(
|
||||
getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus: { installed: true, authLoggedIn: false },
|
||||
cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null },
|
||||
cliStatusLoading: false,
|
||||
}),
|
||||
).toContain('not signed in');
|
||||
|
|
@ -208,7 +266,7 @@ describe('getExtensionActionDisableReason', () => {
|
|||
expect(
|
||||
getExtensionActionDisableReason({
|
||||
isInstalled: true,
|
||||
cliStatus: { installed: true, authLoggedIn: false },
|
||||
cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null },
|
||||
cliStatusLoading: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
|
@ -218,11 +276,26 @@ describe('getExtensionActionDisableReason', () => {
|
|||
expect(
|
||||
getExtensionActionDisableReason({
|
||||
isInstalled: true,
|
||||
cliStatus: { installed: false, authLoggedIn: false },
|
||||
cliStatus: { installed: false, authLoggedIn: false, binaryPath: null, launchError: null },
|
||||
cliStatusLoading: false,
|
||||
}),
|
||||
).toContain('Claude CLI required');
|
||||
});
|
||||
|
||||
it('surfaces startup health-check failures separately from missing CLI', () => {
|
||||
expect(
|
||||
getExtensionActionDisableReason({
|
||||
isInstalled: false,
|
||||
cliStatus: {
|
||||
installed: false,
|
||||
authLoggedIn: false,
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
launchError: 'spawn EACCES',
|
||||
},
|
||||
cliStatusLoading: false,
|
||||
}),
|
||||
).toContain('failed to start');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeMcpServerName', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue