feat: enhance API key management and GitHub stars integration
- Introduced ApiKeyService for managing API keys, including listing, saving, deleting, and looking up values. - Added IPC channels for API key operations and integrated them into the Electron API. - Implemented GitHub stars fetching for MCP servers, enhancing visibility of repository popularity. - Updated UI components to display GitHub stars and API key management features, including a dedicated API Keys tab. - Enhanced McpInstallService to support custom MCP server installations with improved validation and error handling. - Refactored various components to accommodate new features and improve user experience.
This commit is contained in:
parent
1949084bd8
commit
a4210936f9
47 changed files with 3060 additions and 220 deletions
|
|
@ -22,10 +22,11 @@
|
||||||
|
|
||||||
## What is this
|
## What is this
|
||||||
|
|
||||||
A new approach to task management with AI agents.
|
A new approach to task management with AI agent teams.
|
||||||
|
|
||||||
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
|
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
|
||||||
- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments
|
- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments
|
||||||
|
- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams
|
||||||
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
|
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
|
||||||
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
|
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
|
||||||
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
|
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ import {
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from './services';
|
} from './services';
|
||||||
import {
|
import {
|
||||||
|
ApiKeyService,
|
||||||
ExtensionFacadeService,
|
ExtensionFacadeService,
|
||||||
GlamaMcpEnrichmentService,
|
GlamaMcpEnrichmentService,
|
||||||
McpCatalogAggregator,
|
McpCatalogAggregator,
|
||||||
|
|
@ -720,6 +721,7 @@ function initializeServices(): void {
|
||||||
// Install services — pass null for binary (uses PATH lookup via execCli fallback)
|
// Install services — pass null for binary (uses PATH lookup via execCli fallback)
|
||||||
const pluginInstallService = new PluginInstallService(null, pluginCatalogService);
|
const pluginInstallService = new PluginInstallService(null, pluginCatalogService);
|
||||||
const mcpInstallService = new McpInstallService(null, mcpAggregator);
|
const mcpInstallService = new McpInstallService(null, mcpAggregator);
|
||||||
|
const apiKeyService = new ApiKeyService();
|
||||||
// warmup() and ensureInstalled() are deferred to after window creation
|
// warmup() and ensureInstalled() are deferred to after window creation
|
||||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||||
httpServer = new HttpServer();
|
httpServer = new HttpServer();
|
||||||
|
|
@ -780,6 +782,7 @@ function initializeServices(): void {
|
||||||
extensionFacadeService,
|
extensionFacadeService,
|
||||||
pluginInstallService,
|
pluginInstallService,
|
||||||
mcpInstallService,
|
mcpInstallService,
|
||||||
|
apiKeyService,
|
||||||
crossTeamService
|
crossTeamService
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,14 @@
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import type {
|
import type {
|
||||||
|
ApiKeyEntry,
|
||||||
|
ApiKeyLookupResult,
|
||||||
|
ApiKeySaveRequest,
|
||||||
|
ApiKeyStorageStatus,
|
||||||
EnrichedPlugin,
|
EnrichedPlugin,
|
||||||
InstalledMcpEntry,
|
InstalledMcpEntry,
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
|
McpCustomInstallRequest,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
McpSearchResult,
|
McpSearchResult,
|
||||||
OperationResult,
|
OperationResult,
|
||||||
|
|
@ -21,12 +26,20 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||||
|
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
API_KEYS_DELETE,
|
||||||
|
API_KEYS_LIST,
|
||||||
|
API_KEYS_LOOKUP,
|
||||||
|
API_KEYS_SAVE,
|
||||||
|
API_KEYS_STORAGE_STATUS,
|
||||||
|
MCP_GITHUB_STARS,
|
||||||
MCP_REGISTRY_BROWSE,
|
MCP_REGISTRY_BROWSE,
|
||||||
MCP_REGISTRY_GET_BY_ID,
|
MCP_REGISTRY_GET_BY_ID,
|
||||||
MCP_REGISTRY_GET_INSTALLED,
|
MCP_REGISTRY_GET_INSTALLED,
|
||||||
MCP_REGISTRY_INSTALL,
|
MCP_REGISTRY_INSTALL,
|
||||||
|
MCP_REGISTRY_INSTALL_CUSTOM,
|
||||||
MCP_REGISTRY_SEARCH,
|
MCP_REGISTRY_SEARCH,
|
||||||
MCP_REGISTRY_UNINSTALL,
|
MCP_REGISTRY_UNINSTALL,
|
||||||
PLUGIN_GET_ALL,
|
PLUGIN_GET_ALL,
|
||||||
|
|
@ -35,6 +48,8 @@ import {
|
||||||
PLUGIN_UNINSTALL,
|
PLUGIN_UNINSTALL,
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
|
|
||||||
|
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
|
||||||
|
|
||||||
const logger = createLogger('IPC:extensions');
|
const logger = createLogger('IPC:extensions');
|
||||||
|
|
||||||
/** Allowed scope values */
|
/** Allowed scope values */
|
||||||
|
|
@ -42,20 +57,25 @@ const VALID_SCOPES = new Set(['local', 'user', 'project']);
|
||||||
|
|
||||||
// ── Module state ───────────────────────────────────────────────────────────
|
// ── Module state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const starsService = new GitHubStarsService();
|
||||||
|
|
||||||
let extensionFacade: ExtensionFacadeService | null = null;
|
let extensionFacade: ExtensionFacadeService | null = null;
|
||||||
let pluginInstaller: PluginInstallService | null = null;
|
let pluginInstaller: PluginInstallService | null = null;
|
||||||
let mcpInstaller: McpInstallService | null = null;
|
let mcpInstaller: McpInstallService | null = null;
|
||||||
|
let apiKeyService: ApiKeyService | null = null;
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function initializeExtensionHandlers(
|
export function initializeExtensionHandlers(
|
||||||
facade: ExtensionFacadeService,
|
facade: ExtensionFacadeService,
|
||||||
pluginInstall?: PluginInstallService,
|
pluginInstall?: PluginInstallService,
|
||||||
mcpInstall?: McpInstallService
|
mcpInstall?: McpInstallService,
|
||||||
|
apiKeys?: ApiKeyService
|
||||||
): void {
|
): void {
|
||||||
extensionFacade = facade;
|
extensionFacade = facade;
|
||||||
pluginInstaller = pluginInstall ?? null;
|
pluginInstaller = pluginInstall ?? null;
|
||||||
mcpInstaller = mcpInstall ?? null;
|
mcpInstaller = mcpInstall ?? null;
|
||||||
|
apiKeyService = apiKeys ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerExtensionHandlers(ipcMain: IpcMain): void {
|
export function registerExtensionHandlers(ipcMain: IpcMain): void {
|
||||||
|
|
@ -68,7 +88,14 @@ export function registerExtensionHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById);
|
ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById);
|
||||||
ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled);
|
ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled);
|
||||||
ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall);
|
ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall);
|
||||||
|
ipcMain.handle(MCP_REGISTRY_INSTALL_CUSTOM, handleMcpInstallCustom);
|
||||||
ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall);
|
ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall);
|
||||||
|
ipcMain.handle(API_KEYS_LIST, handleApiKeysList);
|
||||||
|
ipcMain.handle(API_KEYS_SAVE, handleApiKeysSave);
|
||||||
|
ipcMain.handle(API_KEYS_DELETE, handleApiKeysDelete);
|
||||||
|
ipcMain.handle(API_KEYS_LOOKUP, handleApiKeysLookup);
|
||||||
|
ipcMain.handle(API_KEYS_STORAGE_STATUS, handleApiKeysStorageStatus);
|
||||||
|
ipcMain.handle(MCP_GITHUB_STARS, handleMcpGitHubStars);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeExtensionHandlers(ipcMain: IpcMain): void {
|
export function removeExtensionHandlers(ipcMain: IpcMain): void {
|
||||||
|
|
@ -81,7 +108,14 @@ export function removeExtensionHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID);
|
ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID);
|
||||||
ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED);
|
ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED);
|
||||||
ipcMain.removeHandler(MCP_REGISTRY_INSTALL);
|
ipcMain.removeHandler(MCP_REGISTRY_INSTALL);
|
||||||
|
ipcMain.removeHandler(MCP_REGISTRY_INSTALL_CUSTOM);
|
||||||
ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL);
|
ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL);
|
||||||
|
ipcMain.removeHandler(API_KEYS_LIST);
|
||||||
|
ipcMain.removeHandler(API_KEYS_SAVE);
|
||||||
|
ipcMain.removeHandler(API_KEYS_DELETE);
|
||||||
|
ipcMain.removeHandler(API_KEYS_LOOKUP);
|
||||||
|
ipcMain.removeHandler(API_KEYS_STORAGE_STATUS);
|
||||||
|
ipcMain.removeHandler(MCP_GITHUB_STARS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service guard ──────────────────────────────────────────────────────────
|
// ── Service guard ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -270,6 +304,28 @@ async function handleMcpInstall(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleMcpInstallCustom(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
request?: McpCustomInstallRequest
|
||||||
|
): Promise<IpcResult<OperationResult>> {
|
||||||
|
return wrapHandler('mcpInstallCustom', async () => {
|
||||||
|
if (!request || typeof request.serverName !== 'string' || !request.serverName) {
|
||||||
|
throw new Error('Invalid custom install request: serverName is required');
|
||||||
|
}
|
||||||
|
if (!request.installSpec) {
|
||||||
|
throw new Error('Invalid custom install request: installSpec is required');
|
||||||
|
}
|
||||||
|
if (request.scope && !VALID_SCOPES.has(request.scope)) {
|
||||||
|
throw new Error(`Invalid scope: "${request.scope}"`);
|
||||||
|
}
|
||||||
|
const result = await getMcpInstaller().installCustom(request);
|
||||||
|
if (result.state === 'success') {
|
||||||
|
getFacade().invalidateInstalledCache();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleMcpUninstall(
|
async function handleMcpUninstall(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
name?: string,
|
name?: string,
|
||||||
|
|
@ -294,3 +350,62 @@ async function handleMcpUninstall(
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── API Keys Guard ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getApiKeyService(): ApiKeyService {
|
||||||
|
if (!apiKeyService) throw new Error('API key service not initialized');
|
||||||
|
return apiKeyService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Keys Handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleApiKeysList(): Promise<IpcResult<ApiKeyEntry[]>> {
|
||||||
|
return wrapHandler('apiKeysList', () => getApiKeyService().list());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApiKeysSave(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
request?: ApiKeySaveRequest
|
||||||
|
): Promise<IpcResult<ApiKeyEntry>> {
|
||||||
|
return wrapHandler('apiKeysSave', () => {
|
||||||
|
if (!request) throw new Error('Request is required');
|
||||||
|
return getApiKeyService().save(request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApiKeysDelete(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
id?: string
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
return wrapHandler('apiKeysDelete', () => {
|
||||||
|
if (typeof id !== 'string' || !id) throw new Error('Key ID is required');
|
||||||
|
return getApiKeyService().delete(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApiKeysLookup(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
envVarNames?: string[]
|
||||||
|
): Promise<IpcResult<ApiKeyLookupResult[]>> {
|
||||||
|
return wrapHandler('apiKeysLookup', () => {
|
||||||
|
if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required');
|
||||||
|
return getApiKeyService().lookup(envVarNames);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApiKeysStorageStatus(): Promise<IpcResult<ApiKeyStorageStatus>> {
|
||||||
|
return wrapHandler('apiKeysStorageStatus', () => getApiKeyService().getStorageStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GitHub Stars Handler ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleMcpGitHubStars(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
repositoryUrls?: string[]
|
||||||
|
): Promise<IpcResult<Record<string, number>>> {
|
||||||
|
return wrapHandler('mcpGitHubStars', () => {
|
||||||
|
if (!Array.isArray(repositoryUrls)) throw new Error('repositoryUrls array is required');
|
||||||
|
return starsService.fetchStars(repositoryUrls);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||||
|
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||||
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -139,6 +140,7 @@ export function initializeIpcHandlers(
|
||||||
extensionFacade?: ExtensionFacadeService,
|
extensionFacade?: ExtensionFacadeService,
|
||||||
pluginInstaller?: PluginInstallService,
|
pluginInstaller?: PluginInstallService,
|
||||||
mcpInstaller?: McpInstallService,
|
mcpInstaller?: McpInstallService,
|
||||||
|
apiKeyService?: ApiKeyService,
|
||||||
crossTeamService?: CrossTeamService
|
crossTeamService?: CrossTeamService
|
||||||
): void {
|
): void {
|
||||||
// Initialize domain handlers with registry
|
// Initialize domain handlers with registry
|
||||||
|
|
@ -175,7 +177,7 @@ export function initializeIpcHandlers(
|
||||||
initializeScheduleHandlers(schedulerService);
|
initializeScheduleHandlers(schedulerService);
|
||||||
}
|
}
|
||||||
if (extensionFacade) {
|
if (extensionFacade) {
|
||||||
initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller);
|
initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller, apiKeyService);
|
||||||
}
|
}
|
||||||
if (crossTeamService) {
|
if (crossTeamService) {
|
||||||
initializeCrossTeamHandlers(crossTeamService);
|
initializeCrossTeamHandlers(crossTeamService);
|
||||||
|
|
|
||||||
398
src/main/services/extensions/apikeys/ApiKeyService.ts
Normal file
398
src/main/services/extensions/apikeys/ApiKeyService.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
/**
|
||||||
|
* ApiKeyService — encrypted API key storage with CRUD operations.
|
||||||
|
*
|
||||||
|
* Encryption strategy (in priority order):
|
||||||
|
* 1. Electron safeStorage (OS keychain: macOS Keychain / Windows DPAPI / Linux gnome-libsecret/kwallet)
|
||||||
|
* 2. AES-256-GCM with machine-derived key (Linux fallback when no keyring is available)
|
||||||
|
*
|
||||||
|
* File permissions: 0o600 (owner read/write only) on Unix systems.
|
||||||
|
* Storage file: ~/.claude/api-keys.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { safeStorage } from 'electron';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import type {
|
||||||
|
ApiKeyEntry,
|
||||||
|
ApiKeyLookupResult,
|
||||||
|
ApiKeySaveRequest,
|
||||||
|
ApiKeyStorageStatus,
|
||||||
|
} from '@shared/types/extensions';
|
||||||
|
|
||||||
|
const logger = createLogger('Extensions:ApiKeys');
|
||||||
|
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
|
||||||
|
|
||||||
|
/** How the value was encrypted on disk */
|
||||||
|
type EncryptionMethod = 'safeStorage' | 'aes-local' | 'base64';
|
||||||
|
|
||||||
|
interface StoredApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
envVarName: string;
|
||||||
|
encryptedValue: string;
|
||||||
|
/** @deprecated Use encryptionMethod instead. Kept for migration. */
|
||||||
|
encrypted?: boolean;
|
||||||
|
encryptionMethod?: EncryptionMethod;
|
||||||
|
scope: 'user' | 'project';
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AES-256-GCM constants */
|
||||||
|
const AES_IV_BYTES = 12;
|
||||||
|
const AES_TAG_BYTES = 16;
|
||||||
|
const PBKDF2_ITERATIONS = 100_000;
|
||||||
|
const PBKDF2_KEY_BYTES = 32;
|
||||||
|
const PBKDF2_SALT = 'claude-apikey-storage-v1';
|
||||||
|
|
||||||
|
export class ApiKeyService {
|
||||||
|
private readonly filePath: string;
|
||||||
|
private cache: StoredApiKey[] | null = null;
|
||||||
|
private aesKey: Buffer | null = null;
|
||||||
|
|
||||||
|
constructor(claudeDir?: string) {
|
||||||
|
const baseDir = claudeDir ?? path.join(os.homedir(), '.claude');
|
||||||
|
this.filePath = path.join(baseDir, 'api-keys.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async list(): Promise<ApiKeyEntry[]> {
|
||||||
|
const keys = await this.readStore();
|
||||||
|
return keys.map((k) => ({
|
||||||
|
id: k.id,
|
||||||
|
name: k.name,
|
||||||
|
envVarName: k.envVarName,
|
||||||
|
maskedValue: this.mask(this.decrypt(k)),
|
||||||
|
scope: k.scope,
|
||||||
|
createdAt: k.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(request: ApiKeySaveRequest): Promise<ApiKeyEntry> {
|
||||||
|
if (!request.name?.trim()) throw new Error('Key name is required');
|
||||||
|
if (!request.envVarName?.trim()) throw new Error('Environment variable name is required');
|
||||||
|
if (!ENV_KEY_RE.test(request.envVarName)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid env var name: "${request.envVarName}". Use uppercase letters, digits, underscores.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!request.value) throw new Error('Key value is required');
|
||||||
|
|
||||||
|
const keys = await this.readStore();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const { method, value } = this.encrypt(request.value);
|
||||||
|
|
||||||
|
if (request.id) {
|
||||||
|
const idx = keys.findIndex((k) => k.id === request.id);
|
||||||
|
if (idx === -1) throw new Error(`API key not found: ${request.id}`);
|
||||||
|
keys[idx] = {
|
||||||
|
...keys[idx],
|
||||||
|
name: request.name.trim(),
|
||||||
|
envVarName: request.envVarName.trim(),
|
||||||
|
encryptedValue: value,
|
||||||
|
encryptionMethod: method,
|
||||||
|
scope: request.scope,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
// Remove legacy field
|
||||||
|
delete keys[idx].encrypted;
|
||||||
|
} else {
|
||||||
|
keys.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: request.name.trim(),
|
||||||
|
envVarName: request.envVarName.trim(),
|
||||||
|
encryptedValue: value,
|
||||||
|
encryptionMethod: method,
|
||||||
|
scope: request.scope,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.writeStore(keys);
|
||||||
|
const saved = keys[request.id ? keys.findIndex((k) => k.id === request.id) : keys.length - 1];
|
||||||
|
return {
|
||||||
|
id: saved.id,
|
||||||
|
name: saved.name,
|
||||||
|
envVarName: saved.envVarName,
|
||||||
|
maskedValue: this.mask(request.value),
|
||||||
|
scope: saved.scope,
|
||||||
|
createdAt: saved.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const keys = await this.readStore();
|
||||||
|
const filtered = keys.filter((k) => k.id !== id);
|
||||||
|
if (filtered.length === keys.length) throw new Error(`API key not found: ${id}`);
|
||||||
|
await this.writeStore(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
async lookup(envVarNames: string[]): Promise<ApiKeyLookupResult[]> {
|
||||||
|
if (!envVarNames.length) return [];
|
||||||
|
const keys = await this.readStore();
|
||||||
|
const nameSet = new Set(envVarNames);
|
||||||
|
return keys
|
||||||
|
.filter((k) => nameSet.has(k.envVarName))
|
||||||
|
.map((k) => ({
|
||||||
|
envVarName: k.envVarName,
|
||||||
|
value: this.decrypt(k),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStorageStatus(): Promise<ApiKeyStorageStatus> {
|
||||||
|
const secure = this.isSecureBackend();
|
||||||
|
const backend = this.getBackendName();
|
||||||
|
let fileSecure = true;
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(this.filePath);
|
||||||
|
fileSecure = (stat.mode & 0o077) === 0;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist yet — considered secure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
encryptionMethod: secure ? 'os-keychain' : 'aes-local',
|
||||||
|
backend,
|
||||||
|
fileSecure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encryption ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the OS provides a real secure backend (not basic_text).
|
||||||
|
* On Linux, safeStorage.isEncryptionAvailable() returns true even with basic_text
|
||||||
|
* backend (hardcoded password), so we must check the actual backend name.
|
||||||
|
*/
|
||||||
|
private isSecureBackend(): boolean {
|
||||||
|
try {
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) return false;
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const backend = safeStorage.getSelectedStorageBackend();
|
||||||
|
return backend !== 'basic_text' && backend !== 'unknown';
|
||||||
|
}
|
||||||
|
return true; // macOS Keychain / Windows DPAPI are always reliable
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBackendName(): string {
|
||||||
|
if (process.platform === 'darwin') return 'macOS Keychain';
|
||||||
|
if (process.platform === 'win32') return 'Windows DPAPI';
|
||||||
|
try {
|
||||||
|
return safeStorage.getSelectedStorageBackend();
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private encrypt(value: string): { method: EncryptionMethod; value: string } {
|
||||||
|
// Try OS keychain first
|
||||||
|
if (this.isSecureBackend()) {
|
||||||
|
try {
|
||||||
|
const buf = safeStorage.encryptString(value);
|
||||||
|
return { method: 'safeStorage', value: buf.toString('base64') };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('safeStorage encryption failed, falling back to AES-local:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: AES-256-GCM with machine-derived key
|
||||||
|
return { method: 'aes-local', value: this.encryptAesLocal(value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private decrypt(stored: StoredApiKey): string {
|
||||||
|
try {
|
||||||
|
const method = this.resolveMethod(stored);
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'safeStorage': {
|
||||||
|
const buf = Buffer.from(stored.encryptedValue, 'base64');
|
||||||
|
return safeStorage.decryptString(buf);
|
||||||
|
}
|
||||||
|
case 'aes-local':
|
||||||
|
return this.decryptAesLocal(stored.encryptedValue);
|
||||||
|
case 'base64':
|
||||||
|
return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to decrypt API key "${stored.name}":`, err);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve encryption method, handling legacy entries without encryptionMethod field */
|
||||||
|
private resolveMethod(stored: StoredApiKey): EncryptionMethod {
|
||||||
|
if (stored.encryptionMethod) return stored.encryptionMethod;
|
||||||
|
// Legacy migration: encrypted boolean → method
|
||||||
|
return stored.encrypted ? 'safeStorage' : 'base64';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AES-256-GCM local encryption ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive an AES key from machine-specific info.
|
||||||
|
* Not as secure as OS keychain (extractable with root), but far better than plaintext.
|
||||||
|
* Protects against casual read by other users.
|
||||||
|
*/
|
||||||
|
private getAesKey(): Buffer {
|
||||||
|
if (this.aesKey) return this.aesKey;
|
||||||
|
|
||||||
|
const material = [os.hostname(), os.userInfo().username, this.getMachineId()].join(':');
|
||||||
|
|
||||||
|
this.aesKey = crypto.pbkdf2Sync(
|
||||||
|
material,
|
||||||
|
PBKDF2_SALT,
|
||||||
|
PBKDF2_ITERATIONS,
|
||||||
|
PBKDF2_KEY_BYTES,
|
||||||
|
'sha512'
|
||||||
|
);
|
||||||
|
return this.aesKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMachineId(): string {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return `${process.platform}-${os.arch()}-${os.cpus()[0]?.model ?? 'unknown'}`;
|
||||||
|
}
|
||||||
|
// Linux: try /etc/machine-id (systemd) or /var/lib/dbus/machine-id
|
||||||
|
const candidates = ['/etc/machine-id', '/var/lib/dbus/machine-id'];
|
||||||
|
for (const p of candidates) {
|
||||||
|
try {
|
||||||
|
// Synchronous read is OK — called once, result cached in aesKey
|
||||||
|
const { readFileSync } = require('node:fs') as typeof import('node:fs');
|
||||||
|
const id = readFileSync(p, 'utf-8').trim();
|
||||||
|
if (id) return id;
|
||||||
|
} catch {
|
||||||
|
// Try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `linux-${os.arch()}-${os.cpus()[0]?.model ?? 'unknown'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private encryptAesLocal(value: string): string {
|
||||||
|
const key = this.getAesKey();
|
||||||
|
const iv = crypto.randomBytes(AES_IV_BYTES);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
// Format: iv:tag:ciphertext (all base64)
|
||||||
|
return [iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptAesLocal(stored: string): string {
|
||||||
|
const parts = stored.split(':');
|
||||||
|
if (parts.length !== 3) throw new Error('Invalid AES-local format');
|
||||||
|
const [ivB64, tagB64, dataB64] = parts;
|
||||||
|
const key = this.getAesKey();
|
||||||
|
const iv = Buffer.from(ivB64, 'base64');
|
||||||
|
const tag = Buffer.from(tagB64, 'base64');
|
||||||
|
const data = Buffer.from(dataB64, 'base64');
|
||||||
|
|
||||||
|
if (iv.length !== AES_IV_BYTES) throw new Error('Invalid IV length');
|
||||||
|
if (tag.length !== AES_TAG_BYTES) throw new Error('Invalid auth tag length');
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return decipher.update(data).toString('utf-8') + decipher.final('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private mask(value: string): string {
|
||||||
|
if (value.length <= 3) return '***';
|
||||||
|
return '*'.repeat(Math.min(value.length - 3, 20)) + value.slice(-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Storage I/O ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async readStore(): Promise<StoredApiKey[]> {
|
||||||
|
if (this.cache) return this.cache;
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.filePath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
const keys: StoredApiKey[] = Array.isArray(data) ? data : [];
|
||||||
|
|
||||||
|
// Fix file permissions if insecure
|
||||||
|
await this.ensureFilePermissions();
|
||||||
|
|
||||||
|
// Migrate legacy entries (base64 → current method)
|
||||||
|
const migrated = this.migrateKeys(keys);
|
||||||
|
if (migrated) {
|
||||||
|
await this.writeStore(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache = keys;
|
||||||
|
} catch {
|
||||||
|
this.cache = [];
|
||||||
|
}
|
||||||
|
return this.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate legacy entries: re-encrypt base64 entries with current best method.
|
||||||
|
* Returns true if any entries were migrated.
|
||||||
|
*/
|
||||||
|
private migrateKeys(keys: StoredApiKey[]): boolean {
|
||||||
|
let migrated = false;
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const method = this.resolveMethod(keys[i]);
|
||||||
|
if (method === 'base64') {
|
||||||
|
try {
|
||||||
|
const plaintext = Buffer.from(keys[i].encryptedValue, 'base64').toString('utf-8');
|
||||||
|
if (!plaintext) continue;
|
||||||
|
const { method: newMethod, value } = this.encrypt(plaintext);
|
||||||
|
keys[i] = {
|
||||||
|
...keys[i],
|
||||||
|
encryptedValue: value,
|
||||||
|
encryptionMethod: newMethod,
|
||||||
|
};
|
||||||
|
delete keys[i].encrypted;
|
||||||
|
migrated = true;
|
||||||
|
logger.info(`Migrated API key "${keys[i].name}" from base64 to ${newMethod}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to migrate API key "${keys[i].name}":`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeStore(keys: StoredApiKey[]): Promise<void> {
|
||||||
|
const dir = path.dirname(this.filePath);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
const tmpPath = this.filePath + `.tmp.${crypto.randomUUID()}`;
|
||||||
|
await fs.writeFile(tmpPath, JSON.stringify(keys, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
// Set restrictive permissions before rename (Unix only)
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
try {
|
||||||
|
await fs.chmod(tmpPath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// Best-effort — Windows or restricted FS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rename(tmpPath, this.filePath);
|
||||||
|
this.cache = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure the storage file has owner-only permissions */
|
||||||
|
private async ensureFilePermissions(): Promise<void> {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(this.filePath);
|
||||||
|
if ((stat.mode & 0o077) !== 0) {
|
||||||
|
logger.warn('API keys file has insecure permissions, fixing to 0600');
|
||||||
|
await fs.chmod(this.filePath, 0o600);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File may not exist yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/main/services/extensions/catalog/GitHubStarsService.ts
Normal file
184
src/main/services/extensions/catalog/GitHubStarsService.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
/**
|
||||||
|
* GitHubStarsService — fetches stargazer counts from the GitHub public API.
|
||||||
|
*
|
||||||
|
* - Batch interface: accepts repository URLs, returns map of URL → stars
|
||||||
|
* - In-memory cache with 1-hour TTL
|
||||||
|
* - Concurrency-limited to 5 parallel requests
|
||||||
|
* - Silent failure: 404, timeout, rate-limit → entry skipped
|
||||||
|
*/
|
||||||
|
|
||||||
|
import https from 'node:https';
|
||||||
|
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { parseGitHubOwnerRepo } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
|
const logger = createLogger('Extensions:GitHubStars');
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 60 * 60_000; // 1 hour
|
||||||
|
const HTTP_TIMEOUT_MS = 10_000; // 10 seconds
|
||||||
|
const MAX_CONCURRENCY = 5;
|
||||||
|
const MAX_BODY_SIZE = 256 * 1024; // 256KB (GitHub repo JSON is ~10KB)
|
||||||
|
|
||||||
|
// ── Cache entry ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
stars: number;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class GitHubStarsService {
|
||||||
|
private cache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch GitHub stars for a batch of repository URLs.
|
||||||
|
* Returns `Record<repositoryUrl, starCount>` — only includes URLs with valid results.
|
||||||
|
*/
|
||||||
|
async fetchStars(repositoryUrls: string[]): Promise<Record<string, number>> {
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
const tasks: Array<{ url: string; owner: string; repo: string }> = [];
|
||||||
|
|
||||||
|
for (const url of repositoryUrls) {
|
||||||
|
const parsed = parseGitHubOwnerRepo(url);
|
||||||
|
if (!parsed) continue;
|
||||||
|
|
||||||
|
const cacheKey = `${parsed.owner}/${parsed.repo}`.toLowerCase();
|
||||||
|
const cached = this.cache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||||
|
result[url] = cached.stars;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.push({ url, owner: parsed.owner, repo: parsed.repo });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tasks.length === 0) return result;
|
||||||
|
|
||||||
|
// Fetch with concurrency limit
|
||||||
|
const outcomes = await this.withConcurrencyLimit(
|
||||||
|
tasks.map(({ url, owner, repo }) => async () => {
|
||||||
|
const stars = await this.fetchSingle(owner, repo);
|
||||||
|
if (stars != null) {
|
||||||
|
const cacheKey = `${owner}/${repo}`.toLowerCase();
|
||||||
|
this.cache.set(cacheKey, { stars, fetchedAt: Date.now() });
|
||||||
|
result[url] = stars;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
MAX_CONCURRENCY
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log any failures for debugging (already silent to caller)
|
||||||
|
const failures = outcomes.filter((o) => o === 'error').length;
|
||||||
|
if (failures > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`GitHub stars: ${tasks.length - failures}/${tasks.length} fetched, ${failures} failed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch stargazer count for a single repo. Returns null on any error.
|
||||||
|
*/
|
||||||
|
private async fetchSingle(owner: string, repo: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
||||||
|
const { statusCode, body } = await githubGet(url);
|
||||||
|
|
||||||
|
if (statusCode !== 200) {
|
||||||
|
if (statusCode === 403 || statusCode === 429) {
|
||||||
|
logger.debug(`GitHub API rate limit hit (${statusCode}), skipping remaining`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(body) as { stargazers_count?: number };
|
||||||
|
return typeof data.stargazers_count === 'number' ? data.stargazers_count : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run async tasks with a concurrency limit.
|
||||||
|
*/
|
||||||
|
private async withConcurrencyLimit(
|
||||||
|
tasks: Array<() => Promise<void>>,
|
||||||
|
limit: number
|
||||||
|
): Promise<Array<'ok' | 'error'>> {
|
||||||
|
const results: Array<'ok' | 'error'> = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const run = async (): Promise<void> => {
|
||||||
|
while (index < tasks.length) {
|
||||||
|
const i = index++;
|
||||||
|
try {
|
||||||
|
await tasks[i]();
|
||||||
|
results[i] = 'ok';
|
||||||
|
} catch {
|
||||||
|
results[i] = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => run());
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP helper (GitHub-specific) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function githubGet(url: string): Promise<{ statusCode: number; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const settleResolve = (v: { statusCode: number; body: string }): void => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve(v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const settleReject = (e: Error): void => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.get(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'claude-devtools',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const status = res.statusCode ?? 0;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
res.on('data', (c: Buffer) => {
|
||||||
|
totalSize += c.length;
|
||||||
|
if (totalSize > MAX_BODY_SIZE) {
|
||||||
|
res.destroy(new Error('Response too large'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(c);
|
||||||
|
});
|
||||||
|
res.on('end', () =>
|
||||||
|
settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
|
||||||
|
);
|
||||||
|
res.on('error', settleReject);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout: ${url}`)));
|
||||||
|
req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ import https from 'node:https';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import type { McpCatalogItem, McpToolDef } from '@shared/types/extensions';
|
import type { McpCatalogItem, McpHostingType, McpToolDef } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:GlamaMcp');
|
const logger = createLogger('Extensions:GlamaMcp');
|
||||||
|
|
||||||
|
|
@ -103,6 +103,7 @@ interface GlamaServer {
|
||||||
repository?: { url: string };
|
repository?: { url: string };
|
||||||
spdxLicense?: { name: string; url?: string } | null;
|
spdxLicense?: { name: string; url?: string } | null;
|
||||||
tools?: { name?: string; description?: string }[];
|
tools?: { name?: string; description?: string }[];
|
||||||
|
attributes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service ────────────────────────────────────────────────────────────────
|
// ── Service ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -171,6 +172,18 @@ export class GlamaMcpEnrichmentService {
|
||||||
tools,
|
tools,
|
||||||
glamaUrl: raw.url,
|
glamaUrl: raw.url,
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
|
author: raw.namespace,
|
||||||
|
hostingType: this.deriveHostingType(raw.attributes),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private deriveHostingType(attributes?: string[]): McpHostingType | undefined {
|
||||||
|
if (!attributes?.length) return undefined;
|
||||||
|
const hasLocal = attributes.includes('hosting:local-only');
|
||||||
|
const hasRemote = attributes.includes('hosting:remote-capable');
|
||||||
|
if (hasLocal && hasRemote) return 'both';
|
||||||
|
if (hasLocal) return 'local';
|
||||||
|
if (hasRemote) return 'remote';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,8 @@ export class McpCatalogAggregator {
|
||||||
license: item.license ?? glamaMatch.license,
|
license: item.license ?? glamaMatch.license,
|
||||||
tools: item.tools.length > 0 ? item.tools : glamaMatch.tools,
|
tools: item.tools.length > 0 ? item.tools : glamaMatch.tools,
|
||||||
glamaUrl: glamaMatch.glamaUrl,
|
glamaUrl: glamaMatch.glamaUrl,
|
||||||
|
author: item.author ?? glamaMatch.author,
|
||||||
|
hostingType: item.hostingType ?? glamaMatch.hostingType,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@ interface RegistryServerEntry {
|
||||||
'io.modelcontextprotocol.registry/official'?: {
|
'io.modelcontextprotocol.registry/official'?: {
|
||||||
status?: string;
|
status?: string;
|
||||||
isLatest?: boolean;
|
isLatest?: boolean;
|
||||||
|
publishedAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -238,10 +240,17 @@ export class OfficialMcpRegistryService {
|
||||||
return meta?.isLatest !== false; // include if isLatest is true or undefined
|
return meta?.isLatest !== false; // include if isLatest is true or undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter to active only (include servers with no status or status "active")
|
||||||
|
const active = latest.filter((e) => {
|
||||||
|
const meta = e._meta?.['io.modelcontextprotocol.registry/official'];
|
||||||
|
const status = meta?.status;
|
||||||
|
return !status || status === 'active';
|
||||||
|
});
|
||||||
|
|
||||||
// Deduplicate by server name (take first = latest version)
|
// Deduplicate by server name (take first = latest version)
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const unique: RegistryServerEntry[] = [];
|
const unique: RegistryServerEntry[] = [];
|
||||||
for (const entry of latest) {
|
for (const entry of active) {
|
||||||
if (!seen.has(entry.server.name)) {
|
if (!seen.has(entry.server.name)) {
|
||||||
seen.add(entry.server.name);
|
seen.add(entry.server.name);
|
||||||
unique.push(entry);
|
unique.push(entry);
|
||||||
|
|
@ -253,6 +262,7 @@ export class OfficialMcpRegistryService {
|
||||||
|
|
||||||
private normalizeEntry(entry: RegistryServerEntry): McpCatalogItem {
|
private normalizeEntry(entry: RegistryServerEntry): McpCatalogItem {
|
||||||
const { server } = entry;
|
const { server } = entry;
|
||||||
|
const meta = entry._meta?.['io.modelcontextprotocol.registry/official'];
|
||||||
const installSpec = this.deriveInstallSpec(server);
|
const installSpec = this.deriveInstallSpec(server);
|
||||||
const envVars = this.collectEnvVars(server);
|
const envVars = this.collectEnvVars(server);
|
||||||
const requiresAuth = this.detectAuthRequired(server);
|
const requiresAuth = this.detectAuthRequired(server);
|
||||||
|
|
@ -271,6 +281,10 @@ export class OfficialMcpRegistryService {
|
||||||
glamaUrl: undefined,
|
glamaUrl: undefined,
|
||||||
requiresAuth,
|
requiresAuth,
|
||||||
iconUrl: this.pickIconUrl(server.icons),
|
iconUrl: this.pickIconUrl(server.icons),
|
||||||
|
websiteUrl: server.websiteUrl,
|
||||||
|
status: meta?.status,
|
||||||
|
publishedAt: meta?.publishedAt,
|
||||||
|
updatedAt: meta?.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,5 @@ export { McpInstallationStateService } from './state/McpInstallationStateService
|
||||||
export { ExtensionFacadeService } from './ExtensionFacadeService';
|
export { ExtensionFacadeService } from './ExtensionFacadeService';
|
||||||
export { PluginInstallService } from './install/PluginInstallService';
|
export { PluginInstallService } from './install/PluginInstallService';
|
||||||
export { McpInstallService } from './install/McpInstallService';
|
export { McpInstallService } from './install/McpInstallService';
|
||||||
|
export { ApiKeyService } from './apikeys/ApiKeyService';
|
||||||
|
export { GitHubStarsService } from './catalog/GitHubStarsService';
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { McpInstallRequest, OperationResult } from '@shared/types/extensions';
|
import type {
|
||||||
|
McpCustomInstallRequest,
|
||||||
|
McpInstallRequest,
|
||||||
|
OperationResult,
|
||||||
|
} from '@shared/types/extensions';
|
||||||
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:McpInstall');
|
const logger = createLogger('Extensions:McpInstall');
|
||||||
|
|
@ -180,6 +184,107 @@ export class McpInstallService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a custom MCP server — user provides installSpec directly (bypasses registry).
|
||||||
|
*/
|
||||||
|
async installCustom(request: McpCustomInstallRequest): Promise<OperationResult> {
|
||||||
|
const { serverName, scope, projectPath, installSpec, envValues, headers } = request;
|
||||||
|
|
||||||
|
// Validate inputs (same rules as registry install)
|
||||||
|
if (!SERVER_NAME_RE.test(serverName)) {
|
||||||
|
return {
|
||||||
|
state: 'error',
|
||||||
|
error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope && !VALID_SCOPES.has(scope)) {
|
||||||
|
return { state: 'error', error: `Invalid scope: "${scope}".` };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(envValues)) {
|
||||||
|
if (!ENV_KEY_RE.test(key)) {
|
||||||
|
return { state: 'error', error: `Invalid env var name: "${key}".` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const header of headers) {
|
||||||
|
if (header.key && !HEADER_KEY_RE.test(header.key)) {
|
||||||
|
return { state: 'error', error: `Invalid header name: "${header.key}".` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectPath && !projectPath.startsWith('/')) {
|
||||||
|
return { state: 'error', error: 'projectPath must be an absolute path' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build CLI args from provided installSpec
|
||||||
|
const args: string[] = ['mcp', 'add'];
|
||||||
|
|
||||||
|
if (scope && scope !== 'local') {
|
||||||
|
args.push('-s', scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installSpec.type === 'stdio') {
|
||||||
|
for (const [key, value] of Object.entries(envValues)) {
|
||||||
|
if (key && value) args.push('-e', `${key}=${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(serverName);
|
||||||
|
args.push('--');
|
||||||
|
args.push('npx', '-y');
|
||||||
|
|
||||||
|
const pkg = installSpec.npmVersion
|
||||||
|
? `${installSpec.npmPackage}@${installSpec.npmVersion}`
|
||||||
|
: installSpec.npmPackage;
|
||||||
|
args.push(pkg);
|
||||||
|
} else if (installSpec.type === 'http') {
|
||||||
|
const transport = installSpec.transportType === 'sse' ? 'sse' : 'http';
|
||||||
|
args.push('-t', transport);
|
||||||
|
|
||||||
|
for (const header of headers) {
|
||||||
|
if (header.key && header.value) {
|
||||||
|
args.push('-H', `${header.key}: ${header.value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(envValues)) {
|
||||||
|
if (key && value) args.push('-e', `${key}=${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(serverName);
|
||||||
|
args.push(installSpec.url);
|
||||||
|
} else {
|
||||||
|
return { state: 'error', error: 'Unsupported install spec type' };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Installing custom MCP server: ${serverName} (type: ${installSpec.type}, scope: ${scope ?? 'local'})`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stderr } = await execCli(this.claudeBinary, args, {
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
logger.warn(`Custom MCP install stderr: ${stderr.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: 'success' };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const safeMessage = maskSecrets(
|
||||||
|
message,
|
||||||
|
envValues,
|
||||||
|
headers.map((h) => h.value)
|
||||||
|
);
|
||||||
|
logger.error(`Custom MCP install failed: ${safeMessage}`);
|
||||||
|
return { state: 'error', error: safeMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async uninstall(name: string, scope?: string, projectPath?: string): Promise<OperationResult> {
|
async uninstall(name: string, scope?: string, projectPath?: string): Promise<OperationResult> {
|
||||||
if (!SERVER_NAME_RE.test(name)) {
|
if (!SERVER_NAME_RE.test(name)) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -605,3 +605,28 @@ export const MCP_REGISTRY_INSTALL = 'mcpRegistry:install';
|
||||||
|
|
||||||
/** Uninstall an MCP server */
|
/** Uninstall an MCP server */
|
||||||
export const MCP_REGISTRY_UNINSTALL = 'mcpRegistry:uninstall';
|
export const MCP_REGISTRY_UNINSTALL = 'mcpRegistry:uninstall';
|
||||||
|
|
||||||
|
/** Install a custom MCP server (bypasses registry) */
|
||||||
|
export const MCP_REGISTRY_INSTALL_CUSTOM = 'mcpRegistry:installCustom';
|
||||||
|
|
||||||
|
/** Fetch GitHub stars for MCP server repositories */
|
||||||
|
export const MCP_GITHUB_STARS = 'mcpRegistry:githubStars';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// API Keys Management Channels
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** List all saved API keys (masked values) */
|
||||||
|
export const API_KEYS_LIST = 'apiKeys:list';
|
||||||
|
|
||||||
|
/** Save (create or update) an API key */
|
||||||
|
export const API_KEYS_SAVE = 'apiKeys:save';
|
||||||
|
|
||||||
|
/** Delete an API key by ID */
|
||||||
|
export const API_KEYS_DELETE = 'apiKeys:delete';
|
||||||
|
|
||||||
|
/** Lookup decrypted values by env var names (for auto-fill) */
|
||||||
|
export const API_KEYS_LOOKUP = 'apiKeys:lookup';
|
||||||
|
|
||||||
|
/** Get storage encryption status (for UI display) */
|
||||||
|
export const API_KEYS_STORAGE_STATUS = 'apiKeys:storageStatus';
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,14 @@ import {
|
||||||
MCP_REGISTRY_GET_BY_ID,
|
MCP_REGISTRY_GET_BY_ID,
|
||||||
MCP_REGISTRY_GET_INSTALLED,
|
MCP_REGISTRY_GET_INSTALLED,
|
||||||
MCP_REGISTRY_INSTALL,
|
MCP_REGISTRY_INSTALL,
|
||||||
|
MCP_REGISTRY_INSTALL_CUSTOM,
|
||||||
MCP_REGISTRY_UNINSTALL,
|
MCP_REGISTRY_UNINSTALL,
|
||||||
|
MCP_GITHUB_STARS,
|
||||||
|
API_KEYS_LIST,
|
||||||
|
API_KEYS_SAVE,
|
||||||
|
API_KEYS_DELETE,
|
||||||
|
API_KEYS_LOOKUP,
|
||||||
|
API_KEYS_STORAGE_STATUS,
|
||||||
} from './constants/ipcChannels';
|
} from './constants/ipcChannels';
|
||||||
import {
|
import {
|
||||||
CONFIG_ADD_CUSTOM_PROJECT_PATH,
|
CONFIG_ADD_CUSTOM_PROJECT_PATH,
|
||||||
|
|
@ -259,9 +266,14 @@ import type {
|
||||||
WslClaudeRootCandidate,
|
WslClaudeRootCandidate,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type {
|
import type {
|
||||||
|
ApiKeyEntry,
|
||||||
|
ApiKeyLookupResult,
|
||||||
|
ApiKeySaveRequest,
|
||||||
|
ApiKeyStorageStatus,
|
||||||
EnrichedPlugin,
|
EnrichedPlugin,
|
||||||
InstalledMcpEntry,
|
InstalledMcpEntry,
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
|
McpCustomInstallRequest,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
McpSearchResult,
|
McpSearchResult,
|
||||||
OperationResult,
|
OperationResult,
|
||||||
|
|
@ -1384,8 +1396,22 @@ const electronAPI: ElectronAPI = {
|
||||||
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
|
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
|
||||||
install: (request: McpInstallRequest) =>
|
install: (request: McpInstallRequest) =>
|
||||||
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
|
||||||
|
installCustom: (request: McpCustomInstallRequest) =>
|
||||||
|
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL_CUSTOM, request),
|
||||||
uninstall: (name: string, scope?: string, projectPath?: string) =>
|
uninstall: (name: string, scope?: string, projectPath?: string) =>
|
||||||
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_UNINSTALL, name, scope, projectPath),
|
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_UNINSTALL, name, scope, projectPath),
|
||||||
|
githubStars: (repositoryUrls: string[]) =>
|
||||||
|
invokeIpcWithResult<Record<string, number>>(MCP_GITHUB_STARS, repositoryUrls),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== API Keys API (Electron-only) =====
|
||||||
|
apiKeys: {
|
||||||
|
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),
|
||||||
|
save: (request: ApiKeySaveRequest) => invokeIpcWithResult<ApiKeyEntry>(API_KEYS_SAVE, request),
|
||||||
|
delete: (id: string) => invokeIpcWithResult<void>(API_KEYS_DELETE, id),
|
||||||
|
lookup: (envVarNames: string[]) =>
|
||||||
|
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames),
|
||||||
|
getStorageStatus: () => invokeIpcWithResult<ApiKeyStorageStatus>(API_KEYS_STORAGE_STATUS),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText, UsersRound } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -67,11 +67,12 @@ interface MarkdownViewerProps {
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom URL transform that preserves task:// and mention:// protocols.
|
* Custom URL transform that preserves task://, mention://, and team:// protocols.
|
||||||
* react-markdown v10 strips non-standard protocols by default.
|
* react-markdown v10 strips non-standard protocols by default.
|
||||||
*/
|
*/
|
||||||
function allowCustomProtocols(url: string): string {
|
function allowCustomProtocols(url: string): string {
|
||||||
if (url.startsWith('task://') || url.startsWith('mention://')) return url;
|
if (url.startsWith('task://') || url.startsWith('mention://') || url.startsWith('team://'))
|
||||||
|
return url;
|
||||||
return defaultUrlTransform(url);
|
return defaultUrlTransform(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,6 +246,31 @@ function createViewerMarkdownComponents(
|
||||||
}
|
}
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
if (href?.startsWith('team://')) {
|
||||||
|
let teamName = '';
|
||||||
|
try {
|
||||||
|
teamName = decodeURIComponent(href.slice('team://'.length));
|
||||||
|
} catch {
|
||||||
|
// malformed percent-encoding
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-0.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(168, 85, 247, 0.15)',
|
||||||
|
color: '#c084fc',
|
||||||
|
borderRadius: '3px',
|
||||||
|
boxShadow: '0 0 0 1.5px rgba(168, 85, 247, 0.15)',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
title={teamName ? `Team: ${teamName}` : undefined}
|
||||||
|
>
|
||||||
|
<UsersRound size={11} className="shrink-0" style={{ opacity: 0.8 }} />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (href?.startsWith('task://')) {
|
if (href?.startsWith('task://')) {
|
||||||
const taskId = href.slice('task://'.length);
|
const taskId = href.slice('task://'.length);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Global catalog data comes from Zustand store.
|
* Global catalog data comes from Zustand store.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
|
@ -17,13 +17,16 @@ import {
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
import { AlertTriangle, Info, Puzzle, RefreshCw, Server } from 'lucide-react';
|
import { AlertTriangle, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
||||||
|
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
|
||||||
import { McpServersPanel } from './mcp/McpServersPanel';
|
import { McpServersPanel } from './mcp/McpServersPanel';
|
||||||
import { PluginsPanel } from './plugins/PluginsPanel';
|
import { PluginsPanel } from './plugins/PluginsPanel';
|
||||||
|
|
||||||
export const ExtensionStoreView = (): React.JSX.Element => {
|
export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog);
|
const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog);
|
||||||
|
const fetchApiKeys = useStore((s) => s.fetchApiKeys);
|
||||||
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
||||||
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
|
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
|
||||||
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
|
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
|
||||||
|
|
@ -33,6 +36,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing));
|
const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing));
|
||||||
|
|
||||||
const tabState = useExtensionsTabState();
|
const tabState = useExtensionsTabState();
|
||||||
|
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Fetch plugin catalog on mount
|
// Fetch plugin catalog on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -44,6 +48,11 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
void mcpFetchInstalled();
|
void mcpFetchInstalled();
|
||||||
}, [mcpFetchInstalled]);
|
}, [mcpFetchInstalled]);
|
||||||
|
|
||||||
|
// Fetch API keys on mount
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchApiKeys();
|
||||||
|
}, [fetchApiKeys]);
|
||||||
|
|
||||||
// Refresh all data (plugins + MCP browse + installed)
|
// Refresh all data (plugins + MCP browse + installed)
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void fetchPluginCatalog(undefined, true);
|
void fetchPluginCatalog(undefined, true);
|
||||||
|
|
@ -104,18 +113,37 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
)}
|
)}
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tabState.activeSubTab}
|
value={tabState.activeSubTab}
|
||||||
onValueChange={(v) => tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers')}
|
onValueChange={(v) =>
|
||||||
|
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'api-keys')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<TabsList className="mb-4">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<TabsTrigger value="plugins" className="gap-1.5">
|
<TabsList>
|
||||||
<Puzzle className="size-3.5" />
|
<TabsTrigger value="plugins" className="gap-1.5">
|
||||||
Plugins
|
<Puzzle className="size-3.5" />
|
||||||
</TabsTrigger>
|
Plugins
|
||||||
<TabsTrigger value="mcp-servers" className="gap-1.5">
|
</TabsTrigger>
|
||||||
<Server className="size-3.5" />
|
<TabsTrigger value="mcp-servers" className="gap-1.5">
|
||||||
MCP Servers
|
<Server className="size-3.5" />
|
||||||
</TabsTrigger>
|
MCP Servers
|
||||||
</TabsList>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="api-keys" className="gap-1.5">
|
||||||
|
<Key className="size-3.5" />
|
||||||
|
API Keys
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
{tabState.activeSubTab === 'mcp-servers' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCustomMcpDialogOpen(true)}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 size-3.5" />
|
||||||
|
Add Custom
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabsContent value="plugins">
|
<TabsContent value="plugins">
|
||||||
<PluginsPanel
|
<PluginsPanel
|
||||||
|
|
@ -144,7 +172,17 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
|
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="api-keys">
|
||||||
|
<ApiKeysPanel />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Custom MCP server dialog (lifted to store view level) */}
|
||||||
|
<CustomMcpServerDialog
|
||||||
|
open={customMcpDialogOpen}
|
||||||
|
onClose={() => setCustomMcpDialogOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
137
src/renderer/components/extensions/apikeys/ApiKeyCard.tsx
Normal file
137
src/renderer/components/extensions/apikeys/ApiKeyCard.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/**
|
||||||
|
* ApiKeyCard — displays a single API key entry with edit/delete controls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@renderer/components/ui/tooltip';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { Copy, Check, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
interface ApiKeyCardProps {
|
||||||
|
apiKey: ApiKeyEntry;
|
||||||
|
onEdit: (key: ApiKeyEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Element => {
|
||||||
|
const deleteApiKey = useStore((s) => s.deleteApiKey);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
const handleCopyEnvVar = async () => {
|
||||||
|
await navigator.clipboard.writeText(apiKey.envVarName);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
setTimeout(() => setConfirmDelete(false), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void deleteApiKey(apiKey.id);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdDate = new Date(apiKey.createdAt).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2.5 rounded-lg border border-border bg-surface p-4 transition-colors hover:border-border-emphasis">
|
||||||
|
{/* Name + scope badge */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate text-sm font-medium text-text">{apiKey.name}</h3>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
apiKey.scope === 'user'
|
||||||
|
? 'border-blue-500/30 bg-blue-500/10 text-blue-400'
|
||||||
|
: 'border-purple-500/30 bg-purple-500/10 text-purple-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{apiKey.scope}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Env var name */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<code className="rounded bg-surface-raised px-1.5 py-0.5 text-xs text-blue-400">
|
||||||
|
{apiKey.envVarName}
|
||||||
|
</code>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-5"
|
||||||
|
onClick={() => void handleCopyEnvVar()}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-3 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-3 text-text-muted" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{copied ? 'Copied!' : 'Copy env var name'}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Masked value */}
|
||||||
|
<p className="font-mono text-xs text-text-muted">{apiKey.maskedValue}</p>
|
||||||
|
|
||||||
|
{/* Footer: date + actions */}
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<span className="text-xs text-text-muted">{createdDate}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => onEdit(apiKey)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5 text-text-muted" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Edit</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`size-7 ${confirmDelete ? 'text-red-400 hover:bg-red-500/10' : 'text-text-muted'}`}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{confirmDelete ? 'Click again to confirm' : 'Delete'}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
243
src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx
Normal file
243
src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
/**
|
||||||
|
* ApiKeyFormDialog — create or edit an API key entry.
|
||||||
|
* Edit mode pre-fills all fields except the value (which must be re-entered).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@renderer/components/ui/dialog';
|
||||||
|
import { Input } from '@renderer/components/ui/input';
|
||||||
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@renderer/components/ui/select';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { AlertTriangle, Key } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
|
||||||
|
|
||||||
|
interface ApiKeyFormDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
editingKey: ApiKeyEntry | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scope = 'user' | 'project';
|
||||||
|
|
||||||
|
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||||
|
{ value: 'user', label: 'User (global)' },
|
||||||
|
{ value: 'project', label: 'Project' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ApiKeyFormDialog = ({
|
||||||
|
open,
|
||||||
|
editingKey,
|
||||||
|
onClose,
|
||||||
|
}: ApiKeyFormDialogProps): React.JSX.Element => {
|
||||||
|
const saveApiKey = useStore((s) => s.saveApiKey);
|
||||||
|
const apiKeySaving = useStore((s) => s.apiKeySaving);
|
||||||
|
const storageStatus = useStore((s) => s.apiKeyStorageStatus);
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [envVarName, setEnvVarName] = useState('');
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [scope, setScope] = useState<Scope>('user');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [envVarError, setEnvVarError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reset form when dialog opens/closes or editing key changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (editingKey) {
|
||||||
|
setName(editingKey.name);
|
||||||
|
setEnvVarName(editingKey.envVarName);
|
||||||
|
setScope(editingKey.scope);
|
||||||
|
setValue('');
|
||||||
|
} else {
|
||||||
|
setName('');
|
||||||
|
setEnvVarName('');
|
||||||
|
setValue('');
|
||||||
|
setScope('user');
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setEnvVarError(null);
|
||||||
|
}
|
||||||
|
}, [open, editingKey]);
|
||||||
|
|
||||||
|
const validateEnvVar = (v: string) => {
|
||||||
|
if (!v.trim()) {
|
||||||
|
setEnvVarError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ENV_KEY_RE.test(v)) {
|
||||||
|
setEnvVarError('Use letters, digits, underscores. Must start with a letter or underscore.');
|
||||||
|
} else {
|
||||||
|
setEnvVarError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!envVarName.trim()) {
|
||||||
|
setError('Environment variable name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ENV_KEY_RE.test(envVarName)) {
|
||||||
|
setError('Invalid environment variable name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
setError('Key value is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveApiKey({
|
||||||
|
id: editingKey?.id,
|
||||||
|
name: name.trim(),
|
||||||
|
envVarName: envVarName.trim(),
|
||||||
|
value,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEdit = editingKey !== null;
|
||||||
|
const canSubmit = name.trim() && envVarName.trim() && value && !envVarError && !apiKeySaving;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||||
|
<Key className="size-4 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogTitle>{isEdit ? 'Edit API Key' : 'Add API Key'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? 'Update the key details. You must re-enter the value.'
|
||||||
|
: 'Store an API key for auto-filling in MCP server installations.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{storageStatus && storageStatus.encryptionMethod !== 'os-keychain' && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-400">
|
||||||
|
<AlertTriangle className="size-3.5 shrink-0" />
|
||||||
|
OS keychain unavailable — keys encrypted with AES-256 locally. Install gnome-keyring for
|
||||||
|
OS-level protection.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="apikey-name" className="text-xs">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="apikey-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. OpenAI Production"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Env var name */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="apikey-envvar" className="text-xs">
|
||||||
|
Environment Variable Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="apikey-envvar"
|
||||||
|
value={envVarName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEnvVarName(e.target.value);
|
||||||
|
validateEnvVar(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="e.g. OPENAI_API_KEY"
|
||||||
|
className={`h-8 font-mono text-sm ${envVarError ? 'border-red-500/50' : ''}`}
|
||||||
|
/>
|
||||||
|
{envVarError && <p className="text-xs text-red-400">{envVarError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="apikey-value" className="text-xs">
|
||||||
|
Value
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="apikey-value"
|
||||||
|
type="password"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={isEdit ? 'Re-enter key value' : 'sk-...'}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scope */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Scope</Label>
|
||||||
|
<Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCOPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" disabled={!canSubmit}>
|
||||||
|
{apiKeySaving ? 'Saving...' : isEdit ? 'Update' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
139
src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx
Normal file
139
src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
/**
|
||||||
|
* ApiKeysPanel — grid of saved API keys with add button and empty state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ApiKeyCard } from './ApiKeyCard';
|
||||||
|
import { ApiKeyFormDialog } from './ApiKeyFormDialog';
|
||||||
|
|
||||||
|
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
export const ApiKeysPanel = (): React.JSX.Element => {
|
||||||
|
const apiKeys = useStore((s) => s.apiKeys);
|
||||||
|
const apiKeysLoading = useStore((s) => s.apiKeysLoading);
|
||||||
|
const apiKeysError = useStore((s) => s.apiKeysError);
|
||||||
|
const storageStatus = useStore((s) => s.apiKeyStorageStatus);
|
||||||
|
const fetchStorageStatus = useStore((s) => s.fetchApiKeyStorageStatus);
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchStorageStatus();
|
||||||
|
}, [fetchStorageStatus]);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingKey(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (key: ApiKeyEntry) => {
|
||||||
|
setEditingKey(key);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingKey(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-text-secondary">
|
||||||
|
Securely store API keys for auto-filling when installing MCP servers.
|
||||||
|
{storageStatus && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{isOsKeychain ? (
|
||||||
|
<Info className="size-3.5 shrink-0 cursor-help text-text-muted" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="size-3.5 shrink-0 cursor-help text-amber-400" />
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="max-w-xs">
|
||||||
|
{isOsKeychain ? (
|
||||||
|
<p>
|
||||||
|
Keys are encrypted via {storageStatus.backend} and stored with restricted file
|
||||||
|
permissions (owner-only).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
OS keychain unavailable — keys are encrypted locally with AES-256. For stronger
|
||||||
|
protection, install a keyring service (gnome-keyring, kwallet).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add API Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{apiKeysError && (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
|
||||||
|
{apiKeysError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skeleton loading */}
|
||||||
|
{apiKeysLoading && apiKeys.length === 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="skeleton-card flex flex-col gap-2 rounded-lg border border-border p-4"
|
||||||
|
style={{ animationDelay: `${i * 80}ms` }}
|
||||||
|
>
|
||||||
|
<div className="h-4 w-32 rounded bg-surface-raised" />
|
||||||
|
<div className="h-3 w-24 rounded bg-surface-raised" />
|
||||||
|
<div className="h-3 w-40 rounded bg-surface-raised" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!apiKeysLoading && apiKeys.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||||
|
<Key className="size-5 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-secondary">No API keys saved</p>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Add keys to auto-fill environment variables when installing MCP servers.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAdd} className="mt-2 gap-1.5">
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add your first key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key cards grid */}
|
||||||
|
{apiKeys.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{apiKeys.map((key) => (
|
||||||
|
<ApiKeyCard key={key.id} apiKey={key} onEdit={handleEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form dialog */}
|
||||||
|
<ApiKeyFormDialog open={dialogOpen} editingKey={editingKey} onClose={handleDialogClose} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
444
src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx
Normal file
444
src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx
Normal file
|
|
@ -0,0 +1,444 @@
|
||||||
|
/**
|
||||||
|
* CustomMcpServerDialog — add a custom MCP server by providing install spec directly.
|
||||||
|
* Supports stdio (npm package) and HTTP/SSE transports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@renderer/components/ui/dialog';
|
||||||
|
import { Input } from '@renderer/components/ui/input';
|
||||||
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@renderer/components/ui/select';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { api } from '@renderer/api';
|
||||||
|
import { Plus, Server, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
McpCustomInstallRequest,
|
||||||
|
McpHeaderDef,
|
||||||
|
McpInstallSpec,
|
||||||
|
} from '@shared/types/extensions';
|
||||||
|
|
||||||
|
const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
|
||||||
|
|
||||||
|
interface CustomMcpServerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransportMode = 'stdio' | 'http';
|
||||||
|
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
||||||
|
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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [
|
||||||
|
{ value: 'streamable-http', label: 'Streamable HTTP' },
|
||||||
|
{ value: 'sse', label: 'SSE' },
|
||||||
|
{ value: 'http', label: 'HTTP' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface EnvEntry {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMcpServerDialog = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: CustomMcpServerDialogProps): React.JSX.Element => {
|
||||||
|
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [serverName, setServerName] = useState('');
|
||||||
|
const [transportMode, setTransportMode] = useState<TransportMode>('stdio');
|
||||||
|
const [scope, setScope] = useState<Scope>('user');
|
||||||
|
|
||||||
|
// Stdio fields
|
||||||
|
const [npmPackage, setNpmPackage] = useState('');
|
||||||
|
const [npmVersion, setNpmVersion] = useState('');
|
||||||
|
|
||||||
|
// HTTP fields
|
||||||
|
const [httpUrl, setHttpUrl] = useState('');
|
||||||
|
const [httpTransport, setHttpTransport] = useState<HttpTransport>('streamable-http');
|
||||||
|
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
||||||
|
|
||||||
|
// Shared
|
||||||
|
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
|
||||||
|
// Reset on open
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setServerName('');
|
||||||
|
setTransportMode('stdio');
|
||||||
|
setScope('user');
|
||||||
|
setNpmPackage('');
|
||||||
|
setNpmVersion('');
|
||||||
|
setHttpUrl('');
|
||||||
|
setHttpTransport('streamable-http');
|
||||||
|
setHeaders([]);
|
||||||
|
setEnvVars([]);
|
||||||
|
setError(null);
|
||||||
|
setInstalling(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Auto-fill env vars from saved API keys
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || envVars.length === 0 || !api.apiKeys) return;
|
||||||
|
|
||||||
|
const envVarNames = envVars.map((e) => e.key).filter(Boolean);
|
||||||
|
if (envVarNames.length === 0) return;
|
||||||
|
|
||||||
|
void api.apiKeys.lookup(envVarNames).then(
|
||||||
|
(results) => {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
const lookup = new Map(results.map((r) => [r.envVarName, r.value]));
|
||||||
|
setEnvVars((prev) =>
|
||||||
|
prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [open, envVars.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!serverName.trim()) {
|
||||||
|
setError('Server name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!SERVER_NAME_RE.test(serverName)) {
|
||||||
|
setError('Invalid server name. Use alphanumeric characters, dashes, underscores, dots.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let installSpec: McpInstallSpec;
|
||||||
|
|
||||||
|
if (transportMode === 'stdio') {
|
||||||
|
if (!npmPackage.trim()) {
|
||||||
|
setError('npm package name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
installSpec = {
|
||||||
|
type: 'stdio',
|
||||||
|
npmPackage: npmPackage.trim(),
|
||||||
|
npmVersion: npmVersion.trim() || undefined,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (!httpUrl.trim()) {
|
||||||
|
setError('Server URL is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
installSpec = {
|
||||||
|
type: 'http',
|
||||||
|
url: httpUrl.trim(),
|
||||||
|
transportType: httpTransport,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const envValues: Record<string, string> = {};
|
||||||
|
for (const entry of envVars) {
|
||||||
|
if (entry.key.trim() && entry.value) {
|
||||||
|
envValues[entry.key.trim()] = entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: McpCustomInstallRequest = {
|
||||||
|
serverName,
|
||||||
|
scope,
|
||||||
|
installSpec,
|
||||||
|
envValues,
|
||||||
|
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
|
||||||
|
};
|
||||||
|
|
||||||
|
setInstalling(true);
|
||||||
|
try {
|
||||||
|
await installCustomMcpServer(request);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Install failed');
|
||||||
|
} finally {
|
||||||
|
setInstalling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEnvVar = () => setEnvVars((prev) => [...prev, { key: '', value: '' }]);
|
||||||
|
const removeEnvVar = (i: number) => setEnvVars((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
const updateEnvVar = (i: number, field: 'key' | 'value', val: string) =>
|
||||||
|
setEnvVars((prev) => prev.map((e, idx) => (idx === i ? { ...e, [field]: val } : e)));
|
||||||
|
|
||||||
|
const addHeader = () => setHeaders((prev) => [...prev, { key: '', value: '' }]);
|
||||||
|
const removeHeader = (i: number) => setHeaders((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
const updateHeader = (i: number, field: 'key' | 'value', val: string) =>
|
||||||
|
setHeaders((prev) => prev.map((h, idx) => (idx === i ? { ...h, [field]: val } : h)));
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
serverName.trim() &&
|
||||||
|
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
|
||||||
|
!installing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||||
|
<Server className="size-4 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogTitle>Add Custom MCP Server</DialogTitle>
|
||||||
|
<DialogDescription>Add a server manually without the catalog.</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Server name */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="custom-name" className="text-xs">
|
||||||
|
Server Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-name"
|
||||||
|
value={serverName}
|
||||||
|
onChange={(e) => setServerName(e.target.value)}
|
||||||
|
placeholder="my-server"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transport toggle */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Transport</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={transportMode === 'stdio' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTransportMode('stdio')}
|
||||||
|
>
|
||||||
|
Stdio (npm)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={transportMode === 'http' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTransportMode('http')}
|
||||||
|
>
|
||||||
|
HTTP / SSE
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stdio fields */}
|
||||||
|
{transportMode === 'stdio' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="custom-npm" className="text-xs">
|
||||||
|
npm Package
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-npm"
|
||||||
|
value={npmPackage}
|
||||||
|
onChange={(e) => setNpmPackage(e.target.value)}
|
||||||
|
placeholder="@example/mcp-server"
|
||||||
|
className="h-8 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="custom-version" className="text-xs">
|
||||||
|
Version (optional)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-version"
|
||||||
|
value={npmVersion}
|
||||||
|
onChange={(e) => setNpmVersion(e.target.value)}
|
||||||
|
placeholder="latest"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HTTP fields */}
|
||||||
|
{transportMode === 'http' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="custom-url" className="text-xs">
|
||||||
|
Server URL
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-url"
|
||||||
|
value={httpUrl}
|
||||||
|
onChange={(e) => setHttpUrl(e.target.value)}
|
||||||
|
placeholder="https://api.example.com/mcp"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Transport Type</Label>
|
||||||
|
<Select
|
||||||
|
value={httpTransport}
|
||||||
|
onValueChange={(v) => setHttpTransport(v as HttpTransport)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{HTTP_TRANSPORT_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headers */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">Headers</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addHeader}
|
||||||
|
className="h-6 px-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 size-3" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{headers.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{headers.map((header, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={header.key}
|
||||||
|
onChange={(e) => updateHeader(i, 'key', e.target.value)}
|
||||||
|
className="h-7 w-32 text-xs"
|
||||||
|
placeholder="Header-Name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={header.value}
|
||||||
|
onChange={(e) => updateHeader(i, 'value', e.target.value)}
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
placeholder="value"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 text-red-400 hover:bg-red-500/10"
|
||||||
|
onClick={() => removeHeader(i)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scope */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Scope</Label>
|
||||||
|
<Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCOPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environment variables */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">Environment Variables</Label>
|
||||||
|
<Button variant="ghost" size="sm" onClick={addEnvVar} className="h-6 px-1.5 text-xs">
|
||||||
|
<Plus className="mr-1 size-3" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{envVars.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{envVars.map((entry, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={entry.key}
|
||||||
|
onChange={(e) => updateEnvVar(i, 'key', e.target.value)}
|
||||||
|
className="h-7 w-40 font-mono text-xs"
|
||||||
|
placeholder="ENV_VAR_NAME"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={entry.value}
|
||||||
|
onChange={(e) => updateEnvVar(i, 'value', e.target.value)}
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
placeholder="value"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 text-red-400 hover:bg-red-500/10"
|
||||||
|
onClick={() => removeEnvVar(i)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={!canSubmit} onClick={() => void handleInstall()}>
|
||||||
|
{installing ? 'Installing...' : 'Install'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -6,15 +6,26 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { Lock, Server, Wrench } from 'lucide-react';
|
import { api } from '@renderer/api';
|
||||||
|
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
|
||||||
|
import { Cloud, Clock, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- lucide naming migration, alias is stable
|
||||||
|
import { Github as GithubIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { SourceBadge } from '../common/SourceBadge';
|
|
||||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
import type { McpCatalogItem } from '@shared/types/extensions';
|
import type { McpCatalogItem } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
/** Ribbon colors by source */
|
||||||
|
const RIBBON_STYLES: Record<string, string> = {
|
||||||
|
official: 'bg-blue-500/90 text-white',
|
||||||
|
glama: 'bg-zinc-600/90 text-zinc-200',
|
||||||
|
};
|
||||||
|
|
||||||
interface McpServerCardProps {
|
interface McpServerCardProps {
|
||||||
server: McpCatalogItem;
|
server: McpCatalogItem;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
|
@ -30,31 +41,50 @@ export const McpServerCard = ({
|
||||||
const installMcpServer = useStore((s) => s.installMcpServer);
|
const installMcpServer = useStore((s) => s.installMcpServer);
|
||||||
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
||||||
const installError = useStore((s) => s.installErrors[server.id]);
|
const installError = useStore((s) => s.installErrors[server.id]);
|
||||||
|
const stars = useStore((s) =>
|
||||||
|
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
||||||
|
);
|
||||||
const canAutoInstall = !!server.installSpec;
|
const canAutoInstall = !!server.installSpec;
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
|
const hasIcon = !!server.iconUrl && !imgError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => onClick(server.id)}
|
onClick={() => onClick(server.id)}
|
||||||
className={`flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick(server.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`relative flex w-full cursor-pointer flex-col gap-2 overflow-hidden rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
|
||||||
isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
|
isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header: icon + name + source */}
|
{/* Source ribbon (top-left corner) */}
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="pointer-events-none absolute -left-[1px] -top-[1px] size-16 overflow-hidden">
|
||||||
{/* Server icon or fallback */}
|
<div
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
className={`absolute left-[-18px] top-[8px] w-[80px] rotate-[-45deg] text-center text-[9px] font-semibold leading-[18px] shadow-sm ${RIBBON_STYLES[server.source] ?? RIBBON_STYLES.glama}`}
|
||||||
{server.iconUrl && !imgError ? (
|
>
|
||||||
|
{server.source === 'official' ? 'Official' : 'Glama'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header: icon + name */}
|
||||||
|
<div className={`flex items-start gap-2.5 ${hasIcon ? 'pl-5' : 'pl-7'}`}>
|
||||||
|
{/* Server icon (only when available) */}
|
||||||
|
{hasIcon && (
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||||
<img
|
<img
|
||||||
src={server.iconUrl}
|
src={server.iconUrl!}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-7 rounded object-contain"
|
className="size-7 rounded object-contain"
|
||||||
onError={() => setImgError(true)}
|
onError={() => setImgError(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<Server className="size-4 text-text-muted" />
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h3 className="truncate text-sm font-semibold text-text">{server.name}</h3>
|
<h3 className="truncate text-sm font-semibold text-text">{server.name}</h3>
|
||||||
|
|
@ -67,7 +97,6 @@ export const McpServerCard = ({
|
||||||
Installed
|
Installed
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<SourceBadge source={server.source} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -78,11 +107,17 @@ export const McpServerCard = ({
|
||||||
|
|
||||||
{/* Footer indicators + install button */}
|
{/* Footer indicators + install button */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
|
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-muted">
|
||||||
{server.tools.length > 0 && (
|
{server.tools.length > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-surface-raised px-1.5 py-0.5 ring-1 ring-border">
|
<span className="inline-flex items-center gap-1 rounded-full bg-surface-raised px-1.5 py-0.5 ring-1 ring-border">
|
||||||
<Wrench className="size-3" />
|
<Wrench className="size-3" />
|
||||||
{server.tools.length}
|
{server.tools.length} {server.tools.length === 1 ? 'tool' : 'tools'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{server.envVars.length > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<KeyRound className="size-3" />
|
||||||
|
{server.envVars.length} {server.envVars.length === 1 ? 'env' : 'envs'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{server.requiresAuth && (
|
{server.requiresAuth && (
|
||||||
|
|
@ -91,7 +126,76 @@ export const McpServerCard = ({
|
||||||
Auth
|
Auth
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{server.license && <span>{server.license}</span>}
|
{server.version && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Tag className="size-3" />
|
||||||
|
{server.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{server.updatedAt && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Clock className="size-3" />
|
||||||
|
{formatRelativeTime(server.updatedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{server.author && <span className="truncate">by {server.author}</span>}
|
||||||
|
{server.hostingType === 'remote' && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Cloud className="size-3" />
|
||||||
|
Remote
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{server.hostingType === 'local' && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Monitor className="size-3" />
|
||||||
|
Local
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{server.hostingType === 'both' && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Globe className="size-3" />
|
||||||
|
Both
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* External links + stars */}
|
||||||
|
{server.repositoryUrl && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1.5 text-text-muted transition-colors hover:text-text"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void api.openExternal(server.repositoryUrl!);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GithubIcon className="size-3.5" />
|
||||||
|
{stars != null && (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<Star className="size-3 fill-amber-400 text-amber-400" />
|
||||||
|
{formatCompactNumber(stars)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Repository</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{server.websiteUrl && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center text-text-muted transition-colors hover:text-text"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void api.openExternal(server.websiteUrl!);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Globe className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Website</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canAutoInstall && (
|
{canAutoInstall && (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
|
|
@ -114,6 +218,6 @@ export const McpServerCard = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Uses Radix UI Kit for all form elements.
|
* Uses Radix UI Kit for all form elements.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { ExternalLink, Lock, Plus, Server, Trash2, Wrench } from 'lucide-react';
|
import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { SourceBadge } from '../common/SourceBadge';
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
|
|
@ -60,12 +60,16 @@ export const McpServerDetailDialog = ({
|
||||||
const installMcpServer = useStore((s) => s.installMcpServer);
|
const installMcpServer = useStore((s) => s.installMcpServer);
|
||||||
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
||||||
const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined));
|
const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined));
|
||||||
|
const stars = useStore((s) =>
|
||||||
|
server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
||||||
|
);
|
||||||
|
|
||||||
const [scope, setScope] = useState<Scope>('user');
|
const [scope, setScope] = useState<Scope>('user');
|
||||||
const [serverName, setServerName] = useState('');
|
const [serverName, setServerName] = useState('');
|
||||||
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
||||||
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
|
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Initialize form when server changes
|
// Initialize form when server changes
|
||||||
const [lastServerId, setLastServerId] = useState<string | null>(null);
|
const [lastServerId, setLastServerId] = useState<string | null>(null);
|
||||||
|
|
@ -75,12 +79,69 @@ export const McpServerDetailDialog = ({
|
||||||
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
|
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
|
||||||
setHeaders([]);
|
setHeaders([]);
|
||||||
setImgError(false);
|
setImgError(false);
|
||||||
|
setAutoFilledFields(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-fill env values from saved API keys
|
||||||
|
useEffect(() => {
|
||||||
|
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
|
||||||
|
|
||||||
|
const envVarNames = server.envVars.map((e) => e.name);
|
||||||
|
void api.apiKeys.lookup(envVarNames).then(
|
||||||
|
(results) => {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
const filled = new Set<string>();
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const r of results) {
|
||||||
|
values[r.envVarName] = r.value;
|
||||||
|
filled.add(r.envVarName);
|
||||||
|
}
|
||||||
|
setEnvValues((prev) => ({ ...prev, ...values }));
|
||||||
|
setAutoFilledFields(filled);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Silently fail — auto-fill is supplementary
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [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 <></>;
|
if (!server) return <></>;
|
||||||
|
|
||||||
const canAutoInstall = !!server.installSpec;
|
const canAutoInstall = !!server.installSpec;
|
||||||
const isHttp = server.installSpec?.type === 'http';
|
const isHttp = server.installSpec?.type === 'http';
|
||||||
|
const hasIcon = !!server.iconUrl && !imgError;
|
||||||
|
|
||||||
const handleInstall = () => {
|
const handleInstall = () => {
|
||||||
installMcpServer({
|
installMcpServer({
|
||||||
|
|
@ -113,19 +174,17 @@ export const McpServerDetailDialog = ({
|
||||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Server icon */}
|
{/* Server icon (only when available) */}
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
{hasIcon && (
|
||||||
{server.iconUrl && !imgError ? (
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||||
<img
|
<img
|
||||||
src={server.iconUrl}
|
src={server.iconUrl!}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-8 rounded object-contain"
|
className="size-8 rounded object-contain"
|
||||||
onError={() => setImgError(true)}
|
onError={() => setImgError(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<Server className="size-5 text-text-muted" />
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -154,6 +213,15 @@ export const McpServerDetailDialog = ({
|
||||||
<span className="text-text-muted">Source</span>
|
<span className="text-text-muted">Source</span>
|
||||||
<p className="capitalize text-text">{server.source}</p>
|
<p className="capitalize text-text">{server.source}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{stars != null && (
|
||||||
|
<div>
|
||||||
|
<span className="text-text-muted">GitHub Stars</span>
|
||||||
|
<p className="flex items-center gap-1 text-text">
|
||||||
|
<Star className="size-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
{stars.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{server.version && (
|
{server.version && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-muted">Version</span>
|
<span className="text-text-muted">Version</span>
|
||||||
|
|
@ -176,6 +244,30 @@ export const McpServerDetailDialog = ({
|
||||||
: 'Manual setup required'}
|
: 'Manual setup required'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{server.author && (
|
||||||
|
<div>
|
||||||
|
<span className="text-text-muted">Author</span>
|
||||||
|
<p className="text-text">{server.author}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{server.hostingType && (
|
||||||
|
<div>
|
||||||
|
<span className="text-text-muted">Hosting</span>
|
||||||
|
<p className="capitalize text-text">{server.hostingType}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{server.publishedAt && (
|
||||||
|
<div>
|
||||||
|
<span className="text-text-muted">Published</span>
|
||||||
|
<p className="text-text">{new Date(server.publishedAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{server.updatedAt && (
|
||||||
|
<div>
|
||||||
|
<span className="text-text-muted">Updated</span>
|
||||||
|
<p className="text-text">{new Date(server.updatedAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auth indicator */}
|
{/* Auth indicator */}
|
||||||
|
|
@ -243,6 +335,9 @@ export const McpServerDetailDialog = ({
|
||||||
className="h-7 flex-1 text-xs"
|
className="h-7 flex-1 text-xs"
|
||||||
placeholder={env.description ?? env.name}
|
placeholder={env.description ?? env.name}
|
||||||
/>
|
/>
|
||||||
|
{autoFilledFields.has(env.name) && envValues[env.name] && (
|
||||||
|
<span className="shrink-0 text-[10px] text-emerald-400">Auto-filled</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -357,6 +452,16 @@ export const McpServerDetailDialog = ({
|
||||||
Glama
|
Glama
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{server.websiteUrl && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0 text-sm text-blue-400"
|
||||||
|
onClick={() => void api.openExternal(server.websiteUrl!)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-1 size-3.5" />
|
||||||
|
Website
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export const McpServersPanel = ({
|
||||||
const browseError = useStore((s) => s.mcpBrowseError);
|
const browseError = useStore((s) => s.mcpBrowseError);
|
||||||
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
const mcpBrowse = useStore((s) => s.mcpBrowse);
|
||||||
const installedServers = useStore((s) => s.mcpInstalledServers);
|
const installedServers = useStore((s) => s.mcpInstalledServers);
|
||||||
|
const fetchMcpGitHubStars = useStore((s) => s.fetchMcpGitHubStars);
|
||||||
|
|
||||||
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
|
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
|
||||||
const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false);
|
const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false);
|
||||||
|
|
@ -84,6 +85,14 @@ export const McpServersPanel = ({
|
||||||
}
|
}
|
||||||
}, [browseCatalog.length, browseLoading, mcpBrowse]);
|
}, [browseCatalog.length, browseLoading, mcpBrowse]);
|
||||||
|
|
||||||
|
// Fetch GitHub stars after catalog loads (fire-and-forget)
|
||||||
|
useEffect(() => {
|
||||||
|
const urls = browseCatalog.map((s) => s.repositoryUrl).filter((u): u is string => !!u);
|
||||||
|
if (urls.length > 0) {
|
||||||
|
fetchMcpGitHubStars(urls);
|
||||||
|
}
|
||||||
|
}, [browseCatalog, fetchMcpGitHubStars]);
|
||||||
|
|
||||||
// Decide which list to show: search results or browse
|
// Decide which list to show: search results or browse
|
||||||
const isSearching = mcpSearchQuery.trim().length > 0;
|
const isSearching = mcpSearchQuery.trim().length > 0;
|
||||||
const rawServers = isSearching ? mcpSearchResults : browseCatalog;
|
const rawServers = isSearching ? mcpSearchResults : browseCatalog;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export const CapabilityChips = ({
|
||||||
}, [plugins]);
|
}, [plugins]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-2">
|
||||||
{ALL_CAPABILITIES.map((cap) => {
|
{ALL_CAPABILITIES.map((cap) => {
|
||||||
const count = capabilityCounts.get(cap) ?? 0;
|
const count = capabilityCounts.get(cap) ?? 0;
|
||||||
if (count === 0) return null;
|
if (count === 0) return null;
|
||||||
|
|
@ -45,14 +45,23 @@ export const CapabilityChips = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onToggle(cap)}
|
onClick={() => onToggle(cap)}
|
||||||
className={`h-7 rounded-full px-2.5 text-xs font-medium ${
|
aria-pressed={isActive}
|
||||||
|
className={`h-8 rounded-full border px-3 text-xs font-medium transition-all ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/40 hover:bg-purple-500/30'
|
? 'border-purple-500/40 bg-purple-500/15 text-purple-300 shadow-sm'
|
||||||
: 'text-text-muted hover:text-text-secondary'
|
: 'hover:bg-surface-raised/60 border-border bg-transparent text-text-secondary hover:border-border-emphasis hover:text-text'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getCapabilityLabel(cap)}
|
<span>{getCapabilityLabel(cap)}</span>
|
||||||
<span className="ml-1 text-text-muted">({count})</span>
|
<span
|
||||||
|
className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
||||||
|
isActive
|
||||||
|
? 'bg-surface-raised text-text-secondary'
|
||||||
|
: 'bg-surface-raised/70 text-text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export const CategoryChips = ({
|
||||||
if (categoryCounts.length === 0) return <></>;
|
if (categoryCounts.length === 0) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-2">
|
||||||
{categoryCounts.map(([category, count]) => {
|
{categoryCounts.map(([category, count]) => {
|
||||||
const isActive = selected.includes(category);
|
const isActive = selected.includes(category);
|
||||||
return (
|
return (
|
||||||
|
|
@ -42,14 +42,23 @@ export const CategoryChips = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onToggle(category)}
|
onClick={() => onToggle(category)}
|
||||||
className={`h-7 rounded-full px-2.5 text-xs font-medium ${
|
aria-pressed={isActive}
|
||||||
|
className={`h-8 rounded-full border px-3 text-xs font-medium transition-all ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/40 hover:bg-blue-500/30'
|
? 'border-blue-500/40 bg-blue-500/15 text-blue-300 shadow-sm'
|
||||||
: 'text-text-muted hover:text-text-secondary'
|
: 'hover:bg-surface-raised/60 border-border bg-transparent text-text-secondary hover:border-border-emphasis hover:text-text'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{category}
|
<span>{category}</span>
|
||||||
<span className="ml-1 text-text-muted">({count})</span>
|
<span
|
||||||
|
className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
||||||
|
isActive
|
||||||
|
? 'bg-surface-raised text-text-secondary'
|
||||||
|
: 'bg-surface-raised/70 text-text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
import { Tag } from 'lucide-react';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { InstallCountBadge } from '../common/InstallCountBadge';
|
import { InstallCountBadge } from '../common/InstallCountBadge';
|
||||||
|
|
@ -39,49 +40,57 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem
|
||||||
onClick(plugin.pluginId);
|
onClick(plugin.pluginId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`flex w-full cursor-pointer flex-col gap-2 rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
|
className={`hover:bg-surface-raised/45 flex w-full cursor-pointer flex-col gap-3 rounded-xl border bg-transparent p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
|
||||||
plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
|
plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/35' : 'border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header: name + installed badge */}
|
{/* Header: name + status/meta */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h3 className="text-sm font-semibold text-text">{plugin.name}</h3>
|
<div className="min-w-0 space-y-1">
|
||||||
{plugin.isInstalled && (
|
<h3 className="truncate text-sm font-semibold text-text">{plugin.name}</h3>
|
||||||
<Badge
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
<Badge variant="secondary" className="text-[11px]">
|
||||||
variant="outline"
|
{category}
|
||||||
>
|
</Badge>
|
||||||
Installed
|
{capabilities.map((cap) => (
|
||||||
</Badge>
|
<Badge
|
||||||
)}
|
key={cap}
|
||||||
|
variant="outline"
|
||||||
|
className="bg-surface-raised/60 border-border text-[11px] text-text-secondary"
|
||||||
|
>
|
||||||
|
{getCapabilityLabel(cap)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<InstallCountBadge count={plugin.installCount} />
|
||||||
|
{plugin.isInstalled && (
|
||||||
|
<Badge
|
||||||
|
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Installed
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="line-clamp-2 text-xs text-text-secondary">{plugin.description}</p>
|
<p className="line-clamp-3 min-h-[3.75rem] text-xs leading-5 text-text-secondary">
|
||||||
|
{plugin.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Category + Capabilities */}
|
{/* Footer: author + version + install button */}
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{category}
|
|
||||||
</Badge>
|
|
||||||
{capabilities.map((cap) => (
|
|
||||||
<Badge
|
|
||||||
key={cap}
|
|
||||||
variant="outline"
|
|
||||||
className="border-purple-500/30 bg-purple-500/10 text-xs text-purple-400"
|
|
||||||
>
|
|
||||||
{getCapabilityLabel(cap)}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer: author, install count, install button */}
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
|
||||||
<span className="truncate text-xs text-text-muted">
|
<span className="truncate">{plugin.author?.name ?? 'Unknown author'}</span>
|
||||||
{plugin.author?.name ?? 'Unknown author'}
|
{plugin.version && (
|
||||||
</span>
|
<span className="inline-flex shrink-0 items-center gap-1">
|
||||||
<InstallCountBadge count={plugin.installCount} />
|
<Tag className="size-3" />
|
||||||
|
{plugin.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
import { ExternalLink, Loader2 } from 'lucide-react';
|
import { ExternalLink, Loader2, Mail } from 'lucide-react';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { InstallCountBadge } from '../common/InstallCountBadge';
|
import { InstallCountBadge } from '../common/InstallCountBadge';
|
||||||
|
|
@ -108,6 +108,12 @@ export const PluginDetailDialog = ({
|
||||||
<span className="text-text-muted">Category</span>
|
<span className="text-text-muted">Category</span>
|
||||||
<p className="capitalize text-text">{category}</p>
|
<p className="capitalize text-text">{category}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{plugin.version && (
|
||||||
|
<div>
|
||||||
|
<span className="text-text-muted">Version</span>
|
||||||
|
<p className="text-text">{plugin.version}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-muted">Capabilities</span>
|
<span className="text-text-muted">Capabilities</span>
|
||||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||||
|
|
@ -157,17 +163,29 @@ export const PluginDetailDialog = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Homepage link */}
|
{/* Links */}
|
||||||
{plugin.homepage && (
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
{plugin.homepage && (
|
||||||
variant="link"
|
<Button
|
||||||
className="h-auto justify-start p-0 text-sm text-blue-400"
|
variant="link"
|
||||||
onClick={() => void api.openExternal(plugin.homepage!)}
|
className="h-auto justify-start p-0 text-sm text-blue-400"
|
||||||
>
|
onClick={() => void api.openExternal(plugin.homepage!)}
|
||||||
<ExternalLink className="mr-1 size-3.5" />
|
>
|
||||||
Homepage
|
<ExternalLink className="mr-1 size-3.5" />
|
||||||
</Button>
|
Homepage
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
{plugin.author?.email && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto justify-start p-0 text-sm text-blue-400"
|
||||||
|
onClick={() => void api.openExternal(`mailto:${plugin.author!.email}`)}
|
||||||
|
>
|
||||||
|
<Mail className="mr-1 size-3.5" />
|
||||||
|
Contact
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* README */}
|
{/* README */}
|
||||||
<div className="mt-2 max-h-80 overflow-y-auto rounded-md border border-border bg-surface-raised p-4">
|
<div className="mt-2 max-h-80 overflow-y-auto rounded-md border border-border bg-surface-raised p-4">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
|
@ -16,7 +17,7 @@ import {
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
||||||
import { Puzzle, Search } from 'lucide-react';
|
import { Filter, Puzzle, Search } from 'lucide-react';
|
||||||
|
|
||||||
import { SearchInput } from '../common/SearchInput';
|
import { SearchInput } from '../common/SearchInput';
|
||||||
|
|
||||||
|
|
@ -95,7 +96,7 @@ function selectFilteredPlugins(
|
||||||
result = [...result].sort((a, b) => {
|
result = [...result].sort((a, b) => {
|
||||||
switch (sort.field) {
|
switch (sort.field) {
|
||||||
case 'popularity':
|
case 'popularity':
|
||||||
return (b.installCount - a.installCount) * direction;
|
return (a.installCount - b.installCount) * direction;
|
||||||
case 'name':
|
case 'name':
|
||||||
return a.name.localeCompare(b.name) * direction;
|
return a.name.localeCompare(b.name) * direction;
|
||||||
case 'category':
|
case 'category':
|
||||||
|
|
@ -137,11 +138,29 @@ export const PluginsPanel = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortValue = `${pluginSort.field}:${pluginSort.order}`;
|
const sortValue = `${pluginSort.field}:${pluginSort.order}`;
|
||||||
|
const activeFilterCount =
|
||||||
|
pluginFilters.categories.length +
|
||||||
|
pluginFilters.capabilities.length +
|
||||||
|
(pluginFilters.installedOnly ? 1 : 0) +
|
||||||
|
(pluginFilters.search ? 1 : 0);
|
||||||
|
const totalCategoryCount = useMemo(
|
||||||
|
() => new Set(catalog.map((plugin) => normalizeCategory(plugin.category))).size,
|
||||||
|
[catalog]
|
||||||
|
);
|
||||||
|
const totalCapabilityCount = useMemo(() => {
|
||||||
|
const counts = new Set<PluginCapability>();
|
||||||
|
for (const plugin of catalog) {
|
||||||
|
for (const capability of inferCapabilities(plugin)) {
|
||||||
|
counts.add(capability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts.size;
|
||||||
|
}, [catalog]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Search + Sort + Installed only row */}
|
{/* Search + Sort + Installed only row */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
value={pluginFilters.search}
|
value={pluginFilters.search}
|
||||||
|
|
@ -149,76 +168,132 @@ export const PluginsPanel = ({
|
||||||
placeholder="Search plugins..."
|
placeholder="Search plugins..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
value={sortValue}
|
<Select
|
||||||
onValueChange={(v) => {
|
value={sortValue}
|
||||||
const [field, order] = v.split(':') as [PluginSortField, 'asc' | 'desc'];
|
onValueChange={(v) => {
|
||||||
setPluginSort({ field, order });
|
const [field, order] = v.split(':') as [PluginSortField, 'asc' | 'desc'];
|
||||||
}}
|
setPluginSort({ field, order });
|
||||||
>
|
}}
|
||||||
<SelectTrigger className="w-36">
|
>
|
||||||
<SelectValue />
|
<SelectTrigger className="w-full sm:w-40">
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{SORT_OPTIONS.map((opt) => (
|
<SelectContent>
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
{SORT_OPTIONS.map((opt) => (
|
||||||
{opt.label}
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
</SelectItem>
|
{opt.label}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
<div className="flex items-center gap-2">
|
</Select>
|
||||||
<Checkbox
|
<Label
|
||||||
id="installed-only"
|
htmlFor="installed-only"
|
||||||
checked={pluginFilters.installedOnly}
|
className="bg-surface-raised/40 flex h-10 cursor-pointer items-center gap-2 rounded-lg border border-border px-3 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
|
||||||
onCheckedChange={toggleInstalledOnly}
|
>
|
||||||
/>
|
<Checkbox
|
||||||
<Label htmlFor="installed-only" className="whitespace-nowrap text-xs text-text-secondary">
|
id="installed-only"
|
||||||
|
checked={pluginFilters.installedOnly}
|
||||||
|
onCheckedChange={toggleInstalledOnly}
|
||||||
|
/>
|
||||||
Installed only
|
Installed only
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="bg-surface-raised/50 rounded-md border border-border p-3">
|
<div className="bg-surface-raised/20 rounded-xl p-4">
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-muted">
|
<div className="space-y-2">
|
||||||
Categories
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</span>
|
<div className="bg-surface-raised/60 flex size-8 items-center justify-center rounded-lg border border-border">
|
||||||
|
<Filter className="size-4 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-sm font-semibold text-text">Browse by fit</h2>
|
||||||
|
<Badge variant="outline" className="text-[11px] text-text-muted">
|
||||||
|
{activeFilterCount} active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Narrow the catalog by category, capability, or installed state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 text-[11px] text-text-muted">
|
||||||
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
{catalog.length} plugins
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
{totalCategoryCount} categories
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
{totalCapabilityCount} capabilities
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={clearFilters}
|
onClick={clearFilters}
|
||||||
className="h-auto p-0 text-xs text-[var(--color-accent)]"
|
className="justify-start rounded-lg border border-border px-3 text-xs text-text-secondary hover:text-text lg:justify-center"
|
||||||
>
|
>
|
||||||
Clear all
|
Clear all filters
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CategoryChips
|
|
||||||
plugins={catalog}
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
selected={pluginFilters.categories}
|
<section className="space-y-3 rounded-lg border border-border bg-transparent p-3">
|
||||||
onToggle={toggleCategory}
|
<div className="flex items-center justify-between gap-2">
|
||||||
/>
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
|
||||||
<div className="border-b border-border" />
|
Categories
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-muted">
|
</span>
|
||||||
Capabilities
|
<span className="text-[11px] text-text-muted">
|
||||||
</span>
|
{pluginFilters.categories.length} selected
|
||||||
<CapabilityChips
|
</span>
|
||||||
plugins={catalog}
|
</div>
|
||||||
selected={pluginFilters.capabilities}
|
<CategoryChips
|
||||||
onToggle={toggleCapability}
|
plugins={catalog}
|
||||||
/>
|
selected={pluginFilters.categories}
|
||||||
|
onToggle={toggleCategory}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3 rounded-lg border border-border bg-transparent p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
|
||||||
|
Capabilities
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-text-muted">
|
||||||
|
{pluginFilters.capabilities.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CapabilityChips
|
||||||
|
plugins={catalog}
|
||||||
|
selected={pluginFilters.capabilities}
|
||||||
|
onToggle={toggleCapability}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Result count */}
|
{/* Result count */}
|
||||||
{!loading && !error && filtered.length > 0 && (
|
{!loading && !error && filtered.length > 0 && (
|
||||||
<p className="text-xs text-text-muted">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{filtered.length} plugin{filtered.length !== 1 ? 's' : ''}
|
<p className="text-xs text-text-muted">
|
||||||
</p>
|
Showing {filtered.length} of {catalog.length} plugin{catalog.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Results update instantly as you refine filters.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|
|
||||||
|
|
@ -183,10 +183,16 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
const [totalMatches, setTotalMatches] = useState(0);
|
const [totalMatches, setTotalMatches] = useState(0);
|
||||||
const [searchIsPartial, setSearchIsPartial] = useState(false);
|
const [searchIsPartial, setSearchIsPartial] = useState(false);
|
||||||
const [globalSearchEnabled, setGlobalSearchEnabled] = useState(false);
|
const [globalSearchEnabled, setGlobalSearchEnabled] = useState(false);
|
||||||
|
const [browsingProjects, setBrowsingProjects] = useState(false);
|
||||||
const latestSearchRequestRef = useRef(0);
|
const latestSearchRequestRef = useRef(0);
|
||||||
|
|
||||||
// Determine search mode based on whether a project is selected OR global search is enabled
|
// Determine search mode based on whether a project is selected OR global search is enabled
|
||||||
const searchMode: SearchMode = selectedProjectId || globalSearchEnabled ? 'sessions' : 'projects';
|
// browsingProjects overrides back to project selection
|
||||||
|
const searchMode: SearchMode = browsingProjects
|
||||||
|
? 'projects'
|
||||||
|
: selectedProjectId || globalSearchEnabled
|
||||||
|
? 'sessions'
|
||||||
|
: 'projects';
|
||||||
|
|
||||||
// Filter projects for project search mode
|
// Filter projects for project search mode
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
|
|
@ -235,6 +241,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
setTotalMatches(0);
|
setTotalMatches(0);
|
||||||
setSearchIsPartial(false);
|
setSearchIsPartial(false);
|
||||||
setGlobalSearchEnabled(false);
|
setGlobalSearchEnabled(false);
|
||||||
|
setBrowsingProjects(false);
|
||||||
}
|
}
|
||||||
}, [commandPaletteOpen]);
|
}, [commandPaletteOpen]);
|
||||||
|
|
||||||
|
|
@ -291,15 +298,32 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}, [filteredProjects, sessionResults]);
|
}, [filteredProjects, sessionResults]);
|
||||||
|
|
||||||
// Handle project click
|
// Handle project click — select project without closing palette
|
||||||
const handleProjectClick = useCallback(
|
const handleProjectClick = useCallback(
|
||||||
(repo: RepositoryGroup) => {
|
(repo: RepositoryGroup) => {
|
||||||
closeCommandPalette();
|
|
||||||
selectRepository(repo.id);
|
selectRepository(repo.id);
|
||||||
|
setBrowsingProjects(false);
|
||||||
|
setQuery('');
|
||||||
|
setSessionResults([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setTotalMatches(0);
|
||||||
|
setSearchIsPartial(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
},
|
},
|
||||||
[closeCommandPalette, selectRepository]
|
[selectRepository]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle clearing project filter — go back to project browsing
|
||||||
|
const handleClearProject = useCallback(() => {
|
||||||
|
setBrowsingProjects(true);
|
||||||
|
setQuery('');
|
||||||
|
setSessionResults([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setTotalMatches(0);
|
||||||
|
setSearchIsPartial(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle session result click
|
// Handle session result click
|
||||||
const handleSessionResultClick = useCallback(
|
const handleSessionResultClick = useCallback(
|
||||||
(result: SearchResult) => {
|
(result: SearchResult) => {
|
||||||
|
|
@ -438,14 +462,20 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
<span className="text-xs text-text-muted">
|
<span className="text-xs text-text-muted">
|
||||||
{globalSearchEnabled ? 'Search across all projects' : 'Search in project'}
|
{globalSearchEnabled ? 'Search across all projects' : 'Search in project'}
|
||||||
</span>
|
</span>
|
||||||
{!globalSearchEnabled && (
|
{!globalSearchEnabled && selectedProjectId && (
|
||||||
<>
|
<>
|
||||||
<span className="text-text-muted/50 mx-1 text-xs">·</span>
|
<span className="text-text-muted/50 mx-1 text-xs">·</span>
|
||||||
<span className="truncate text-xs text-text-secondary">
|
<button
|
||||||
{repositoryGroups.find((r) =>
|
onClick={handleClearProject}
|
||||||
r.worktrees.some((w) => w.id === selectedProjectId)
|
className="flex items-center gap-1.5 rounded-full bg-surface-raised px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-surface-overlay hover:text-text"
|
||||||
)?.name ?? 'Current project'}
|
>
|
||||||
</span>
|
<span className="max-w-[200px] truncate">
|
||||||
|
{repositoryGroups.find((r) =>
|
||||||
|
r.worktrees.some((w) => w.id === selectedProjectId)
|
||||||
|
)?.name ?? 'Current project'}
|
||||||
|
</span>
|
||||||
|
<X className="size-3 shrink-0" />
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
|
@ -198,6 +198,41 @@ export const GlobalTaskList = ({
|
||||||
const readState = useReadStateSnapshot();
|
const readState = useReadStateSnapshot();
|
||||||
const taskLocalState = useTaskLocalState();
|
const taskLocalState = useTaskLocalState();
|
||||||
|
|
||||||
|
// --- New-task animation tracking (same pattern as ChatHistory) ---
|
||||||
|
const knownTaskIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const isInitialTaskLoadRef = useRef(true);
|
||||||
|
|
||||||
|
const newTaskIds = useMemo(() => {
|
||||||
|
if (!globalTasksInitialized || globalTasks.length === 0) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// First load: seed all known IDs, no animations
|
||||||
|
if (isInitialTaskLoadRef.current) {
|
||||||
|
isInitialTaskLoadRef.current = false;
|
||||||
|
for (const t of globalTasks) {
|
||||||
|
knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`);
|
||||||
|
}
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent updates: detect truly new tasks
|
||||||
|
const newIds = new Set<string>();
|
||||||
|
for (const t of globalTasks) {
|
||||||
|
const key = `${t.teamName}:${t.id}`;
|
||||||
|
if (!knownTaskIdsRef.current.has(key)) {
|
||||||
|
newIds.add(key);
|
||||||
|
knownTaskIdsRef.current.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newIds;
|
||||||
|
}, [globalTasks, globalTasksInitialized]);
|
||||||
|
|
||||||
|
const isNewTask = useCallback(
|
||||||
|
(task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`),
|
||||||
|
[newTaskIds]
|
||||||
|
);
|
||||||
|
|
||||||
// Local project filter (independent from sessions tab)
|
// Local project filter (independent from sessions tab)
|
||||||
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(null);
|
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -481,6 +516,7 @@ export const GlobalTaskList = ({
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
showTeamName
|
showTeamName
|
||||||
|
isNew={isNewTask(task)}
|
||||||
renamingKey={renamingTaskKey}
|
renamingKey={renamingTaskKey}
|
||||||
onRenameComplete={handleRenameComplete}
|
onRenameComplete={handleRenameComplete}
|
||||||
onRenameCancel={handleRenameCancel}
|
onRenameCancel={handleRenameCancel}
|
||||||
|
|
@ -578,6 +614,7 @@ export const GlobalTaskList = ({
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
showTeamName
|
showTeamName
|
||||||
|
isNew={isNewTask(task)}
|
||||||
renamingKey={renamingTaskKey}
|
renamingKey={renamingTaskKey}
|
||||||
onRenameComplete={handleRenameComplete}
|
onRenameComplete={handleRenameComplete}
|
||||||
onRenameCancel={handleRenameCancel}
|
onRenameCancel={handleRenameCancel}
|
||||||
|
|
@ -643,6 +680,7 @@ export const GlobalTaskList = ({
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
hideTeamName
|
hideTeamName
|
||||||
|
isNew={isNewTask(task)}
|
||||||
renamingKey={renamingTaskKey}
|
renamingKey={renamingTaskKey}
|
||||||
onRenameComplete={handleRenameComplete}
|
onRenameComplete={handleRenameComplete}
|
||||||
onRenameCancel={handleRenameCancel}
|
onRenameCancel={handleRenameCancel}
|
||||||
|
|
@ -708,6 +746,7 @@ export const GlobalTaskList = ({
|
||||||
>
|
>
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
|
isNew={isNewTask(task)}
|
||||||
renamingKey={renamingTaskKey}
|
renamingKey={renamingTaskKey}
|
||||||
onRenameComplete={handleRenameComplete}
|
onRenameComplete={handleRenameComplete}
|
||||||
onRenameCancel={handleRenameCancel}
|
onRenameCancel={handleRenameCancel}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ interface SidebarTaskItemProps {
|
||||||
task: GlobalTask;
|
task: GlobalTask;
|
||||||
hideTeamName?: boolean;
|
hideTeamName?: boolean;
|
||||||
showTeamName?: boolean;
|
showTeamName?: boolean;
|
||||||
|
/** When true, the item plays an enter animation */
|
||||||
|
isNew?: boolean;
|
||||||
/** The composite key "teamName:taskId" of the task being renamed, or null */
|
/** The composite key "teamName:taskId" of the task being renamed, or null */
|
||||||
renamingKey?: string | null;
|
renamingKey?: string | null;
|
||||||
/** Called when rename is completed with Enter or blur */
|
/** Called when rename is completed with Enter or blur */
|
||||||
|
|
@ -72,6 +74,7 @@ export const SidebarTaskItem = ({
|
||||||
task,
|
task,
|
||||||
hideTeamName,
|
hideTeamName,
|
||||||
showTeamName,
|
showTeamName,
|
||||||
|
isNew,
|
||||||
renamingKey,
|
renamingKey,
|
||||||
onRenameComplete,
|
onRenameComplete,
|
||||||
onRenameCancel,
|
onRenameCancel,
|
||||||
|
|
@ -144,10 +147,12 @@ export const SidebarTaskItem = ({
|
||||||
|
|
||||||
const showTeamRow = showTeamName && !hideTeamName;
|
const showTeamRow = showTeamName && !hideTeamName;
|
||||||
|
|
||||||
|
const enterClass = isNew ? 'task-item-enter-animate' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''}`}
|
className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''} ${enterClass}`}
|
||||||
style={{ borderColor: 'var(--color-border)' }}
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isRenaming) {
|
if (!isRenaming) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
||||||
import { useAttachments } from '@renderer/hooks/useAttachments';
|
import { useAttachments } from '@renderer/hooks/useAttachments';
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
|
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||||
|
|
@ -183,6 +184,8 @@ export const SendMessageDialog = ({
|
||||||
[members, colorMap]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
||||||
|
|
||||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||||
|
|
||||||
const trimmedText = textDraft.value.trim();
|
const trimmedText = textDraft.value.trim();
|
||||||
|
|
@ -437,6 +440,7 @@ export const SendMessageDialog = ({
|
||||||
value={textDraft.value}
|
value={textDraft.value}
|
||||||
onValueChange={textDraft.setValue}
|
onValueChange={textDraft.setValue}
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
|
teamSuggestions={teamMentionSuggestions}
|
||||||
chips={chipDraft.chips}
|
chips={chipDraft.chips}
|
||||||
onChipRemove={handleChipRemove}
|
onChipRemove={handleChipRemove}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useComposerDraft } from '@renderer/hooks/useComposerDraft';
|
import { useComposerDraft } from '@renderer/hooks/useComposerDraft';
|
||||||
|
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
|
|
@ -131,6 +132,8 @@ export const MessageComposer = ({
|
||||||
[members, colorMap]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
||||||
|
|
||||||
const trimmed = draft.text.trim();
|
const trimmed = draft.text.trim();
|
||||||
|
|
||||||
const selectedMember = members.find((m) => m.name === recipient);
|
const selectedMember = members.find((m) => m.name === recipient);
|
||||||
|
|
@ -751,6 +754,7 @@ export const MessageComposer = ({
|
||||||
value={draft.text}
|
value={draft.text}
|
||||||
onValueChange={draft.setText}
|
onValueChange={draft.setText}
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
|
teamSuggestions={teamMentionSuggestions}
|
||||||
chips={draft.chips}
|
chips={draft.chips}
|
||||||
onChipRemove={draft.removeChip}
|
onChipRemove={draft.removeChip}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
|
import { Loader2, UsersRound } from 'lucide-react';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
||||||
|
|
@ -68,33 +69,53 @@ export const MentionSuggestionList = ({
|
||||||
if (suggestions.length === 0) {
|
if (suggestions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||||
{hasFileSearch ? 'No matching members or files' : 'No matching members'}
|
{hasFileSearch ? 'No matching members, teams, or files' : 'No matching members'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if we need grouped sections
|
// Categorize suggestions
|
||||||
const hasMemberItems = suggestions.some((s) => s.type !== 'file');
|
type Section = 'member' | 'team' | 'file';
|
||||||
const hasFileItems = suggestions.some((s) => s.type === 'file');
|
const getSuggestionSection = (s: MentionSuggestion): Section => {
|
||||||
const showSections = hasMemberItems && hasFileItems;
|
if (s.type === 'file') return 'file';
|
||||||
|
if (s.type === 'team') return 'team';
|
||||||
|
return 'member';
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionLabel: Record<Section, string> = {
|
||||||
|
member: 'Members',
|
||||||
|
team: 'Teams',
|
||||||
|
file: 'Files',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine which sections are present
|
||||||
|
const presentSections = new Set(suggestions.map(getSuggestionSection));
|
||||||
|
const showSections = presentSections.size > 1;
|
||||||
|
|
||||||
// Build items with section headers inserted
|
// Build items with section headers inserted
|
||||||
const items: React.JSX.Element[] = [];
|
const items: React.JSX.Element[] = [];
|
||||||
let currentSection: 'member' | 'file' | null = null;
|
let currentSection: Section | null = null;
|
||||||
let optionIndex = 0;
|
let optionIndex = 0;
|
||||||
|
|
||||||
for (const s of suggestions) {
|
for (const s of suggestions) {
|
||||||
const isFile = s.type === 'file';
|
const section = getSuggestionSection(s);
|
||||||
const section = isFile ? 'file' : 'member';
|
const isFile = section === 'file';
|
||||||
|
const isTeam = section === 'team';
|
||||||
|
|
||||||
// Insert section header on transition
|
// Insert section header on transition
|
||||||
if (showSections && section !== currentSection) {
|
if (showSections && section !== currentSection) {
|
||||||
items.push(<SectionHeader key={`section-${section}`} label={isFile ? 'Files' : 'Members'} />);
|
items.push(<SectionHeader key={`section-${section}`} label={sectionLabel[section]} />);
|
||||||
currentSection = section;
|
currentSection = section;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSelected = optionIndex === selectedIndex;
|
const isSelected = optionIndex === selectedIndex;
|
||||||
const colorSet = !isFile && s.color ? getTeamColorSet(s.color) : null;
|
const colorSet = isFile
|
||||||
|
? null
|
||||||
|
: s.color
|
||||||
|
? getTeamColorSet(s.color)
|
||||||
|
: isTeam
|
||||||
|
? nameColorSet(s.name)
|
||||||
|
: null;
|
||||||
const idx = optionIndex;
|
const idx = optionIndex;
|
||||||
optionIndex++;
|
optionIndex++;
|
||||||
|
|
||||||
|
|
@ -116,6 +137,12 @@ export const MentionSuggestionList = ({
|
||||||
>
|
>
|
||||||
{isFile ? (
|
{isFile ? (
|
||||||
<FileIcon fileName={s.name} className="size-3.5" />
|
<FileIcon fileName={s.name} className="size-3.5" />
|
||||||
|
) : isTeam ? (
|
||||||
|
<UsersRound
|
||||||
|
size={13}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: colorSet?.text ?? 'var(--color-text-muted)' }}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className="inline-block size-2.5 shrink-0 rounded-full"
|
className="inline-block size-2.5 shrink-0 rounded-full"
|
||||||
|
|
@ -128,6 +155,13 @@ export const MentionSuggestionList = ({
|
||||||
>
|
>
|
||||||
<HighlightedName name={s.name} query={query} />
|
<HighlightedName name={s.name} query={query} />
|
||||||
</span>
|
</span>
|
||||||
|
{isTeam && s.isOnline !== undefined ? (
|
||||||
|
<span
|
||||||
|
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: s.isOnline ? '#22c55e' : '#71717a' }}
|
||||||
|
title={s.isOnline ? 'Online' : 'Offline'}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{s.subtitle ? (
|
{s.subtitle ? (
|
||||||
<span className="truncate text-[var(--color-text-muted)]">{s.subtitle}</span>
|
<span className="truncate text-[var(--color-text-muted)]">{s.subtitle}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useMentionDetection } from '@renderer/hooks/useMentionDetection';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { chipToken } from '@renderer/types/inlineChip';
|
import { chipToken } from '@renderer/types/inlineChip';
|
||||||
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import {
|
import {
|
||||||
createChipFromSelection,
|
createChipFromSelection,
|
||||||
findChipBoundary,
|
findChipBoundary,
|
||||||
|
|
@ -207,6 +208,8 @@ interface MentionableTextareaProps extends Omit<
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
/** Called when a file chip is created via @ selection. Parent must add chip to state. */
|
/** Called when a file chip is created via @ selection. Parent must add chip to state. */
|
||||||
onFileChipInsert?: (chip: InlineChip) => void;
|
onFileChipInsert?: (chip: InlineChip) => void;
|
||||||
|
/** Team suggestions for cross-team @mentions */
|
||||||
|
teamSuggestions?: MentionSuggestion[];
|
||||||
/** Called when Enter (without Shift) is pressed. */
|
/** Called when Enter (without Shift) is pressed. */
|
||||||
onModEnter?: () => void;
|
onModEnter?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -226,6 +229,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
onChipRemove,
|
onChipRemove,
|
||||||
projectPath,
|
projectPath,
|
||||||
onFileChipInsert,
|
onFileChipInsert,
|
||||||
|
teamSuggestions = [],
|
||||||
onModEnter,
|
onModEnter,
|
||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
|
|
@ -271,7 +275,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
textareaRef: internalRef,
|
textareaRef: internalRef,
|
||||||
enableTriggerAlways: enableFiles,
|
enableTriggerAlways: enableFiles || teamSuggestions.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- File suggestions ---
|
// --- File suggestions ---
|
||||||
|
|
@ -281,12 +285,23 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
isOpen && enableFiles
|
isOpen && enableFiles
|
||||||
);
|
);
|
||||||
|
|
||||||
// Merged suggestion list: members first, then files
|
// --- Team suggestions filtered by query ---
|
||||||
|
const filteredTeamSuggestions = React.useMemo(() => {
|
||||||
|
if (teamSuggestions.length === 0 || !isOpen) return [];
|
||||||
|
if (!query) return teamSuggestions;
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
return teamSuggestions.filter((t) => t.name.toLowerCase().includes(lower));
|
||||||
|
}, [teamSuggestions, isOpen, query]);
|
||||||
|
|
||||||
|
// Merged suggestion list: members → online teams → offline teams → files
|
||||||
const allSuggestions = React.useMemo(() => {
|
const allSuggestions = React.useMemo(() => {
|
||||||
if (!enableFiles) return memberSuggestions;
|
const onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline);
|
||||||
if (fileSuggestions.length === 0) return memberSuggestions;
|
const offlineTeams = filteredTeamSuggestions.filter((t) => !t.isOnline);
|
||||||
return [...memberSuggestions, ...fileSuggestions];
|
const merged = [...memberSuggestions, ...onlineTeams, ...offlineTeams];
|
||||||
}, [enableFiles, memberSuggestions, fileSuggestions]);
|
if (!enableFiles) return merged;
|
||||||
|
if (fileSuggestions.length === 0) return merged;
|
||||||
|
return [...merged, ...fileSuggestions];
|
||||||
|
}, [memberSuggestions, filteredTeamSuggestions, enableFiles, fileSuggestions]);
|
||||||
|
|
||||||
// When files are enabled, manage our own selectedIndex for the merged list
|
// When files are enabled, manage our own selectedIndex for the merged list
|
||||||
const [mergedIndex, setMergedIndex] = React.useState(0);
|
const [mergedIndex, setMergedIndex] = React.useState(0);
|
||||||
|
|
@ -296,9 +311,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
setMergedIndex(0);
|
setMergedIndex(0);
|
||||||
}, [query, allSuggestions.length]);
|
}, [query, allSuggestions.length]);
|
||||||
|
|
||||||
// Effective index: use merged when files enabled, hook's index otherwise
|
// Use merged index when we have extra suggestion types (teams or files)
|
||||||
const effectiveIndex = enableFiles ? mergedIndex : selectedIndex;
|
const hasMergedSuggestions = enableFiles || teamSuggestions.length > 0;
|
||||||
const effectiveSuggestions = enableFiles ? allSuggestions : memberSuggestions;
|
|
||||||
|
// Effective index: use merged when extra types present, hook's index otherwise
|
||||||
|
const effectiveIndex = hasMergedSuggestions ? mergedIndex : selectedIndex;
|
||||||
|
const effectiveSuggestions = hasMergedSuggestions ? allSuggestions : memberSuggestions;
|
||||||
|
|
||||||
// --- File selection handler ---
|
// --- File selection handler ---
|
||||||
const handleFileSelect = React.useCallback(
|
const handleFileSelect = React.useCallback(
|
||||||
|
|
@ -386,11 +404,17 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
// --- Overlay activation ---
|
// --- Overlay activation ---
|
||||||
const hasOverlay = suggestions.length > 0 || chips.length > 0;
|
const hasOverlay = suggestions.length > 0 || teamSuggestions.length > 0 || chips.length > 0;
|
||||||
|
|
||||||
|
// Combine member + team suggestions for overlay parsing
|
||||||
|
const allOverlaySuggestions = React.useMemo(
|
||||||
|
() => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions),
|
||||||
|
[suggestions, teamSuggestions]
|
||||||
|
);
|
||||||
|
|
||||||
const segments = React.useMemo(
|
const segments = React.useMemo(
|
||||||
() => (hasOverlay ? parseSegments(value, suggestions, chips) : []),
|
() => (hasOverlay ? parseSegments(value, allOverlaySuggestions, chips) : []),
|
||||||
[hasOverlay, value, suggestions, chips]
|
[hasOverlay, value, allOverlaySuggestions, chips]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer
|
// Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer
|
||||||
|
|
@ -512,7 +536,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
// When mention dropdown is open, let mention handler consume Enter/Arrow keys first
|
// When mention dropdown is open, let mention handler consume Enter/Arrow keys first
|
||||||
if (isOpen && effectiveSuggestions.length > 0) {
|
if (isOpen && effectiveSuggestions.length > 0) {
|
||||||
if (enableFiles) {
|
if (hasMergedSuggestions) {
|
||||||
fileMentionHandleKeyDown(e);
|
fileMentionHandleKeyDown(e);
|
||||||
} else {
|
} else {
|
||||||
mentionHandleKeyDown(e);
|
mentionHandleKeyDown(e);
|
||||||
|
|
@ -527,7 +551,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
}
|
}
|
||||||
handleChipKeyDown(e);
|
handleChipKeyDown(e);
|
||||||
if (!e.defaultPrevented && !isOpen) {
|
if (!e.defaultPrevented && !isOpen) {
|
||||||
if (enableFiles) {
|
if (hasMergedSuggestions) {
|
||||||
fileMentionHandleKeyDown(e);
|
fileMentionHandleKeyDown(e);
|
||||||
} else {
|
} else {
|
||||||
mentionHandleKeyDown(e);
|
mentionHandleKeyDown(e);
|
||||||
|
|
@ -537,7 +561,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
[
|
[
|
||||||
onModEnter,
|
onModEnter,
|
||||||
handleChipKeyDown,
|
handleChipKeyDown,
|
||||||
enableFiles,
|
hasMergedSuggestions,
|
||||||
fileMentionHandleKeyDown,
|
fileMentionHandleKeyDown,
|
||||||
mentionHandleKeyDown,
|
mentionHandleKeyDown,
|
||||||
isOpen,
|
isOpen,
|
||||||
|
|
@ -645,7 +669,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
}, [advanceTip]);
|
}, [advanceTip]);
|
||||||
|
|
||||||
const resolvedHintText = hintText ?? rotatingTips[tipIndex];
|
const resolvedHintText = hintText ?? rotatingTips[tipIndex];
|
||||||
const showHintRow = showHint && (suggestions.length > 0 || enableFiles);
|
const showHintRow =
|
||||||
|
showHint && (suggestions.length > 0 || enableFiles || teamSuggestions.length > 0);
|
||||||
const showFooter = showHintRow || footerRight;
|
const showFooter = showHintRow || footerRight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -673,10 +698,13 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
if (seg.type === 'chip') {
|
if (seg.type === 'chip') {
|
||||||
return <CodeChipBadge key={idx} chip={seg.chip} tokenText={seg.value} />;
|
return <CodeChipBadge key={idx} chip={seg.chip} tokenText={seg.value} />;
|
||||||
}
|
}
|
||||||
// mention
|
// mention (member or team)
|
||||||
|
const isTeamMention = seg.suggestion.type === 'team';
|
||||||
const colorSet = seg.suggestion.color
|
const colorSet = seg.suggestion.color
|
||||||
? getTeamColorSet(seg.suggestion.color)
|
? getTeamColorSet(seg.suggestion.color)
|
||||||
: null;
|
: isTeamMention
|
||||||
|
? nameColorSet(seg.suggestion.name, isLight)
|
||||||
|
: null;
|
||||||
const bg = colorSet ? getThemedBadge(colorSet, isLight) : DEFAULT_MENTION_BG;
|
const bg = colorSet ? getThemedBadge(colorSet, isLight) : DEFAULT_MENTION_BG;
|
||||||
const fg = colorSet?.text ?? DEFAULT_MENTION_TEXT;
|
const fg = colorSet?.text ?? DEFAULT_MENTION_TEXT;
|
||||||
return (
|
return (
|
||||||
|
|
@ -751,7 +779,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
<MentionSuggestionList
|
<MentionSuggestionList
|
||||||
suggestions={effectiveSuggestions}
|
suggestions={effectiveSuggestions}
|
||||||
selectedIndex={effectiveIndex}
|
selectedIndex={effectiveIndex}
|
||||||
onSelect={enableFiles ? handleMergedSelect : selectSuggestion}
|
onSelect={hasMergedSuggestions ? handleMergedSelect : selectSuggestion}
|
||||||
query={query}
|
query={query}
|
||||||
hasFileSearch={enableFiles}
|
hasFileSearch={enableFiles}
|
||||||
filesLoading={enableFiles && filesLoading}
|
filesLoading={enableFiles && filesLoading}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import type {
|
||||||
PluginSortField,
|
PluginSortField,
|
||||||
} from '@shared/types/extensions';
|
} from '@shared/types/extensions';
|
||||||
|
|
||||||
export type ExtensionsSubTab = 'plugins' | 'mcp-servers';
|
export type ExtensionsSubTab = 'plugins' | 'mcp-servers' | 'api-keys';
|
||||||
|
|
||||||
interface PluginSortState {
|
interface PluginSortState {
|
||||||
field: PluginSortField;
|
field: PluginSortField;
|
||||||
|
|
|
||||||
77
src/renderer/hooks/useTeamSuggestions.ts
Normal file
77
src/renderer/hooks/useTeamSuggestions.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* Hook for providing team @-mention suggestions.
|
||||||
|
*
|
||||||
|
* Returns non-deleted teams (excluding the current one) as MentionSuggestion[]
|
||||||
|
* with online/offline status. Uses the alive list API to determine status.
|
||||||
|
*
|
||||||
|
* The returned list is unfiltered — query filtering is handled downstream
|
||||||
|
* by useMentionDetection inside MentionableTextarea.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
|
||||||
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
||||||
|
export interface UseTeamSuggestionsResult {
|
||||||
|
suggestions: MentionSuggestion[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns team MentionSuggestion[] sorted by online status (online first).
|
||||||
|
*
|
||||||
|
* @param currentTeamName - The current team name to exclude from suggestions
|
||||||
|
*/
|
||||||
|
export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult {
|
||||||
|
const teams = useStore((s) => s.teams);
|
||||||
|
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchAlive = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await api.teams.aliveList();
|
||||||
|
setAliveTeams(new Set(list));
|
||||||
|
} catch {
|
||||||
|
// best-effort — treat all as offline on error
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch on mount and when teams list changes
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchAlive();
|
||||||
|
}, [fetchAlive, teams]);
|
||||||
|
|
||||||
|
// Build suggestion list sorted: online first, then offline
|
||||||
|
const suggestions = useMemo<MentionSuggestion[]>(() => {
|
||||||
|
const nonDeleted = teams.filter((t) => !t.deletedAt && t.teamName !== currentTeamName);
|
||||||
|
|
||||||
|
const result: MentionSuggestion[] = nonDeleted.map((t) => {
|
||||||
|
const isOnline = aliveTeams.has(t.teamName);
|
||||||
|
return {
|
||||||
|
id: `team:${t.teamName}`,
|
||||||
|
name: t.displayName || t.teamName,
|
||||||
|
subtitle: isOnline ? 'online' : 'offline',
|
||||||
|
color: t.color,
|
||||||
|
type: 'team' as const,
|
||||||
|
isOnline,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort: online teams first
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (a.isOnline && !b.isOnline) return -1;
|
||||||
|
if (!a.isOnline && b.isOnline) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [teams, currentTeamName, aliveTeams]);
|
||||||
|
|
||||||
|
return { suggestions, loading };
|
||||||
|
}
|
||||||
|
|
@ -709,6 +709,24 @@ body {
|
||||||
animation: chat-message-enter 350ms ease-out both;
|
animation: chat-message-enter 350ms ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes task-item-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 80px;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-enter-animate {
|
||||||
|
animation: task-item-enter 280ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes thought-expand {
|
@keyframes thought-expand {
|
||||||
from {
|
from {
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,15 @@ import { api } from '@renderer/api';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type {
|
import type {
|
||||||
|
ApiKeyEntry,
|
||||||
|
ApiKeySaveRequest,
|
||||||
|
ApiKeyStorageStatus,
|
||||||
EnrichedPlugin,
|
EnrichedPlugin,
|
||||||
ExtensionOperationState,
|
ExtensionOperationState,
|
||||||
InstallScope,
|
InstallScope,
|
||||||
InstalledMcpEntry,
|
InstalledMcpEntry,
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
|
McpCustomInstallRequest,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
PluginInstallRequest,
|
PluginInstallRequest,
|
||||||
} from '@shared/types/extensions';
|
} from '@shared/types/extensions';
|
||||||
|
|
@ -43,6 +47,16 @@ export interface ExtensionsSlice {
|
||||||
mcpInstallProgress: Record<string, ExtensionOperationState>;
|
mcpInstallProgress: Record<string, ExtensionOperationState>;
|
||||||
installErrors: Record<string, string>; // keyed by pluginId or registryId
|
installErrors: Record<string, string>; // keyed by pluginId or registryId
|
||||||
|
|
||||||
|
// ── API Keys ──
|
||||||
|
apiKeys: ApiKeyEntry[];
|
||||||
|
apiKeysLoading: boolean;
|
||||||
|
apiKeysError: string | null;
|
||||||
|
apiKeySaving: boolean;
|
||||||
|
apiKeyStorageStatus: ApiKeyStorageStatus | null;
|
||||||
|
|
||||||
|
// ── GitHub Stars (supplementary) ──
|
||||||
|
mcpGitHubStars: Record<string, number>;
|
||||||
|
|
||||||
// ── Read actions ──
|
// ── Read actions ──
|
||||||
fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise<void>;
|
fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise<void>;
|
||||||
fetchPluginReadme: (pluginId: string) => void;
|
fetchPluginReadme: (pluginId: string) => void;
|
||||||
|
|
@ -53,6 +67,7 @@ export interface ExtensionsSlice {
|
||||||
installPlugin: (request: PluginInstallRequest) => Promise<void>;
|
installPlugin: (request: PluginInstallRequest) => Promise<void>;
|
||||||
uninstallPlugin: (pluginId: string, scope?: InstallScope, projectPath?: string) => Promise<void>;
|
uninstallPlugin: (pluginId: string, scope?: InstallScope, projectPath?: string) => Promise<void>;
|
||||||
installMcpServer: (request: McpInstallRequest) => Promise<void>;
|
installMcpServer: (request: McpInstallRequest) => Promise<void>;
|
||||||
|
installCustomMcpServer: (request: McpCustomInstallRequest) => Promise<void>;
|
||||||
uninstallMcpServer: (
|
uninstallMcpServer: (
|
||||||
registryId: string,
|
registryId: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -60,8 +75,17 @@ export interface ExtensionsSlice {
|
||||||
projectPath?: string
|
projectPath?: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
|
// ── API Keys actions ──
|
||||||
|
fetchApiKeys: () => Promise<void>;
|
||||||
|
fetchApiKeyStorageStatus: () => Promise<void>;
|
||||||
|
saveApiKey: (request: ApiKeySaveRequest) => Promise<void>;
|
||||||
|
deleteApiKey: (id: string) => Promise<void>;
|
||||||
|
|
||||||
// ── Tab opener ──
|
// ── Tab opener ──
|
||||||
openExtensionsTab: () => void;
|
openExtensionsTab: () => void;
|
||||||
|
|
||||||
|
// ── GitHub Stars ──
|
||||||
|
fetchMcpGitHubStars: (repositoryUrls: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -96,6 +120,14 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
mcpInstallProgress: {},
|
mcpInstallProgress: {},
|
||||||
installErrors: {},
|
installErrors: {},
|
||||||
|
|
||||||
|
apiKeys: [],
|
||||||
|
apiKeysLoading: false,
|
||||||
|
apiKeysError: null,
|
||||||
|
apiKeySaving: false,
|
||||||
|
apiKeyStorageStatus: null,
|
||||||
|
|
||||||
|
mcpGitHubStars: {},
|
||||||
|
|
||||||
// ── Plugin catalog fetch ──
|
// ── Plugin catalog fetch ──
|
||||||
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
|
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
|
@ -163,11 +195,23 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
set({ mcpBrowseLoading: true, mcpBrowseError: null });
|
set({ mcpBrowseLoading: true, mcpBrowseError: null });
|
||||||
try {
|
try {
|
||||||
const result = await api.mcpRegistry.browse(cursor);
|
const result = await api.mcpRegistry.browse(cursor);
|
||||||
set((prev) => ({
|
set((prev) => {
|
||||||
mcpBrowseCatalog: cursor ? [...prev.mcpBrowseCatalog, ...result.servers] : result.servers,
|
if (!cursor) {
|
||||||
mcpBrowseNextCursor: result.nextCursor,
|
return {
|
||||||
mcpBrowseLoading: false,
|
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) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
mcpBrowseLoading: false,
|
mcpBrowseLoading: false,
|
||||||
|
|
@ -324,6 +368,53 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── MCP custom install ──
|
||||||
|
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
|
||||||
|
if (!api.mcpRegistry) {
|
||||||
|
const progressKey = `custom:${request.serverName}`;
|
||||||
|
set((prev) => ({
|
||||||
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||||
|
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressKey = `custom:${request.serverName}`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
set((prev) => ({
|
||||||
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Refresh installed list
|
||||||
|
void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
set((prev) => ({
|
||||||
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' },
|
||||||
|
}));
|
||||||
|
}, SUCCESS_DISPLAY_MS);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Install failed';
|
||||||
|
set((prev) => ({
|
||||||
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||||
|
installErrors: { ...prev.installErrors, [progressKey]: message },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ── MCP uninstall ──
|
// ── MCP uninstall ──
|
||||||
uninstallMcpServer: async (
|
uninstallMcpServer: async (
|
||||||
registryId: string,
|
registryId: string,
|
||||||
|
|
@ -376,6 +467,67 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── 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 api.apiKeys.list();
|
||||||
|
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);
|
||||||
|
set((prev) => ({
|
||||||
|
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
set({
|
||||||
|
apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ── Tab opener ──
|
// ── Tab opener ──
|
||||||
openExtensionsTab: () => {
|
openExtensionsTab: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|
@ -391,4 +543,21 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
label: 'Extensions',
|
label: 'Extensions',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ export interface MentionSuggestion {
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
/** Color name from TeamColorSet palette */
|
/** Color name from TeamColorSet palette */
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Suggestion type — 'member' (default) or 'file' */
|
/** Suggestion type — 'member' (default), 'team', or 'file' */
|
||||||
type?: 'member' | 'file';
|
type?: 'member' | 'team' | 'file';
|
||||||
|
/** Whether the team is currently online (team suggestions only) */
|
||||||
|
isOnline?: boolean;
|
||||||
/** Absolute file path (file suggestions only) */
|
/** Absolute file path (file suggestions only) */
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
/** Relative display path (file suggestions only) */
|
/** Relative display path (file suggestions only) */
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,17 @@ export function formatDuration(ms: number): string {
|
||||||
const remainingSeconds = Math.round(seconds % 60);
|
const remainingSeconds = Math.round(seconds % 60);
|
||||||
return `${minutes}m ${remainingSeconds}s`;
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a number in compact notation for UI display.
|
||||||
|
* 42 → "42", 1200 → "1.2k", 45300 → "45.3k", 120000 → "120k", 1500000 → "1.5M"
|
||||||
|
*/
|
||||||
|
export function formatCompactNumber(n: number): string {
|
||||||
|
if (n < 1_000) return String(n);
|
||||||
|
if (n < 1_000_000) {
|
||||||
|
const k = n / 1_000;
|
||||||
|
return k < 10 ? `${k.toFixed(1)}k` : `${Math.round(k)}k`;
|
||||||
|
}
|
||||||
|
const m = n / 1_000_000;
|
||||||
|
return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* Shared utility for converting @memberName mentions in plain text
|
* Shared utility for converting @memberName and @teamName mentions in plain text
|
||||||
* to markdown links with mention:// protocol.
|
* to markdown links with mention:// and team:// protocols.
|
||||||
*
|
*
|
||||||
* Used by UserChatGroup, TeammateMessageItem, ActivityItem, TaskCommentsSection.
|
* Used by UserChatGroup, TeammateMessageItem, ActivityItem, TaskCommentsSection.
|
||||||
* MarkdownViewer already handles rendering mention:// links as colored badges.
|
* MarkdownViewer already handles rendering mention:// and team:// links as colored badges.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -34,3 +34,51 @@ export function linkifyMentionsInMarkdown(
|
||||||
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
|
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert `@teamName` in plain text to markdown links with team:// protocol.
|
||||||
|
* Greedy match: longer names are tried first to avoid partial matches.
|
||||||
|
*
|
||||||
|
* @param text - The plain text to process
|
||||||
|
* @param teamNames - Set or array of known team names
|
||||||
|
* @returns Text with @teamName replaced by markdown links
|
||||||
|
*/
|
||||||
|
export function linkifyTeamMentionsInMarkdown(
|
||||||
|
text: string,
|
||||||
|
teamNames: ReadonlySet<string> | readonly string[]
|
||||||
|
): string {
|
||||||
|
const names = Array.isArray(teamNames) ? teamNames : [...teamNames];
|
||||||
|
if (names.length === 0) return text;
|
||||||
|
|
||||||
|
// Sort by name length descending for greedy matching
|
||||||
|
const sorted = [...names].sort((a, b) => b.length - a.length);
|
||||||
|
const escaped = sorted.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
|
// eslint-disable-next-line no-useless-escape -- escaped chars needed for regex character class
|
||||||
|
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`, 'gi');
|
||||||
|
|
||||||
|
return text.replace(pattern, (_match, prefix: string, name: string) => {
|
||||||
|
const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
||||||
|
return `${prefix}[@${canonical}](team://${encodeURIComponent(canonical)})`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply both member and team linkification. Team names are matched first to avoid
|
||||||
|
* team names being captured as member mentions when they share similar names.
|
||||||
|
*
|
||||||
|
* @param text - The plain text to process
|
||||||
|
* @param memberColorMap - Map of member name → color key
|
||||||
|
* @param teamNames - Known team names
|
||||||
|
* @returns Text with both @member and @team mentions replaced by markdown links
|
||||||
|
*/
|
||||||
|
export function linkifyAllMentionsInMarkdown(
|
||||||
|
text: string,
|
||||||
|
memberColorMap: Map<string, string>,
|
||||||
|
teamNames: ReadonlySet<string> | readonly string[] = []
|
||||||
|
): string {
|
||||||
|
// Apply team linkification first (team names tend to be longer / more specific)
|
||||||
|
let result = linkifyTeamMentionsInMarkdown(text, teamNames);
|
||||||
|
// Then member linkification on the remaining text
|
||||||
|
result = linkifyMentionsInMarkdown(result, memberColorMap);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
|
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
|
||||||
import type { CliInstallerAPI } from './cliInstaller';
|
import type { CliInstallerAPI } from './cliInstaller';
|
||||||
import type { EditorAPI, ProjectAPI } from './editor';
|
import type { EditorAPI, ProjectAPI } from './editor';
|
||||||
import type { McpCatalogAPI, PluginCatalogAPI } from './extensions';
|
import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI } from './extensions';
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
DetectedError,
|
DetectedError,
|
||||||
|
|
@ -811,6 +811,9 @@ export interface ElectronAPI {
|
||||||
|
|
||||||
// Extension Store — MCP Registry API (Electron-only, optional)
|
// Extension Store — MCP Registry API (Electron-only, optional)
|
||||||
mcpRegistry?: McpCatalogAPI;
|
mcpRegistry?: McpCatalogAPI;
|
||||||
|
|
||||||
|
// Extension Store — API Keys Management (Electron-only, optional)
|
||||||
|
apiKeys?: ApiKeysAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,21 @@
|
||||||
* Both APIs are OPTIONAL in ElectronAPI (Electron-only V1).
|
* Both APIs are OPTIONAL in ElectronAPI (Electron-only V1).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ApiKeyEntry,
|
||||||
|
ApiKeyLookupResult,
|
||||||
|
ApiKeySaveRequest,
|
||||||
|
ApiKeyStorageStatus,
|
||||||
|
} from './apikey';
|
||||||
import type { InstallScope, OperationResult } from './common';
|
import type { InstallScope, OperationResult } from './common';
|
||||||
import type { EnrichedPlugin, PluginInstallRequest } from './plugin';
|
import type { EnrichedPlugin, PluginInstallRequest } from './plugin';
|
||||||
import type { InstalledMcpEntry, McpCatalogItem, McpInstallRequest, McpSearchResult } from './mcp';
|
import type {
|
||||||
|
InstalledMcpEntry,
|
||||||
|
McpCatalogItem,
|
||||||
|
McpCustomInstallRequest,
|
||||||
|
McpInstallRequest,
|
||||||
|
McpSearchResult,
|
||||||
|
} from './mcp';
|
||||||
|
|
||||||
// ── Plugin API ─────────────────────────────────────────────────────────────
|
// ── Plugin API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -31,5 +43,17 @@ export interface McpCatalogAPI {
|
||||||
getById: (registryId: string) => Promise<McpCatalogItem | null>;
|
getById: (registryId: string) => Promise<McpCatalogItem | null>;
|
||||||
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
|
||||||
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
install: (request: McpInstallRequest) => Promise<OperationResult>;
|
||||||
|
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
|
||||||
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
|
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
|
||||||
|
githubStars: (repositoryUrls: string[]) => Promise<Record<string, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Keys API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ApiKeysAPI {
|
||||||
|
list: () => Promise<ApiKeyEntry[]>;
|
||||||
|
save: (request: ApiKeySaveRequest) => Promise<ApiKeyEntry>;
|
||||||
|
delete: (id: string) => Promise<void>;
|
||||||
|
lookup: (envVarNames: string[]) => Promise<ApiKeyLookupResult[]>;
|
||||||
|
getStorageStatus: () => Promise<ApiKeyStorageStatus>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
src/shared/types/extensions/apikey.ts
Normal file
38
src/shared/types/extensions/apikey.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* API Key management types — stored encrypted, transmitted masked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** API key entry returned from backend (with masked value) */
|
||||||
|
export interface ApiKeyEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
envVarName: string;
|
||||||
|
maskedValue: string;
|
||||||
|
scope: 'user' | 'project';
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request to create or update an API key */
|
||||||
|
export interface ApiKeySaveRequest {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
envVarName: string;
|
||||||
|
value: string;
|
||||||
|
scope: 'user' | 'project';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypted key lookup result (for auto-fill) */
|
||||||
|
export interface ApiKeyLookupResult {
|
||||||
|
envVarName: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage encryption status (for UI display) */
|
||||||
|
export interface ApiKeyStorageStatus {
|
||||||
|
/** How keys are encrypted: OS keychain or local AES-256-GCM */
|
||||||
|
encryptionMethod: 'os-keychain' | 'aes-local';
|
||||||
|
/** Human-readable backend name: "macOS Keychain", "gnome_libsecret", etc. */
|
||||||
|
backend: string;
|
||||||
|
/** Whether the storage file has secure permissions (0o600) */
|
||||||
|
fileSecure: boolean;
|
||||||
|
}
|
||||||
|
|
@ -18,8 +18,10 @@ export { inferCapabilities } from './plugin';
|
||||||
export type {
|
export type {
|
||||||
InstalledMcpEntry,
|
InstalledMcpEntry,
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
|
McpCustomInstallRequest,
|
||||||
McpEnvVarDef,
|
McpEnvVarDef,
|
||||||
McpHeaderDef,
|
McpHeaderDef,
|
||||||
|
McpHostingType,
|
||||||
McpHttpInstallSpec,
|
McpHttpInstallSpec,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
McpInstallSpec,
|
McpInstallSpec,
|
||||||
|
|
@ -28,4 +30,11 @@ export type {
|
||||||
McpToolDef,
|
McpToolDef,
|
||||||
} from './mcp';
|
} from './mcp';
|
||||||
|
|
||||||
export type { McpCatalogAPI, PluginCatalogAPI } from './api';
|
export type {
|
||||||
|
ApiKeyEntry,
|
||||||
|
ApiKeyLookupResult,
|
||||||
|
ApiKeySaveRequest,
|
||||||
|
ApiKeyStorageStatus,
|
||||||
|
} from './apikey';
|
||||||
|
|
||||||
|
export type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI } from './api';
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
// ── Catalog item (normalized from Official Registry / Glama) ───────────────
|
// ── Catalog item (normalized from Official Registry / Glama) ───────────────
|
||||||
|
|
||||||
|
export type McpHostingType = 'local' | 'remote' | 'both';
|
||||||
|
|
||||||
export interface McpCatalogItem {
|
export interface McpCatalogItem {
|
||||||
id: string; // Official: reverse-DNS (e.g. "io.github.upstash/context7"), Glama: "glama:<id>"
|
id: string; // Official: reverse-DNS (e.g. "io.github.upstash/context7"), Glama: "glama:<id>"
|
||||||
name: string; // display name
|
name: string; // display name
|
||||||
|
|
@ -18,6 +20,12 @@ export interface McpCatalogItem {
|
||||||
glamaUrl?: string;
|
glamaUrl?: string;
|
||||||
requiresAuth: boolean; // true if HTTP server has required headers
|
requiresAuth: boolean; // true if HTTP server has required headers
|
||||||
iconUrl?: string; // First icon URL from official registry (icons[0].src)
|
iconUrl?: string; // First icon URL from official registry (icons[0].src)
|
||||||
|
websiteUrl?: string;
|
||||||
|
status?: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
author?: string;
|
||||||
|
hostingType?: McpHostingType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpToolDef {
|
export interface McpToolDef {
|
||||||
|
|
@ -77,6 +85,17 @@ export interface McpInstallRequest {
|
||||||
headers: McpHeaderDef[]; // for HTTP/SSE servers (CLI --header flag)
|
headers: McpHeaderDef[]; // for HTTP/SSE servers (CLI --header flag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Custom install request (bypasses registry, user provides spec) ──────────
|
||||||
|
|
||||||
|
export interface McpCustomInstallRequest {
|
||||||
|
serverName: string;
|
||||||
|
scope: 'local' | 'user' | 'project';
|
||||||
|
projectPath?: string;
|
||||||
|
installSpec: McpInstallSpec; // user provides directly
|
||||||
|
envValues: Record<string, string>;
|
||||||
|
headers: McpHeaderDef[];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Search result wrapper ──────────────────────────────────────────────────
|
// ── Search result wrapper ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface McpSearchResult {
|
export interface McpSearchResult {
|
||||||
|
|
|
||||||
|
|
@ -100,3 +100,23 @@ export function sanitizeMcpServerName(displayName: string): string {
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/[^\w.-]/g, '');
|
.replace(/[^\w.-]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract owner/repo from a GitHub URL. Returns null for non-GitHub URLs.
|
||||||
|
* Handles: https://github.com/owner/repo, https://github.com/owner/repo.git, trailing slashes.
|
||||||
|
*/
|
||||||
|
export function parseGitHubOwnerRepo(url: string): { owner: string; repo: string } | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.hostname !== 'github.com') return null;
|
||||||
|
const parts = parsed.pathname
|
||||||
|
.replace(/^\//, '')
|
||||||
|
.replace(/\.git$/, '')
|
||||||
|
.replace(/\/+$/, '')
|
||||||
|
.split('/');
|
||||||
|
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
|
||||||
|
return { owner: parts[0], repo: parts[1] };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue