1294 lines
43 KiB
TypeScript
1294 lines
43 KiB
TypeScript
/**
|
|
* Extensions slice — global catalog caches shared across all Extensions tabs.
|
|
* Per-tab UI state lives in useExtensionsTabState() hook, NOT here.
|
|
*/
|
|
|
|
import { api } from '@renderer/api';
|
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
|
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
|
|
import {
|
|
getMcpDiagnosticKey,
|
|
getMcpProjectStateKey,
|
|
getMcpOperationKey,
|
|
getPluginOperationKey,
|
|
} from '@shared/utils/extensionNormalizers';
|
|
|
|
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
|
|
|
import type { AppState } from '../types';
|
|
import type {
|
|
ApiKeyEntry,
|
|
ApiKeySaveRequest,
|
|
ApiKeyStorageStatus,
|
|
EnrichedPlugin,
|
|
ExtensionOperationState,
|
|
InstalledMcpEntry,
|
|
InstallScope,
|
|
McpCatalogItem,
|
|
McpCustomInstallRequest,
|
|
McpInstallRequest,
|
|
McpServerDiagnostic,
|
|
PluginInstallRequest,
|
|
SkillCatalogItem,
|
|
SkillDeleteRequest,
|
|
SkillDetail,
|
|
SkillImportRequest,
|
|
SkillReviewPreview,
|
|
SkillUpsertRequest,
|
|
} from '@shared/types/extensions';
|
|
import type { StateCreator } from 'zustand';
|
|
|
|
// =============================================================================
|
|
// Slice Interface
|
|
// =============================================================================
|
|
|
|
export interface ExtensionsSlice {
|
|
// ── Plugin catalog cache ──
|
|
pluginCatalog: EnrichedPlugin[];
|
|
pluginCatalogLoading: boolean;
|
|
pluginCatalogError: string | null;
|
|
pluginCatalogProjectPath: string | null;
|
|
pluginReadmes: Record<string, string | null>;
|
|
pluginReadmeLoading: Record<string, boolean>;
|
|
|
|
// ── MCP catalog cache ──
|
|
mcpBrowseCatalog: McpCatalogItem[];
|
|
mcpBrowseNextCursor?: string;
|
|
mcpBrowseLoading: boolean;
|
|
mcpBrowseError: string | null;
|
|
mcpInstalledServers: InstalledMcpEntry[];
|
|
mcpInstalledServersByProjectPath: Record<string, InstalledMcpEntry[]>;
|
|
mcpInstalledProjectPath: string | null;
|
|
mcpDiagnostics: Record<string, McpServerDiagnostic>;
|
|
mcpDiagnosticsByProjectPath: Record<string, Record<string, McpServerDiagnostic>>;
|
|
mcpDiagnosticsLoading: boolean;
|
|
mcpDiagnosticsLoadingByProjectPath: Record<string, boolean>;
|
|
mcpDiagnosticsError: string | null;
|
|
mcpDiagnosticsErrorByProjectPath: Record<string, string | null>;
|
|
mcpDiagnosticsLastCheckedAt: number | null;
|
|
mcpDiagnosticsLastCheckedAtByProjectPath: Record<string, number | null>;
|
|
|
|
// ── Install progress ──
|
|
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
|
mcpInstallProgress: Record<string, ExtensionOperationState>;
|
|
installErrors: Record<string, string>; // keyed by scoped operation key
|
|
|
|
// ── API Keys ──
|
|
apiKeys: ApiKeyEntry[];
|
|
apiKeysLoading: boolean;
|
|
apiKeysError: string | null;
|
|
apiKeySaving: boolean;
|
|
apiKeyStorageStatus: ApiKeyStorageStatus | null;
|
|
|
|
// ── Skills catalog cache ──
|
|
skillsUserCatalog: SkillCatalogItem[];
|
|
skillsProjectCatalogByProjectPath: Record<string, SkillCatalogItem[]>;
|
|
skillsCatalogLoadingByProjectPath: Record<string, boolean>;
|
|
skillsCatalogErrorByProjectPath: Record<string, string | null>;
|
|
skillsLoading: boolean;
|
|
skillsError: string | null;
|
|
skillsDetailsById: Record<string, SkillDetail | null | undefined>;
|
|
skillsDetailLoadingById: Record<string, boolean>;
|
|
skillsDetailErrorById: Record<string, string | null>;
|
|
skillsMutationLoading: boolean;
|
|
skillsMutationError: string | null;
|
|
|
|
// ── GitHub Stars (supplementary) ──
|
|
mcpGitHubStars: Record<string, number>;
|
|
|
|
// ── Read actions ──
|
|
fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise<void>;
|
|
fetchPluginReadme: (pluginId: string) => void;
|
|
mcpBrowse: (cursor?: string) => Promise<void>;
|
|
mcpFetchInstalled: (projectPath?: string) => Promise<void>;
|
|
runMcpDiagnostics: (projectPath?: string) => Promise<void>;
|
|
fetchSkillsCatalog: (projectPath?: string) => Promise<void>;
|
|
fetchSkillDetail: (skillId: string, projectPath?: string) => Promise<void>;
|
|
previewSkillUpsert: (request: SkillUpsertRequest) => Promise<SkillReviewPreview>;
|
|
applySkillUpsert: (request: SkillUpsertRequest) => Promise<SkillDetail | null>;
|
|
previewSkillImport: (request: SkillImportRequest) => Promise<SkillReviewPreview>;
|
|
applySkillImport: (request: SkillImportRequest) => Promise<SkillDetail | null>;
|
|
deleteSkill: (request: SkillDeleteRequest) => Promise<void>;
|
|
|
|
// ── Mutation actions ──
|
|
installPlugin: (request: PluginInstallRequest) => Promise<void>;
|
|
uninstallPlugin: (pluginId: string, scope?: InstallScope, projectPath?: string) => Promise<void>;
|
|
installMcpServer: (request: McpInstallRequest) => Promise<void>;
|
|
installCustomMcpServer: (request: McpCustomInstallRequest) => Promise<void>;
|
|
uninstallMcpServer: (
|
|
registryId: string,
|
|
name: string,
|
|
scope?: string,
|
|
projectPath?: string
|
|
) => Promise<void>;
|
|
|
|
// ── API Keys actions ──
|
|
fetchApiKeys: () => Promise<void>;
|
|
fetchApiKeyStorageStatus: () => Promise<void>;
|
|
saveApiKey: (request: ApiKeySaveRequest) => Promise<void>;
|
|
deleteApiKey: (id: string) => Promise<void>;
|
|
|
|
// ── Tab opener ──
|
|
openExtensionsTab: () => void;
|
|
|
|
// ── GitHub Stars ──
|
|
fetchMcpGitHubStars: (repositoryUrls: string[]) => void;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Slice Creator
|
|
// =============================================================================
|
|
|
|
let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
|
|
let pluginCatalogRequestSeq = 0;
|
|
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
const mcpDiagnosticsInFlightByKey = new Map<string, Promise<void>>();
|
|
let skillsCatalogRequestSeq = 0;
|
|
let skillsDetailRequestSeq = 0;
|
|
const latestSkillsCatalogRequestByKey = new Map<string, number>();
|
|
const latestSkillsDetailRequestById = new Map<string, number>();
|
|
|
|
const USER_SKILLS_CATALOG_KEY = '__user__';
|
|
|
|
function hasAnyLoading(loadingMap: Record<string, boolean>): boolean {
|
|
return Object.values(loadingMap).some(Boolean);
|
|
}
|
|
|
|
function getPluginCatalogKey(projectPath?: string): string {
|
|
return projectPath ?? '__user__';
|
|
}
|
|
|
|
function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
|
|
return new Set(catalog.map((plugin) => plugin.pluginId));
|
|
}
|
|
|
|
function isPluginOperationKeyForPlugin(operationKey: string, pluginId: string): boolean {
|
|
return operationKey.startsWith(`plugin:${pluginId}:`);
|
|
}
|
|
|
|
function clearPluginOperationState(
|
|
pluginIds: Set<string>,
|
|
pluginInstallProgress: Record<string, ExtensionOperationState>,
|
|
installErrors: Record<string, string>
|
|
): {
|
|
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
|
installErrors: Record<string, string>;
|
|
} {
|
|
if (pluginIds.size === 0) {
|
|
return { pluginInstallProgress, installErrors };
|
|
}
|
|
|
|
const nextPluginInstallProgress = { ...pluginInstallProgress };
|
|
const nextInstallErrors = { ...installErrors };
|
|
const pluginIdsList = Array.from(pluginIds);
|
|
|
|
for (const operationKey of Object.keys(nextPluginInstallProgress)) {
|
|
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
|
delete nextPluginInstallProgress[operationKey];
|
|
}
|
|
}
|
|
|
|
for (const operationKey of Object.keys(nextInstallErrors)) {
|
|
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
|
delete nextInstallErrors[operationKey];
|
|
}
|
|
}
|
|
|
|
return {
|
|
pluginInstallProgress: nextPluginInstallProgress,
|
|
installErrors: nextInstallErrors,
|
|
};
|
|
}
|
|
|
|
function clearPluginSuccessResetTimer(operationKey: string): void {
|
|
const timer = pluginSuccessResetTimers.get(operationKey);
|
|
if (!timer) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(timer);
|
|
pluginSuccessResetTimers.delete(operationKey);
|
|
}
|
|
|
|
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
|
const pluginIdsList = Array.from(pluginIds);
|
|
for (const operationKey of Array.from(pluginSuccessResetTimers.keys())) {
|
|
if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) {
|
|
clearPluginSuccessResetTimer(operationKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
function schedulePluginSuccessReset(
|
|
operationKey: string,
|
|
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
|
|
): void {
|
|
clearPluginSuccessResetTimer(operationKey);
|
|
const timer = setTimeout(() => {
|
|
pluginSuccessResetTimers.delete(operationKey);
|
|
set((prev) => {
|
|
if (prev.pluginInstallProgress[operationKey] !== 'success') {
|
|
return {};
|
|
}
|
|
|
|
return {
|
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'idle' },
|
|
};
|
|
});
|
|
}, SUCCESS_DISPLAY_MS);
|
|
pluginSuccessResetTimers.set(operationKey, timer);
|
|
}
|
|
|
|
function getCustomMcpOperationKey(
|
|
serverName: string,
|
|
scope: InstallScope,
|
|
projectPath?: string | null
|
|
): string {
|
|
if (scope === 'project' || scope === 'local') {
|
|
return `mcp-custom:${serverName}:${scope}:${getMcpProjectStateKey(projectPath)}`;
|
|
}
|
|
return `mcp-custom:${serverName}:${scope}`;
|
|
}
|
|
|
|
function isProjectScopedMcpOperationKey(operationKey: string): boolean {
|
|
return (
|
|
operationKey.includes(':project:') ||
|
|
operationKey.endsWith(':project') ||
|
|
operationKey.includes(':local:') ||
|
|
operationKey.endsWith(':local')
|
|
);
|
|
}
|
|
|
|
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:')) &&
|
|
isProjectScopedMcpOperationKey(operationKey)
|
|
) {
|
|
delete nextMcpInstallProgress[operationKey];
|
|
}
|
|
}
|
|
|
|
for (const operationKey of Object.keys(nextInstallErrors)) {
|
|
if (
|
|
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
|
isProjectScopedMcpOperationKey(operationKey)
|
|
) {
|
|
delete nextInstallErrors[operationKey];
|
|
}
|
|
}
|
|
|
|
return {
|
|
mcpInstallProgress: nextMcpInstallProgress,
|
|
installErrors: nextInstallErrors,
|
|
};
|
|
}
|
|
|
|
function clearMcpProjectScopedSuccessResetTimers(): void {
|
|
for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) {
|
|
if (isProjectScopedMcpOperationKey(operationKey)) {
|
|
clearMcpSuccessResetTimer(operationKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getSkillsCatalogKey(projectPath?: string): string {
|
|
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
|
}
|
|
|
|
/** Duration to show "success" state before returning to idle */
|
|
const SUCCESS_DISPLAY_MS = 2_000;
|
|
const CLI_AUTH_REQUIRED_MESSAGE =
|
|
'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.';
|
|
const CLI_HEALTHCHECK_FAILED_MESSAGE =
|
|
'Claude CLI was found but failed its startup health check. Open the Dashboard to repair or reinstall it before retrying.';
|
|
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.';
|
|
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
|
set,
|
|
get
|
|
) => ({
|
|
// ── Initial state ──
|
|
pluginCatalog: [],
|
|
pluginCatalogLoading: false,
|
|
pluginCatalogError: null,
|
|
pluginCatalogProjectPath: null,
|
|
pluginReadmes: {},
|
|
pluginReadmeLoading: {},
|
|
|
|
mcpBrowseCatalog: [],
|
|
mcpBrowseNextCursor: undefined,
|
|
mcpBrowseLoading: false,
|
|
mcpBrowseError: null,
|
|
mcpInstalledServers: [],
|
|
mcpInstalledServersByProjectPath: {},
|
|
mcpInstalledProjectPath: null,
|
|
mcpDiagnostics: {},
|
|
mcpDiagnosticsByProjectPath: {},
|
|
mcpDiagnosticsLoading: false,
|
|
mcpDiagnosticsLoadingByProjectPath: {},
|
|
mcpDiagnosticsError: null,
|
|
mcpDiagnosticsErrorByProjectPath: {},
|
|
mcpDiagnosticsLastCheckedAt: null,
|
|
mcpDiagnosticsLastCheckedAtByProjectPath: {},
|
|
|
|
pluginInstallProgress: {},
|
|
mcpInstallProgress: {},
|
|
installErrors: {},
|
|
|
|
apiKeys: [],
|
|
apiKeysLoading: false,
|
|
apiKeysError: null,
|
|
apiKeySaving: false,
|
|
apiKeyStorageStatus: null,
|
|
|
|
skillsUserCatalog: [],
|
|
skillsProjectCatalogByProjectPath: {},
|
|
skillsCatalogLoadingByProjectPath: {},
|
|
skillsCatalogErrorByProjectPath: {},
|
|
skillsLoading: false,
|
|
skillsError: null,
|
|
skillsDetailsById: {},
|
|
skillsDetailLoadingById: {},
|
|
skillsDetailErrorById: {},
|
|
skillsMutationLoading: false,
|
|
skillsMutationError: null,
|
|
|
|
mcpGitHubStars: {},
|
|
|
|
// ── Plugin catalog fetch ──
|
|
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
|
|
if (!api.plugins) return;
|
|
const requestKey = getPluginCatalogKey(projectPath);
|
|
|
|
// Dedup concurrent requests
|
|
if (pluginFetchInFlight && !forceRefresh && pluginFetchInFlight.key === requestKey) {
|
|
await pluginFetchInFlight.promise;
|
|
return;
|
|
}
|
|
|
|
const requestSeq = ++pluginCatalogRequestSeq;
|
|
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
|
|
|
let currentPromise: Promise<void> | null = null;
|
|
currentPromise = (async () => {
|
|
try {
|
|
const result = await api.plugins!.getAll(projectPath, forceRefresh);
|
|
set((prev) => {
|
|
if (requestSeq !== pluginCatalogRequestSeq) {
|
|
return {};
|
|
}
|
|
|
|
const nextProjectPath = projectPath ?? null;
|
|
const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath;
|
|
const pluginIdsToClear = isSameProjectContext
|
|
? new Set<string>()
|
|
: new Set([...buildPluginIdSet(prev.pluginCatalog), ...buildPluginIdSet(result)]);
|
|
const nextOperationState = clearPluginOperationState(
|
|
pluginIdsToClear,
|
|
prev.pluginInstallProgress,
|
|
prev.installErrors
|
|
);
|
|
clearPluginSuccessResetTimers(pluginIdsToClear);
|
|
|
|
return {
|
|
pluginCatalog: result,
|
|
pluginCatalogLoading: false,
|
|
pluginCatalogError: null,
|
|
pluginCatalogProjectPath: nextProjectPath,
|
|
pluginInstallProgress: nextOperationState.pluginInstallProgress,
|
|
installErrors: nextOperationState.installErrors,
|
|
};
|
|
});
|
|
} catch (err) {
|
|
set((prev) => {
|
|
if (requestSeq !== pluginCatalogRequestSeq) {
|
|
return {};
|
|
}
|
|
|
|
const nextProjectPath = projectPath ?? null;
|
|
const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath;
|
|
const nextOperationState = clearPluginOperationState(
|
|
isSameProjectContext ? new Set<string>() : buildPluginIdSet(prev.pluginCatalog),
|
|
prev.pluginInstallProgress,
|
|
prev.installErrors
|
|
);
|
|
clearPluginSuccessResetTimers(
|
|
isSameProjectContext ? new Set<string>() : buildPluginIdSet(prev.pluginCatalog)
|
|
);
|
|
|
|
return {
|
|
pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [],
|
|
pluginCatalogLoading: false,
|
|
pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins',
|
|
pluginCatalogProjectPath: nextProjectPath,
|
|
pluginInstallProgress: nextOperationState.pluginInstallProgress,
|
|
installErrors: nextOperationState.installErrors,
|
|
};
|
|
});
|
|
} finally {
|
|
if (currentPromise && pluginFetchInFlight?.promise === currentPromise) {
|
|
pluginFetchInFlight = null;
|
|
}
|
|
}
|
|
})();
|
|
|
|
pluginFetchInFlight = { key: requestKey, promise: currentPromise };
|
|
await currentPromise;
|
|
},
|
|
|
|
// ── Plugin README fetch ──
|
|
fetchPluginReadme: (pluginId: string) => {
|
|
if (!api.plugins) return;
|
|
const state = get();
|
|
const cachedReadme = state.pluginReadmes[pluginId];
|
|
if (
|
|
(cachedReadme !== undefined && cachedReadme !== null) ||
|
|
state.pluginReadmeLoading[pluginId]
|
|
) {
|
|
return;
|
|
}
|
|
|
|
set((prev) => ({
|
|
pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true },
|
|
}));
|
|
|
|
void api.plugins.getReadme(pluginId).then(
|
|
(readme) => {
|
|
set((prev) => ({
|
|
pluginReadmes: { ...prev.pluginReadmes, [pluginId]: readme },
|
|
pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: false },
|
|
}));
|
|
},
|
|
() => {
|
|
set((prev) => ({
|
|
pluginReadmes: { ...prev.pluginReadmes, [pluginId]: null },
|
|
pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: false },
|
|
}));
|
|
}
|
|
);
|
|
},
|
|
|
|
// ── MCP browse ──
|
|
mcpBrowse: async (cursor?: string) => {
|
|
if (!api.mcpRegistry) return;
|
|
|
|
set({ mcpBrowseLoading: true, mcpBrowseError: null });
|
|
try {
|
|
const result = await api.mcpRegistry.browse(cursor);
|
|
set((prev) => {
|
|
if (!cursor) {
|
|
return {
|
|
mcpBrowseCatalog: result.servers,
|
|
mcpBrowseNextCursor: result.nextCursor,
|
|
mcpBrowseLoading: false,
|
|
};
|
|
}
|
|
// Deduplicate: existing IDs take precedence
|
|
const existingIds = new Set(prev.mcpBrowseCatalog.map((s) => s.id));
|
|
const newServers = result.servers.filter((s) => !existingIds.has(s.id));
|
|
return {
|
|
mcpBrowseCatalog: [...prev.mcpBrowseCatalog, ...newServers],
|
|
mcpBrowseNextCursor: result.nextCursor,
|
|
mcpBrowseLoading: false,
|
|
};
|
|
});
|
|
} catch (err) {
|
|
set({
|
|
mcpBrowseLoading: false,
|
|
mcpBrowseError: err instanceof Error ? err.message : 'Failed to browse MCP servers',
|
|
});
|
|
}
|
|
},
|
|
|
|
// ── MCP installed fetch ──
|
|
mcpFetchInstalled: async (projectPath?: string) => {
|
|
if (!api.mcpRegistry) return;
|
|
|
|
try {
|
|
const installed = await api.mcpRegistry.getInstalled(projectPath);
|
|
set((prev) => {
|
|
const nextProjectPath = projectPath ?? null;
|
|
const stateKey = getMcpProjectStateKey(nextProjectPath);
|
|
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,
|
|
mcpInstalledServersByProjectPath: {
|
|
...prev.mcpInstalledServersByProjectPath,
|
|
[stateKey]: installed,
|
|
},
|
|
mcpInstalledProjectPath: nextProjectPath,
|
|
mcpInstallProgress: nextOperationState.mcpInstallProgress,
|
|
installErrors: nextOperationState.installErrors,
|
|
};
|
|
});
|
|
} catch {
|
|
// Silently fail — installed state is supplementary
|
|
}
|
|
},
|
|
|
|
runMcpDiagnostics: async (projectPath?: string) => {
|
|
const mcpRegistry = api.mcpRegistry;
|
|
if (!mcpRegistry) return;
|
|
const projectStateKey = getMcpProjectStateKey(projectPath);
|
|
|
|
const existing = mcpDiagnosticsInFlightByKey.get(projectStateKey);
|
|
if (existing) {
|
|
await existing;
|
|
return;
|
|
}
|
|
|
|
set((prev) => ({
|
|
mcpDiagnosticsLoading: true,
|
|
mcpDiagnosticsError: null,
|
|
mcpDiagnosticsLoadingByProjectPath: {
|
|
...prev.mcpDiagnosticsLoadingByProjectPath,
|
|
[projectStateKey]: true,
|
|
},
|
|
mcpDiagnosticsErrorByProjectPath: {
|
|
...prev.mcpDiagnosticsErrorByProjectPath,
|
|
[projectStateKey]: null,
|
|
},
|
|
}));
|
|
|
|
const promise = (async () => {
|
|
try {
|
|
const diagnostics = await mcpRegistry.diagnose(projectPath);
|
|
const diagnosticsRecord = Object.fromEntries(
|
|
diagnostics.map((entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const)
|
|
);
|
|
const checkedAt = Date.now();
|
|
set({
|
|
mcpDiagnostics: diagnosticsRecord,
|
|
mcpDiagnosticsLoading: false,
|
|
mcpDiagnosticsByProjectPath: {
|
|
...get().mcpDiagnosticsByProjectPath,
|
|
[projectStateKey]: diagnosticsRecord,
|
|
},
|
|
mcpDiagnosticsLoadingByProjectPath: {
|
|
...get().mcpDiagnosticsLoadingByProjectPath,
|
|
[projectStateKey]: false,
|
|
},
|
|
mcpDiagnosticsLastCheckedAt: checkedAt,
|
|
mcpDiagnosticsLastCheckedAtByProjectPath: {
|
|
...get().mcpDiagnosticsLastCheckedAtByProjectPath,
|
|
[projectStateKey]: checkedAt,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
const errorMessage =
|
|
err instanceof Error ? err.message : 'Failed to check MCP server health';
|
|
set({
|
|
mcpDiagnosticsLoading: false,
|
|
mcpDiagnosticsError: errorMessage,
|
|
mcpDiagnosticsLoadingByProjectPath: {
|
|
...get().mcpDiagnosticsLoadingByProjectPath,
|
|
[projectStateKey]: false,
|
|
},
|
|
mcpDiagnosticsErrorByProjectPath: {
|
|
...get().mcpDiagnosticsErrorByProjectPath,
|
|
[projectStateKey]: errorMessage,
|
|
},
|
|
});
|
|
} finally {
|
|
mcpDiagnosticsInFlightByKey.delete(projectStateKey);
|
|
}
|
|
})();
|
|
|
|
mcpDiagnosticsInFlightByKey.set(projectStateKey, promise);
|
|
await promise;
|
|
},
|
|
|
|
fetchSkillsCatalog: async (projectPath?: string) => {
|
|
if (!api.skills) return;
|
|
|
|
const requestKey = getSkillsCatalogKey(projectPath);
|
|
const requestId = ++skillsCatalogRequestSeq;
|
|
latestSkillsCatalogRequestByKey.set(requestKey, requestId);
|
|
|
|
set((prev) => {
|
|
const nextLoadingByProjectPath = {
|
|
...prev.skillsCatalogLoadingByProjectPath,
|
|
[requestKey]: true,
|
|
};
|
|
return {
|
|
skillsCatalogLoadingByProjectPath: nextLoadingByProjectPath,
|
|
skillsCatalogErrorByProjectPath: {
|
|
...prev.skillsCatalogErrorByProjectPath,
|
|
[requestKey]: null,
|
|
},
|
|
skillsLoading: hasAnyLoading(nextLoadingByProjectPath),
|
|
skillsError: null,
|
|
};
|
|
});
|
|
try {
|
|
const skills = await api.skills.list(projectPath);
|
|
if (latestSkillsCatalogRequestByKey.get(requestKey) !== requestId) {
|
|
return;
|
|
}
|
|
|
|
set((prev) => ({
|
|
skillsCatalogLoadingByProjectPath: {
|
|
...prev.skillsCatalogLoadingByProjectPath,
|
|
[requestKey]: false,
|
|
},
|
|
skillsCatalogErrorByProjectPath: {
|
|
...prev.skillsCatalogErrorByProjectPath,
|
|
[requestKey]: null,
|
|
},
|
|
skillsLoading: hasAnyLoading({
|
|
...prev.skillsCatalogLoadingByProjectPath,
|
|
[requestKey]: false,
|
|
}),
|
|
skillsError: null,
|
|
skillsUserCatalog: skills.filter((skill) => skill.scope === 'user'),
|
|
skillsProjectCatalogByProjectPath: projectPath
|
|
? {
|
|
...prev.skillsProjectCatalogByProjectPath,
|
|
[projectPath]: skills.filter((skill) => skill.scope === 'project'),
|
|
}
|
|
: prev.skillsProjectCatalogByProjectPath,
|
|
}));
|
|
} catch (err) {
|
|
if (latestSkillsCatalogRequestByKey.get(requestKey) !== requestId) {
|
|
return;
|
|
}
|
|
|
|
const message = err instanceof Error ? err.message : 'Failed to load skills';
|
|
set((prev) => ({
|
|
skillsCatalogLoadingByProjectPath: {
|
|
...prev.skillsCatalogLoadingByProjectPath,
|
|
[requestKey]: false,
|
|
},
|
|
skillsCatalogErrorByProjectPath: {
|
|
...prev.skillsCatalogErrorByProjectPath,
|
|
[requestKey]: message,
|
|
},
|
|
skillsLoading: hasAnyLoading({
|
|
...prev.skillsCatalogLoadingByProjectPath,
|
|
[requestKey]: false,
|
|
}),
|
|
skillsError: message,
|
|
}));
|
|
}
|
|
},
|
|
|
|
fetchSkillDetail: async (skillId: string, projectPath?: string) => {
|
|
if (!api.skills) return;
|
|
|
|
const requestId = ++skillsDetailRequestSeq;
|
|
latestSkillsDetailRequestById.set(skillId, requestId);
|
|
|
|
set((prev) => ({
|
|
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: true },
|
|
skillsDetailErrorById: { ...prev.skillsDetailErrorById, [skillId]: null },
|
|
}));
|
|
|
|
try {
|
|
const detail = await api.skills.getDetail(skillId, projectPath);
|
|
if (latestSkillsDetailRequestById.get(skillId) !== requestId) {
|
|
return;
|
|
}
|
|
|
|
set((prev) => ({
|
|
skillsDetailsById: { ...prev.skillsDetailsById, [skillId]: detail },
|
|
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false },
|
|
skillsDetailErrorById: { ...prev.skillsDetailErrorById, [skillId]: null },
|
|
}));
|
|
} catch (err) {
|
|
if (latestSkillsDetailRequestById.get(skillId) !== requestId) {
|
|
return;
|
|
}
|
|
|
|
const message = err instanceof Error ? err.message : 'Failed to load skill details';
|
|
set((prev) => ({
|
|
skillsDetailLoadingById: { ...prev.skillsDetailLoadingById, [skillId]: false },
|
|
skillsDetailErrorById: { ...prev.skillsDetailErrorById, [skillId]: message },
|
|
}));
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
previewSkillUpsert: async (request: SkillUpsertRequest) => {
|
|
if (!api.skills) {
|
|
throw new Error('Skills API is not available');
|
|
}
|
|
|
|
set({ skillsMutationLoading: true, skillsMutationError: null });
|
|
try {
|
|
const preview = await api.skills.previewUpsert(request);
|
|
set({ skillsMutationLoading: false });
|
|
return preview;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to review skill changes';
|
|
set({ skillsMutationLoading: false, skillsMutationError: message });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
applySkillUpsert: async (request: SkillUpsertRequest) => {
|
|
if (!api.skills) {
|
|
throw new Error('Skills API is not available');
|
|
}
|
|
|
|
set({ skillsMutationLoading: true, skillsMutationError: null });
|
|
try {
|
|
const detail = await api.skills.applyUpsert(request);
|
|
await get().fetchSkillsCatalog(request.projectPath);
|
|
set((prev) => ({
|
|
skillsMutationLoading: false,
|
|
skillsDetailsById: detail?.item.id
|
|
? { ...prev.skillsDetailsById, [detail.item.id]: detail }
|
|
: prev.skillsDetailsById,
|
|
}));
|
|
return detail;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to save skill';
|
|
set({ skillsMutationLoading: false, skillsMutationError: message });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
previewSkillImport: async (request: SkillImportRequest) => {
|
|
if (!api.skills) {
|
|
throw new Error('Skills API is not available');
|
|
}
|
|
|
|
set({ skillsMutationLoading: true, skillsMutationError: null });
|
|
try {
|
|
const preview = await api.skills.previewImport(request);
|
|
set({ skillsMutationLoading: false });
|
|
return preview;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to review import changes';
|
|
set({ skillsMutationLoading: false, skillsMutationError: message });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
applySkillImport: async (request: SkillImportRequest) => {
|
|
if (!api.skills) {
|
|
throw new Error('Skills API is not available');
|
|
}
|
|
|
|
set({ skillsMutationLoading: true, skillsMutationError: null });
|
|
try {
|
|
const detail = await api.skills.applyImport(request);
|
|
await get().fetchSkillsCatalog(request.projectPath);
|
|
set((prev) => ({
|
|
skillsMutationLoading: false,
|
|
skillsDetailsById: detail?.item.id
|
|
? { ...prev.skillsDetailsById, [detail.item.id]: detail }
|
|
: prev.skillsDetailsById,
|
|
}));
|
|
return detail;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to import skill';
|
|
set({ skillsMutationLoading: false, skillsMutationError: message });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
deleteSkill: async (request: SkillDeleteRequest) => {
|
|
if (!api.skills) {
|
|
throw new Error('Skills API is not available');
|
|
}
|
|
|
|
set({ skillsMutationLoading: true, skillsMutationError: null });
|
|
try {
|
|
await api.skills.deleteSkill(request);
|
|
await get().fetchSkillsCatalog(request.projectPath);
|
|
set((prev) => {
|
|
const nextDetails = { ...prev.skillsDetailsById };
|
|
delete nextDetails[request.skillId];
|
|
return {
|
|
skillsMutationLoading: false,
|
|
skillsDetailsById: nextDetails,
|
|
};
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to delete skill';
|
|
set({ skillsMutationLoading: false, skillsMutationError: message });
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
// ── Plugin install ──
|
|
installPlugin: async (request: PluginInstallRequest) => {
|
|
if (!api.plugins) return;
|
|
|
|
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
|
|
const effectiveProjectPath =
|
|
request.scope !== 'user'
|
|
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
|
: request.projectPath;
|
|
const operationKey = getPluginOperationKey(
|
|
request.pluginId,
|
|
request.scope,
|
|
effectiveProjectPath
|
|
);
|
|
const effectiveRequest =
|
|
effectiveProjectPath === request.projectPath
|
|
? request
|
|
: { ...request, projectPath: effectiveProjectPath };
|
|
|
|
const preflightState = get();
|
|
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
|
try {
|
|
await preflightState.fetchCliStatus();
|
|
} catch {
|
|
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
|
}
|
|
}
|
|
|
|
const cliStatus = get().cliStatus;
|
|
const preflightError =
|
|
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
|
|
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
|
: cliStatus === null
|
|
? CLI_STATUS_UNKNOWN_MESSAGE
|
|
: !cliStatus.installed
|
|
? cliStatus.binaryPath && cliStatus.launchError
|
|
? CLI_HEALTHCHECK_FAILED_MESSAGE
|
|
: CLI_NOT_FOUND_MESSAGE
|
|
: !cliStatus.authLoggedIn
|
|
? CLI_AUTH_REQUIRED_MESSAGE
|
|
: null;
|
|
|
|
if (preflightError) {
|
|
clearPluginSuccessResetTimer(operationKey);
|
|
set((prev) => ({
|
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
|
installErrors: { ...prev.installErrors, [operationKey]: preflightError },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
clearPluginSuccessResetTimer(operationKey);
|
|
set((prev) => ({
|
|
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, [operationKey]: 'error' },
|
|
installErrors: {
|
|
...prev.installErrors,
|
|
[operationKey]: result.error ?? 'Install failed',
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
set((prev) => ({
|
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' },
|
|
}));
|
|
|
|
// Refresh catalog to pick up new installed state
|
|
void get().fetchPluginCatalog(
|
|
effectiveRequest.scope !== 'user'
|
|
? effectiveRequest.projectPath
|
|
: catalogProjectPathAtOperationStart,
|
|
true
|
|
);
|
|
|
|
schedulePluginSuccessReset(operationKey, set);
|
|
} catch (err) {
|
|
clearPluginSuccessResetTimer(operationKey);
|
|
const message = err instanceof Error ? err.message : 'Install failed';
|
|
set((prev) => ({
|
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
|
installErrors: { ...prev.installErrors, [operationKey]: message },
|
|
}));
|
|
}
|
|
},
|
|
|
|
// ── Plugin uninstall ──
|
|
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
|
|
if (!api.plugins) return;
|
|
|
|
const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined;
|
|
const effectiveScope = scope ?? 'user';
|
|
const effectiveProjectPath =
|
|
effectiveScope !== 'user'
|
|
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
|
: projectPath;
|
|
const operationKey = getPluginOperationKey(pluginId, effectiveScope, effectiveProjectPath);
|
|
if (effectiveScope !== 'user' && !effectiveProjectPath) {
|
|
clearPluginSuccessResetTimer(operationKey);
|
|
set((prev) => ({
|
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
|
installErrors: { ...prev.installErrors, [operationKey]: PROJECT_SCOPE_REQUIRED_MESSAGE },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
clearPluginSuccessResetTimer(operationKey);
|
|
set((prev) => ({
|
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
|
|
}));
|
|
|
|
try {
|
|
const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath);
|
|
if (result.state === 'error') {
|
|
set((prev) => ({
|
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
|
installErrors: {
|
|
...prev.installErrors,
|
|
[operationKey]: result.error ?? 'Uninstall failed',
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
set((prev) => ({
|
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' },
|
|
}));
|
|
|
|
// Refresh catalog
|
|
void get().fetchPluginCatalog(
|
|
effectiveScope !== 'user' ? effectiveProjectPath : catalogProjectPathAtOperationStart,
|
|
true
|
|
);
|
|
|
|
schedulePluginSuccessReset(operationKey, set);
|
|
} catch (err) {
|
|
clearPluginSuccessResetTimer(operationKey);
|
|
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
|
set((prev) => ({
|
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
|
installErrors: { ...prev.installErrors, [operationKey]: message },
|
|
}));
|
|
}
|
|
},
|
|
|
|
// ── MCP install ──
|
|
installMcpServer: async (request: McpInstallRequest) => {
|
|
const operationKey = getMcpOperationKey(request.registryId, request.scope, request.projectPath);
|
|
if (!api.mcpRegistry) {
|
|
clearMcpSuccessResetTimer(operationKey);
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
|
installErrors: {
|
|
...prev.installErrors,
|
|
[operationKey]: 'MCP Registry not available',
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
clearMcpSuccessResetTimer(operationKey);
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
|
}));
|
|
|
|
try {
|
|
const result = await api.mcpRegistry.install(request);
|
|
if (result.state === 'error') {
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
|
installErrors: {
|
|
...prev.installErrors,
|
|
[operationKey]: result.error ?? 'Install failed',
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
await Promise.all([
|
|
get().mcpFetchInstalled(request.projectPath),
|
|
get().runMcpDiagnostics(request.projectPath),
|
|
]);
|
|
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' },
|
|
}));
|
|
|
|
scheduleMcpSuccessReset(operationKey, set);
|
|
} catch (err) {
|
|
clearMcpSuccessResetTimer(operationKey);
|
|
const message = err instanceof Error ? err.message : 'Install failed';
|
|
set((prev) => ({
|
|
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,
|
|
request.projectPath
|
|
);
|
|
if (!api.mcpRegistry) {
|
|
clearMcpSuccessResetTimer(progressKey);
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
|
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
clearMcpSuccessResetTimer(progressKey);
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
|
}));
|
|
|
|
try {
|
|
const result = await api.mcpRegistry.installCustom(request);
|
|
if (result.state === 'error') {
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
|
installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
await Promise.all([
|
|
get().mcpFetchInstalled(request.projectPath),
|
|
get().runMcpDiagnostics(request.projectPath),
|
|
]);
|
|
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
|
|
}));
|
|
|
|
scheduleMcpSuccessReset(progressKey, set);
|
|
} catch (err) {
|
|
clearMcpSuccessResetTimer(progressKey);
|
|
const message = err instanceof Error ? err.message : 'Install failed';
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
|
installErrors: { ...prev.installErrors, [progressKey]: message },
|
|
}));
|
|
}
|
|
},
|
|
|
|
// ── MCP uninstall ──
|
|
uninstallMcpServer: async (
|
|
registryId: string,
|
|
name: string,
|
|
scope?: string,
|
|
projectPath?: string
|
|
) => {
|
|
const operationScope: InstallScope =
|
|
scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user';
|
|
const operationKey = getMcpOperationKey(registryId, operationScope, projectPath);
|
|
if (!api.mcpRegistry) {
|
|
clearMcpSuccessResetTimer(operationKey);
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
|
installErrors: { ...prev.installErrors, [operationKey]: 'MCP Registry not available' },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
clearMcpSuccessResetTimer(operationKey);
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
|
}));
|
|
|
|
try {
|
|
const result = await api.mcpRegistry.uninstall(name, scope, projectPath);
|
|
if (result.state === 'error') {
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
|
installErrors: {
|
|
...prev.installErrors,
|
|
[operationKey]: result.error ?? 'Uninstall failed',
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
await Promise.all([
|
|
get().mcpFetchInstalled(projectPath),
|
|
get().runMcpDiagnostics(projectPath),
|
|
]);
|
|
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' },
|
|
}));
|
|
|
|
scheduleMcpSuccessReset(operationKey, set);
|
|
} catch (err) {
|
|
clearMcpSuccessResetTimer(operationKey);
|
|
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
|
set((prev) => ({
|
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
|
installErrors: { ...prev.installErrors, [operationKey]: message },
|
|
}));
|
|
}
|
|
},
|
|
|
|
// ── API Keys fetch ──
|
|
fetchApiKeys: async () => {
|
|
if (!api.apiKeys) return;
|
|
|
|
set({ apiKeysLoading: true, apiKeysError: null });
|
|
try {
|
|
const keys = await api.apiKeys.list();
|
|
set({ apiKeys: keys, apiKeysLoading: false });
|
|
} catch (err) {
|
|
set({
|
|
apiKeysLoading: false,
|
|
apiKeysError: err instanceof Error ? err.message : 'Failed to load API keys',
|
|
});
|
|
}
|
|
},
|
|
|
|
fetchApiKeyStorageStatus: async () => {
|
|
if (!api.apiKeys) return;
|
|
try {
|
|
const status = await api.apiKeys.getStorageStatus();
|
|
set({ apiKeyStorageStatus: status });
|
|
} catch {
|
|
// Non-critical — UI will just not show the info icon
|
|
}
|
|
},
|
|
|
|
// ── API Key save ──
|
|
saveApiKey: async (request: ApiKeySaveRequest) => {
|
|
if (!api.apiKeys) return;
|
|
|
|
set({ apiKeySaving: true, apiKeysError: null });
|
|
try {
|
|
await api.apiKeys.save(request);
|
|
// Refresh the list to get updated masked values
|
|
const [keys] = await Promise.all([api.apiKeys.list(), get().fetchCliStatus()]);
|
|
set({ apiKeys: keys, apiKeySaving: false });
|
|
} catch (err) {
|
|
set({
|
|
apiKeySaving: false,
|
|
apiKeysError: err instanceof Error ? err.message : 'Failed to save API key',
|
|
});
|
|
throw err; // Re-throw so the dialog can show the error
|
|
}
|
|
},
|
|
|
|
// ── API Key delete ──
|
|
deleteApiKey: async (id: string) => {
|
|
if (!api.apiKeys) return;
|
|
|
|
try {
|
|
await api.apiKeys.delete(id);
|
|
await get().fetchCliStatus();
|
|
set((prev) => ({
|
|
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
|
|
}));
|
|
} catch (err) {
|
|
set({
|
|
apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key',
|
|
});
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
// ── Tab opener ──
|
|
openExtensionsTab: () => {
|
|
const state = get();
|
|
const currentProjectId = state.selectedProjectId ?? state.activeProjectId ?? undefined;
|
|
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
|
|
const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'extensions');
|
|
if (existingTab) {
|
|
// Update projectId to reflect the currently selected project
|
|
if (existingTab.projectId !== currentProjectId) {
|
|
const pane = findPaneByTabId(state.paneLayout, existingTab.id);
|
|
if (pane) {
|
|
set({
|
|
paneLayout: updatePane(state.paneLayout, {
|
|
...pane,
|
|
tabs: pane.tabs.map((t) =>
|
|
t.id === existingTab.id ? { ...t, projectId: currentProjectId } : t
|
|
),
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
state.setActiveTab(existingTab.id);
|
|
return;
|
|
}
|
|
|
|
state.openTab({
|
|
type: 'extensions',
|
|
label: 'Extensions',
|
|
projectId: currentProjectId,
|
|
});
|
|
},
|
|
|
|
// ── GitHub Stars (fire-and-forget) ──
|
|
fetchMcpGitHubStars: (repositoryUrls: string[]) => {
|
|
if (!api.mcpRegistry || repositoryUrls.length === 0) return;
|
|
void api.mcpRegistry
|
|
.githubStars(repositoryUrls)
|
|
.then((stars) => {
|
|
if (Object.keys(stars).length > 0) {
|
|
set((prev) => ({
|
|
mcpGitHubStars: { ...prev.mcpGitHubStars, ...stars },
|
|
}));
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Silent failure — stars are supplementary data
|
|
});
|
|
},
|
|
});
|