* feat(session-folders): drag-to-folder DnD, sort by activity, and UX improvements - Add DraggableSessionRow wrapping each session row so the whole row is draggable; stopPropagation prevents outer group-reorder DnD from firing - Add DroppableFolderWrapper + SessionFolderDndScope (inner DndContext scoped per group) with closestCenter collision detection - DragOverlay matches exact width/height of dragged row so cursor stays aligned - Folder header highlights (ring + primary colour) when a session hovers over it during drag - + button on folder header opens a dropdown: 'New session' / 'New folder' - + button on each folder row creates a session scoped to that folder - Empty folders are no longer auto-deleted (removed .filter(sessionIds.length>0) from addSessionToFolder / removeSessionFromFolder / cleanupSessions) - Sessions inside a folder are sorted by most-recent activity (same compareSessionsByPinnedAndTime logic used everywhere else) - Sort comparator now takes sessionAttentionStates so lastUserMessageAt / lastStatusChangeAt is used when newer than session.time.updated; all sort call-sites and their useMemo/useCallback deps updated accordingly - Remove foldersMap from cleanup effect deps to prevent cascade re-renders when folders change; read current value via getState() instead * fix(session-folders): new session is placed into the correct folder sendMessage() was calling useSessionManagementStore.createSession() directly, bypassing the targetFolderId logic in useSessionStore.createSession. Fix: read targetFolderId from draft at the top of the draft branch in sendMessage, then call addSessionToFolder immediately after the session is created and before the draft is closed. Also propagate targetFolderId through openNewSessionDraft options and NewSessionDraftState type. * feat(session-folders): add sub-folder support (one level deep) - SessionFolder gains optional parentId field for hierarchy - createFolder accepts parentId to create sub-folders - deleteFolder cascades to remove all child sub-folders - SessionFolderItem renders sub-folders before sessions in body; new sub-folder button (RiFolderAddLine) visible at depth 0 only - renderOneFolderItem in SessionSidebar builds the tree recursively; sub-folders are indented via depth prop (ml-3 on root's children) - Persist/hydrate parentId correctly from localStorage * feat(session): add delete confirm dialogs and improve subtitle UX - Add confirmation dialogs before deleting sessions or folders - Show relative time (e.g., '2h ago', '35min ago') for recent sessions - Replace +/- diff numbers with file change count (e.g., '3 files changed') - New folders use default name without forcing rename - Cleaner, less cluttered session list UI * fix(session-folders): skip folder cleanup while sessions are loading Prevents race condition on reload where cleanupSessions() runs before the server returns the full session list, causing folder-session assignments to be incorrectly wiped from localStorage. * feat(mcp): add MCP Config Manager UI - Backend: CRUD lib (mcp.js) + 5 REST routes (GET/POST/PATCH/DELETE /api/config/mcp/:name) - Frontend: Zustand store (useMcpConfigStore), McpSidebar with status dots, McpPage with redesigned UX - Textarea command editor: paste full shell commands, auto-split into args, one-arg-per-line view - Compact env editor: wide value column, show/hide toggle, paste .env format support - Header card: name, type badge, enabled toggle, connect/disconnect button - Navigation: 'mcp' added to sidebar sections in SettingsView - TypeScript: all packages pass type-check clean * fix(mcp): remove constant truthiness lint error in McpPage Replace '(isNewServer || true) &&' with unconditional render — type selector should always be visible so the user can switch between stdio and remote without recreating the server. * fix: add MCP server management to VS Code backend - Implement CRUD operations for MCP servers via bridge API - Support local and remote MCP server configurations with validation - Add VS Code webview endpoints for MCP server management * feat: add project-level MCP server configuration --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
207 lines
5.6 KiB
JavaScript
207 lines
5.6 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import {
|
|
CONFIG_FILE,
|
|
AGENT_SCOPE,
|
|
readConfigFile,
|
|
readConfigLayers,
|
|
getJsonEntrySource,
|
|
getJsonWriteTarget,
|
|
writeConfig,
|
|
} from './shared.js';
|
|
|
|
// ============== MCP CONFIG HELPERS ==============
|
|
|
|
/**
|
|
* Validate MCP server name
|
|
*/
|
|
function validateMcpName(name) {
|
|
if (!name || typeof name !== 'string') {
|
|
throw new Error('MCP server name is required');
|
|
}
|
|
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
|
|
throw new Error('MCP server name must be lowercase alphanumeric with hyphens/underscores');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all MCP server configs from user-level opencode.json
|
|
*/
|
|
function resolveMcpScopeFromPath(layers, sourcePath) {
|
|
if (!sourcePath) return null;
|
|
return sourcePath === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
|
}
|
|
|
|
function ensureProjectMcpConfigPath(workingDirectory) {
|
|
const configDir = path.join(workingDirectory, '.opencode');
|
|
if (!fs.existsSync(configDir)) {
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
}
|
|
return path.join(configDir, 'opencode.json');
|
|
}
|
|
|
|
function listMcpConfigs(workingDirectory) {
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const mcp = layers?.mergedConfig?.mcp || {};
|
|
|
|
return Object.entries(mcp)
|
|
.filter(([, entry]) => entry && typeof entry === 'object' && !Array.isArray(entry))
|
|
.map(([name, entry]) => {
|
|
const source = getJsonEntrySource(layers, 'mcp', name);
|
|
return {
|
|
name,
|
|
...buildMcpEntry(entry),
|
|
scope: resolveMcpScopeFromPath(layers, source.path),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get a single MCP server config by name
|
|
*/
|
|
function getMcpConfig(name, workingDirectory) {
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const entry = layers?.mergedConfig?.mcp?.[name];
|
|
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
const source = getJsonEntrySource(layers, 'mcp', name);
|
|
return {
|
|
name,
|
|
...buildMcpEntry(entry),
|
|
scope: resolveMcpScopeFromPath(layers, source.path),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a new MCP server config entry
|
|
*/
|
|
function createMcpConfig(name, mcpConfig, workingDirectory, scope) {
|
|
validateMcpName(name);
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const source = getJsonEntrySource(layers, 'mcp', name);
|
|
if (source.exists) {
|
|
throw new Error(`MCP server "${name}" already exists`);
|
|
}
|
|
|
|
let targetPath = CONFIG_FILE;
|
|
let config = {};
|
|
|
|
if (scope === AGENT_SCOPE.PROJECT) {
|
|
if (!workingDirectory) {
|
|
throw new Error('Project scope requires working directory');
|
|
}
|
|
targetPath = ensureProjectMcpConfigPath(workingDirectory);
|
|
config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
|
|
} else {
|
|
const jsonTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
|
targetPath = jsonTarget.path || CONFIG_FILE;
|
|
config = jsonTarget.config || {};
|
|
}
|
|
|
|
if (!config.mcp || typeof config.mcp !== 'object' || Array.isArray(config.mcp)) {
|
|
config.mcp = {};
|
|
}
|
|
|
|
const { name: _ignoredName, ...entryData } = mcpConfig;
|
|
config.mcp[name] = buildMcpEntry(entryData);
|
|
|
|
writeConfig(config, targetPath);
|
|
console.log(`Created MCP server config: ${name}`);
|
|
}
|
|
|
|
/**
|
|
* Update an existing MCP server config entry
|
|
*/
|
|
function updateMcpConfig(name, updates, workingDirectory) {
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const source = getJsonEntrySource(layers, 'mcp', name);
|
|
const targetPath = source.path || CONFIG_FILE;
|
|
const config = source.config || (fs.existsSync(targetPath) ? readConfigFile(targetPath) : {});
|
|
|
|
if (!config.mcp || typeof config.mcp !== 'object' || Array.isArray(config.mcp)) {
|
|
config.mcp = {};
|
|
}
|
|
|
|
const existing = config.mcp[name] ?? {};
|
|
const { name: _ignoredName, ...updateData } = updates;
|
|
|
|
config.mcp[name] = buildMcpEntry({ ...existing, ...updateData });
|
|
|
|
writeConfig(config, targetPath);
|
|
console.log(`Updated MCP server config: ${name}`);
|
|
}
|
|
|
|
/**
|
|
* Delete an MCP server config entry
|
|
*/
|
|
function deleteMcpConfig(name, workingDirectory) {
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const source = getJsonEntrySource(layers, 'mcp', name);
|
|
const targetPath = source.path || CONFIG_FILE;
|
|
const config = source.config || (fs.existsSync(targetPath) ? readConfigFile(targetPath) : {});
|
|
|
|
if (!config.mcp || typeof config.mcp !== 'object' || config.mcp[name] === undefined) {
|
|
throw new Error(`MCP server "${name}" not found`);
|
|
}
|
|
|
|
delete config.mcp[name];
|
|
|
|
if (Object.keys(config.mcp).length === 0) {
|
|
delete config.mcp;
|
|
}
|
|
|
|
writeConfig(config, targetPath);
|
|
console.log(`Deleted MCP server config: ${name}`);
|
|
}
|
|
|
|
/**
|
|
* Build a clean MCP entry object, omitting undefined/null values
|
|
*/
|
|
function buildMcpEntry(data) {
|
|
const entry = {};
|
|
|
|
// type is required
|
|
entry.type = data.type === 'remote' ? 'remote' : 'local';
|
|
|
|
if (entry.type === 'local') {
|
|
// command must be a non-empty array of strings
|
|
if (Array.isArray(data.command) && data.command.length > 0) {
|
|
entry.command = data.command.map(String);
|
|
}
|
|
} else {
|
|
// remote: url required
|
|
if (data.url && typeof data.url === 'string') {
|
|
entry.url = data.url.trim();
|
|
}
|
|
}
|
|
|
|
// environment: flat Record<string, string>
|
|
if (data.environment && typeof data.environment === 'object' && !Array.isArray(data.environment)) {
|
|
const cleaned = {};
|
|
for (const [k, v] of Object.entries(data.environment)) {
|
|
if (k && v !== undefined && v !== null) {
|
|
cleaned[k] = String(v);
|
|
}
|
|
}
|
|
if (Object.keys(cleaned).length > 0) {
|
|
entry.environment = cleaned;
|
|
}
|
|
}
|
|
|
|
// enabled defaults to true
|
|
entry.enabled = data.enabled !== false;
|
|
|
|
return entry;
|
|
}
|
|
|
|
export {
|
|
listMcpConfigs,
|
|
getMcpConfig,
|
|
createMcpConfig,
|
|
updateMcpConfig,
|
|
deleteMcpConfig,
|
|
};
|