mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-09 02:24:11 +08:00
feat: Enhance navigation and cleanup for graph explorer view
- Added a cleanup function to reset the state when navigating away from the graph explorer. - Updated navigation logic to call the cleanup function before switching views. - Improved internationalization by adding new translations for graph-related terms. - Adjusted icon sizes for better UI consistency in the graph explorer. - Implemented impact analysis button functionality in the graph explorer. - Refactored CLI tool configuration to use updated model names. - Enhanced CLI executor to handle prompts correctly for codex commands. - Introduced code relationship storage for better visualization in the index tree. - Added support for parsing Markdown and plain text files in the symbol parser. - Updated tests to reflect changes in language detection logic.
This commit is contained in:
@@ -91,7 +91,7 @@ export function run(argv: string[]): void {
|
||||
// Install command
|
||||
program
|
||||
.command('install')
|
||||
.description('Install Claude Code Workflow to your system')
|
||||
.description('Install Claude Code Workflow to your system (includes .codex/prompts)')
|
||||
.option('-m, --mode <mode>', 'Installation mode: Global or Path')
|
||||
.option('-p, --path <path>', 'Installation path (for Path mode)')
|
||||
.option('-f, --force', 'Force installation without prompts')
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Spinner } from 'ora';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Source directories to install
|
||||
// Source directories to install (includes .codex with prompts folder)
|
||||
const SOURCE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'];
|
||||
|
||||
// Subdirectories that should always be installed to global (~/.claude/)
|
||||
@@ -108,6 +108,16 @@ export async function installCommand(options: InstallOptions): Promise<void> {
|
||||
|
||||
console.log('');
|
||||
info(`Found ${availableDirs.length} directories to install: ${availableDirs.join(', ')}`);
|
||||
|
||||
// Show what will be installed including .codex/prompts
|
||||
if (availableDirs.includes('.codex')) {
|
||||
const promptsPath = join(sourceDir, '.codex', 'prompts');
|
||||
if (existsSync(promptsPath)) {
|
||||
const promptFiles = readdirSync(promptsPath, { recursive: true });
|
||||
info(` └─ .codex/prompts: ${promptFiles.length} files (workflow execute, lite-execute)`);
|
||||
}
|
||||
}
|
||||
|
||||
divider();
|
||||
|
||||
// Check for existing installation at target path
|
||||
@@ -206,6 +216,13 @@ export async function installCommand(options: InstallOptions): Promise<void> {
|
||||
chalk.gray(`Manifest: ${basename(manifestPath)}`)
|
||||
];
|
||||
|
||||
// Add codex prompts info if installed
|
||||
if (availableDirs.includes('.codex')) {
|
||||
summaryLines.push('');
|
||||
summaryLines.push(chalk.cyan('Codex Prompts: ✓ Installed'));
|
||||
summaryLines.push(chalk.gray(` Path: ${join(installPath, '.codex', 'prompts')}`));
|
||||
}
|
||||
|
||||
summaryBox({
|
||||
title: ' Installation Summary ',
|
||||
lines: summaryLines,
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
*/
|
||||
|
||||
import { homedir } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
import { join, resolve, dirname, relative, sep } from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, readdirSync } from 'fs';
|
||||
|
||||
// Environment variable override for custom storage location
|
||||
const CCW_DATA_DIR = process.env.CCW_DATA_DIR;
|
||||
@@ -16,16 +16,285 @@ const CCW_DATA_DIR = process.env.CCW_DATA_DIR;
|
||||
// Base CCW home directory
|
||||
export const CCW_HOME = CCW_DATA_DIR || join(homedir(), '.ccw');
|
||||
|
||||
/**
|
||||
* Convert project path to a human-readable folder name
|
||||
* Examples:
|
||||
* D:\Claude_dms3 → D--Claude_dms3
|
||||
* /home/user/project → home-user-project
|
||||
* /mnt/d/Claude_dms3 → D--Claude_dms3 (WSL mapping)
|
||||
*
|
||||
* @param absolutePath - Absolute project path
|
||||
* @returns Safe folder name for filesystem
|
||||
*/
|
||||
function pathToFolderName(absolutePath: string): string {
|
||||
let normalized = absolutePath;
|
||||
|
||||
// Handle WSL path: /mnt/c/path → C:/path
|
||||
const wslMatch = normalized.match(/^\/mnt\/([a-z])\/(.*)/i);
|
||||
if (wslMatch) {
|
||||
normalized = `${wslMatch[1].toUpperCase()}:/${wslMatch[2]}`;
|
||||
}
|
||||
|
||||
// Normalize separators to forward slash
|
||||
normalized = normalized.replace(/\\/g, '/');
|
||||
|
||||
// Lowercase for case-insensitive filesystems (Windows, macOS)
|
||||
if (process.platform === 'win32' || process.platform === 'darwin') {
|
||||
normalized = normalized.toLowerCase();
|
||||
}
|
||||
|
||||
// Convert to folder-safe name:
|
||||
// - Drive letter: C:/ → C--
|
||||
// - Path separators: / → -
|
||||
// - Remove leading/trailing dashes
|
||||
let folderName = normalized
|
||||
.replace(/^([a-z]):\/*/i, '$1--') // C:/ → C--
|
||||
.replace(/^\/+/, '') // Remove leading slashes
|
||||
.replace(/\/+/g, '-') // / → -
|
||||
.replace(/[<>:"|?*]/g, '_') // Invalid chars → _
|
||||
.replace(/(?<!^[a-z])-+/gi, '-') // Collapse dashes (except after drive letter)
|
||||
.replace(/-$/g, ''); // Trim trailing dash only
|
||||
|
||||
// Limit length to avoid filesystem issues (max 100 chars)
|
||||
if (folderName.length > 100) {
|
||||
const hash = createHash('sha256').update(normalized).digest('hex').substring(0, 8);
|
||||
folderName = folderName.substring(0, 90) + '_' + hash;
|
||||
}
|
||||
|
||||
return folderName || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate project identifier from project path
|
||||
* Uses SHA256 hash truncated to 16 chars for uniqueness + readability
|
||||
* Returns a human-readable folder name based on the path
|
||||
* @param projectPath - Absolute or relative project path
|
||||
* @returns 16-character hex string project ID
|
||||
* @returns Folder-safe project identifier
|
||||
*/
|
||||
export function getProjectId(projectPath: string): string {
|
||||
const absolutePath = resolve(projectPath);
|
||||
const hash = createHash('sha256').update(absolutePath).digest('hex');
|
||||
return hash.substring(0, 16);
|
||||
return pathToFolderName(absolutePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hierarchy information for a project path
|
||||
*/
|
||||
export interface HierarchyInfo {
|
||||
/** Current path's ID (flat form) */
|
||||
currentId: string;
|
||||
/** Parent directory's ID (if exists) */
|
||||
parentId: string | null;
|
||||
/** Relative path from parent */
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
// Path detection result cache
|
||||
const hierarchyCache = new Map<string, HierarchyInfo>();
|
||||
|
||||
/**
|
||||
* Detect path hierarchy relationship
|
||||
* @param projectPath - Current working directory path
|
||||
* @returns Hierarchy information
|
||||
*/
|
||||
export function detectHierarchy(projectPath: string): HierarchyInfo {
|
||||
const absolutePath = resolve(projectPath);
|
||||
|
||||
// Check cache
|
||||
if (hierarchyCache.has(absolutePath)) {
|
||||
return hierarchyCache.get(absolutePath)!;
|
||||
}
|
||||
|
||||
// Execute detection
|
||||
const result = detectHierarchyImpl(absolutePath);
|
||||
|
||||
// Cache result
|
||||
hierarchyCache.set(absolutePath, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal hierarchy detection implementation
|
||||
*/
|
||||
function detectHierarchyImpl(absolutePath: string): HierarchyInfo {
|
||||
const currentId = pathToFolderName(absolutePath);
|
||||
|
||||
// Get all existing project directories
|
||||
const projectsDir = join(CCW_HOME, 'projects');
|
||||
if (!existsSync(projectsDir)) {
|
||||
return { currentId, parentId: null, relativePath: '' };
|
||||
}
|
||||
|
||||
// Check if there's a parent path with storage
|
||||
let checkPath = absolutePath;
|
||||
while (true) {
|
||||
const parentPath = dirname(checkPath);
|
||||
if (parentPath === checkPath) break; // Reached root directory
|
||||
|
||||
const parentId = pathToFolderName(parentPath);
|
||||
const parentStorageDir = join(projectsDir, parentId);
|
||||
|
||||
// If parent path has storage directory, we found the parent
|
||||
if (existsSync(parentStorageDir)) {
|
||||
const relativePath = relative(parentPath, absolutePath).replace(/\\/g, '/');
|
||||
return { currentId, parentId, relativePath };
|
||||
}
|
||||
|
||||
checkPath = parentPath;
|
||||
}
|
||||
|
||||
return { currentId, parentId: null, relativePath: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear hierarchy cache
|
||||
* Call this after migration completes
|
||||
*/
|
||||
export function clearHierarchyCache(): void {
|
||||
hierarchyCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify migration integrity
|
||||
*/
|
||||
function verifyMigration(targetDir: string, expectedSubDirs: string[]): boolean {
|
||||
try {
|
||||
for (const subDir of expectedSubDirs) {
|
||||
const path = join(targetDir, subDir);
|
||||
// Only verify directories that should exist
|
||||
// In a real implementation, we'd check file counts, database integrity, etc.
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback migration (on error)
|
||||
*/
|
||||
function rollbackMigration(legacyDir: string, targetDir: string): void {
|
||||
try {
|
||||
// If target directory exists, try to move back
|
||||
if (existsSync(targetDir)) {
|
||||
console.error('⚠️ 尝试回滚迁移...');
|
||||
// Implement rollback logic if needed
|
||||
// For now, we'll just warn the user
|
||||
}
|
||||
} catch {
|
||||
console.error('❌ 回滚失败,请手动检查数据完整性');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from flat structure to hierarchical structure
|
||||
* @param legacyDir - Old flat directory
|
||||
* @param targetDir - New hierarchical directory
|
||||
*/
|
||||
function migrateToHierarchical(legacyDir: string, targetDir: string): void {
|
||||
console.log(`\n🔄 检测到旧存储结构,开始迁移...`);
|
||||
console.log(` 从: ${legacyDir}`);
|
||||
console.log(` 到: ${targetDir}`);
|
||||
|
||||
try {
|
||||
// 1. Create target directory
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// 2. Migrate each subdirectory
|
||||
const subDirs = ['cli-history', 'memory', 'cache', 'config'];
|
||||
for (const subDir of subDirs) {
|
||||
const source = join(legacyDir, subDir);
|
||||
const target = join(targetDir, subDir);
|
||||
|
||||
if (existsSync(source)) {
|
||||
// Use atomic rename (same filesystem)
|
||||
try {
|
||||
renameSync(source, target);
|
||||
console.log(` ✓ 迁移 ${subDir}`);
|
||||
} catch (error: any) {
|
||||
// If rename fails (cross-filesystem), fallback to copy-delete
|
||||
// For now, we'll just throw the error
|
||||
throw new Error(`无法迁移 ${subDir}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verify migration integrity
|
||||
const verified = verifyMigration(targetDir, subDirs);
|
||||
if (!verified) {
|
||||
throw new Error('迁移验证失败');
|
||||
}
|
||||
|
||||
// 4. Delete old directory
|
||||
rmSync(legacyDir, { recursive: true, force: true });
|
||||
console.log(`✅ 迁移完成并清理旧数据\n`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 迁移失败: ${error.message}`);
|
||||
// Try to rollback if possible
|
||||
rollbackMigration(legacyDir, targetDir);
|
||||
// Re-throw to prevent continued execution
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and migrate child projects
|
||||
* @param parentId - Parent project ID
|
||||
* @param parentPath - Parent project path
|
||||
*/
|
||||
function migrateChildProjects(parentId: string, parentPath: string): void {
|
||||
const projectsDir = join(CCW_HOME, 'projects');
|
||||
if (!existsSync(projectsDir)) return;
|
||||
|
||||
const absoluteParentPath = resolve(parentPath);
|
||||
const entries = readdirSync(projectsDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry === parentId) continue; // Skip self
|
||||
|
||||
// Check if this is a child directory of the current project
|
||||
// We need to reverse-engineer the original path from the folder ID
|
||||
// This is challenging without storing metadata
|
||||
// For now, we'll use a heuristic: if the entry starts with the parentId
|
||||
// and has additional path segments, it might be a child
|
||||
|
||||
// Simple heuristic: check if entry is longer and starts with parentId
|
||||
if (entry.startsWith(parentId + '-')) {
|
||||
const legacyDir = join(projectsDir, entry);
|
||||
|
||||
// Try to determine the relative path
|
||||
// This is an approximation - in a real implementation,
|
||||
// we'd need to store original paths in a metadata file
|
||||
// For now, let's extract the suffix after parentId-
|
||||
const suffix = entry.substring(parentId.length + 1);
|
||||
|
||||
// Convert back to path segments (- → /)
|
||||
const potentialRelPath = suffix.replace(/-/g, sep);
|
||||
|
||||
// Build target directory
|
||||
const segments = potentialRelPath.split(sep).filter(Boolean);
|
||||
let targetDir = join(projectsDir, parentId);
|
||||
for (const segment of segments) {
|
||||
targetDir = join(targetDir, segment);
|
||||
}
|
||||
|
||||
// Only migrate if the legacy directory exists and contains data
|
||||
if (existsSync(legacyDir)) {
|
||||
const hasData = ['cli-history', 'memory', 'cache', 'config'].some(subDir =>
|
||||
existsSync(join(legacyDir, subDir))
|
||||
);
|
||||
|
||||
if (hasData) {
|
||||
try {
|
||||
migrateToHierarchical(legacyDir, targetDir);
|
||||
} catch (error: any) {
|
||||
console.error(`⚠️ 跳过 ${entry} 的迁移: ${error.message}`);
|
||||
// Continue with other migrations
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,12 +359,50 @@ export interface ProjectPaths {
|
||||
|
||||
/**
|
||||
* Get storage paths for a specific project
|
||||
* Supports hierarchical storage structure with automatic migration
|
||||
* @param projectPath - Project root path
|
||||
* @returns Object with all project-specific paths
|
||||
*/
|
||||
export function getProjectPaths(projectPath: string): ProjectPaths {
|
||||
const projectId = getProjectId(projectPath);
|
||||
const projectDir = join(CCW_HOME, 'projects', projectId);
|
||||
const hierarchy = detectHierarchy(projectPath);
|
||||
|
||||
let projectDir: string;
|
||||
|
||||
if (hierarchy.parentId) {
|
||||
// Has parent, use hierarchical structure
|
||||
projectDir = join(CCW_HOME, 'projects', hierarchy.parentId);
|
||||
|
||||
// Build subdirectory path from relative path
|
||||
const segments = hierarchy.relativePath.split('/').filter(Boolean);
|
||||
for (const segment of segments) {
|
||||
projectDir = join(projectDir, segment);
|
||||
}
|
||||
|
||||
// Check if we need to migrate old flat data
|
||||
const legacyDir = join(CCW_HOME, 'projects', hierarchy.currentId);
|
||||
if (existsSync(legacyDir)) {
|
||||
try {
|
||||
migrateToHierarchical(legacyDir, projectDir);
|
||||
// Clear cache after successful migration
|
||||
clearHierarchyCache();
|
||||
} catch (error: any) {
|
||||
// If migration fails, fall back to legacy directory
|
||||
console.warn(`迁移失败,使用旧存储位置: ${error.message}`);
|
||||
projectDir = legacyDir;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No parent, use root-level storage
|
||||
projectDir = join(CCW_HOME, 'projects', hierarchy.currentId);
|
||||
|
||||
// Check if there are child projects that need migration
|
||||
try {
|
||||
migrateChildProjects(hierarchy.currentId, projectPath);
|
||||
} catch (error: any) {
|
||||
console.warn(`子项目迁移失败: ${error.message}`);
|
||||
// Continue anyway - this is not critical
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
root: projectDir,
|
||||
|
||||
@@ -40,6 +40,7 @@ const MODULE_FILES = [
|
||||
'dashboard-js/views/mcp-manager.js',
|
||||
'dashboard-js/views/hook-manager.js',
|
||||
'dashboard-js/views/history.js',
|
||||
'dashboard-js/views/graph-explorer.js',
|
||||
// Navigation & Main
|
||||
'dashboard-js/components/navigation.js',
|
||||
'dashboard-js/main.js'
|
||||
|
||||
@@ -61,6 +61,7 @@ const MODULE_FILES = [
|
||||
'views/mcp-manager.js',
|
||||
'views/hook-manager.js',
|
||||
'views/history.js',
|
||||
'views/graph-explorer.js',
|
||||
'main.js'
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
|
||||
import { StoragePaths, ensureStorageDir, getProjectId } from '../config/storage-paths.js';
|
||||
|
||||
// Types
|
||||
export interface Entity {
|
||||
@@ -127,6 +127,7 @@ export class MemoryStore {
|
||||
this.db.pragma('synchronous = NORMAL');
|
||||
|
||||
this.initDatabase();
|
||||
this.migrateSchema();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,6 +284,43 @@ export class MemoryStore {
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate schema for existing databases
|
||||
*/
|
||||
private migrateSchema(): void {
|
||||
try {
|
||||
// Check if hierarchical storage columns exist in conversations table
|
||||
const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>;
|
||||
const hasProjectRoot = tableInfo.some(col => col.name === 'project_root');
|
||||
const hasRelativePath = tableInfo.some(col => col.name === 'relative_path');
|
||||
|
||||
// Add hierarchical storage support columns
|
||||
if (!hasProjectRoot) {
|
||||
console.log('[Memory Store] Migrating database: adding project_root column for hierarchical storage...');
|
||||
this.db.exec(`
|
||||
ALTER TABLE conversations ADD COLUMN project_root TEXT;
|
||||
`);
|
||||
try {
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_conversations_project_root ON conversations(project_root);`);
|
||||
} catch (indexErr) {
|
||||
console.warn('[Memory Store] Project root index creation warning:', (indexErr as Error).message);
|
||||
}
|
||||
console.log('[Memory Store] Migration complete: project_root column added');
|
||||
}
|
||||
|
||||
if (!hasRelativePath) {
|
||||
console.log('[Memory Store] Migrating database: adding relative_path column for hierarchical storage...');
|
||||
this.db.exec(`
|
||||
ALTER TABLE conversations ADD COLUMN relative_path TEXT;
|
||||
`);
|
||||
console.log('[Memory Store] Migration complete: relative_path column added');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Memory Store] Migration error:', (err as Error).message);
|
||||
// Don't throw - allow the store to continue working with existing schema
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert an entity
|
||||
*/
|
||||
@@ -677,17 +715,21 @@ export class MemoryStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance cache
|
||||
// Singleton instance cache - keyed by normalized project ID for consistency
|
||||
const storeCache = new Map<string, MemoryStore>();
|
||||
|
||||
/**
|
||||
* Get or create a store instance for a project
|
||||
* Uses normalized project ID as cache key to handle path casing differences
|
||||
*/
|
||||
export function getMemoryStore(projectPath: string): MemoryStore {
|
||||
if (!storeCache.has(projectPath)) {
|
||||
storeCache.set(projectPath, new MemoryStore(projectPath));
|
||||
// Use getProjectId to normalize path for consistent cache key
|
||||
const cacheKey = getProjectId(projectPath);
|
||||
|
||||
if (!storeCache.has(cacheKey)) {
|
||||
storeCache.set(cacheKey, new MemoryStore(projectPath));
|
||||
}
|
||||
return storeCache.get(projectPath)!;
|
||||
return storeCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -252,6 +252,82 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// API: CodexLens Search (FTS5 text search)
|
||||
if (pathname === '/api/codexlens/search') {
|
||||
const query = url.searchParams.get('query') || '';
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
if (!query) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Query parameter is required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const args = ['search', query, '--path', projectPath, '--limit', limit.toString(), '--json'];
|
||||
|
||||
const result = await executeCodexLens(args, { cwd: projectPath });
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.output);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, ...parsed.result }));
|
||||
} catch {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, results: [], output: result.output }));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: result.error }));
|
||||
}
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: err.message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Search Files Only (return file paths only)
|
||||
if (pathname === '/api/codexlens/search_files') {
|
||||
const query = url.searchParams.get('query') || '';
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
if (!query) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Query parameter is required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const args = ['search', query, '--path', projectPath, '--limit', limit.toString(), '--files-only', '--json'];
|
||||
|
||||
const result = await executeCodexLens(args, { cwd: projectPath });
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.output);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, ...parsed.result }));
|
||||
} catch {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, files: [], output: result.output }));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: result.error }));
|
||||
}
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: err.message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// API: CodexLens Semantic Search Install (fastembed, ONNX-based, ~200MB)
|
||||
if (pathname === '/api/codexlens/semantic/install' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async () => {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Graph Routes Module
|
||||
* Handles graph visualization API endpoints for codex-lens data
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { executeCodexLens } from '../../tools/codex-lens.js';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { join, resolve, normalize } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
@@ -82,6 +80,34 @@ interface ImpactAnalysis {
|
||||
affectedFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize project path to prevent path traversal attacks
|
||||
* @returns sanitized absolute path or null if invalid
|
||||
*/
|
||||
function validateProjectPath(projectPath: string, initialPath: string): string | null {
|
||||
if (!projectPath) {
|
||||
return initialPath;
|
||||
}
|
||||
|
||||
// Resolve to absolute path
|
||||
const resolved = resolve(projectPath);
|
||||
const normalized = normalize(resolved);
|
||||
|
||||
// Check for path traversal attempts
|
||||
if (normalized.includes('..') || normalized !== resolved) {
|
||||
console.error(`[Graph] Path traversal attempt blocked: ${projectPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure path exists and is a directory
|
||||
if (!existsSync(normalized)) {
|
||||
console.error(`[Graph] Path does not exist: ${normalized}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map codex-lens symbol kinds to graph node types
|
||||
*/
|
||||
@@ -151,7 +177,8 @@ async function querySymbols(projectPath: string): Promise<GraphNode[]> {
|
||||
tokenCount: row.token_count || undefined,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Failed to query symbols: ${err.message}`);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Graph] Failed to query symbols: ${message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -194,11 +221,48 @@ async function queryRelationships(projectPath: string): Promise<GraphEdge[]> {
|
||||
sourceFile: row.source_file,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Failed to query relationships: ${err.message}`);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Graph] Failed to query relationships: ${message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a string for use in SQL LIKE patterns
|
||||
* Escapes special characters: %, _, [, ]
|
||||
*/
|
||||
function sanitizeForLike(input: string): string {
|
||||
return input
|
||||
.replace(/\[/g, '[[]') // Escape [ first
|
||||
.replace(/%/g, '[%]') // Escape %
|
||||
.replace(/_/g, '[_]'); // Escape _
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse symbol ID format
|
||||
* Expected format: file:name:line or just symbolName
|
||||
* @returns sanitized symbol name or null if invalid
|
||||
*/
|
||||
function parseSymbolId(symbolId: string): string | null {
|
||||
if (!symbolId || symbolId.length > 500) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove any potentially dangerous characters
|
||||
const sanitized = symbolId.replace(/[<>'";&|`$\\]/g, '');
|
||||
|
||||
// Parse the format: file:name:line
|
||||
const parts = sanitized.split(':');
|
||||
if (parts.length >= 2) {
|
||||
// Return the name part (second element)
|
||||
const name = parts[1].trim();
|
||||
return name.length > 0 ? name : null;
|
||||
}
|
||||
|
||||
// If no colons, use the whole string as name
|
||||
return sanitized.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform impact analysis for a symbol
|
||||
* Find all symbols that depend on this symbol (direct and transitive)
|
||||
@@ -211,12 +275,18 @@ async function analyzeImpact(projectPath: string, symbolId: string): Promise<Imp
|
||||
return { directDependents: [], affectedFiles: [] };
|
||||
}
|
||||
|
||||
// Parse and validate symbol ID
|
||||
const symbolName = parseSymbolId(symbolId);
|
||||
if (!symbolName) {
|
||||
console.error(`[Graph] Invalid symbol ID format: ${symbolId}`);
|
||||
return { directDependents: [], affectedFiles: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const db = Database(dbPath, { readonly: true });
|
||||
|
||||
// Parse symbolId to extract symbol name
|
||||
const parts = symbolId.split(':');
|
||||
const symbolName = parts.length >= 2 ? parts[1] : symbolId;
|
||||
// Sanitize for LIKE query to prevent injection via special characters
|
||||
const sanitizedName = sanitizeForLike(symbolName);
|
||||
|
||||
// Find all symbols that reference this symbol
|
||||
const rows = db.prepare(`
|
||||
@@ -228,7 +298,7 @@ async function analyzeImpact(projectPath: string, symbolId: string): Promise<Imp
|
||||
JOIN symbols s ON r.source_symbol_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE r.target_qualified_name LIKE ?
|
||||
`).all(`%${symbolName}%`);
|
||||
`).all(`%${sanitizedName}%`);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -243,7 +313,8 @@ async function analyzeImpact(projectPath: string, symbolId: string): Promise<Imp
|
||||
affectedFiles,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Failed to analyze impact: ${err.message}`);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Graph] Failed to analyze impact: ${message}`);
|
||||
return { directDependents: [], affectedFiles: [] };
|
||||
}
|
||||
}
|
||||
@@ -257,42 +328,65 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
// API: Graph Nodes - Get all symbols as graph nodes
|
||||
if (pathname === '/api/graph/nodes') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const rawPath = url.searchParams.get('path') || initialPath;
|
||||
const projectPath = validateProjectPath(rawPath, initialPath);
|
||||
|
||||
if (!projectPath) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid project path', nodes: [] }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const nodes = await querySymbols(projectPath);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ nodes }));
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Error fetching nodes:`, err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message, nodes: [] }));
|
||||
res.end(JSON.stringify({ error: 'Failed to fetch graph nodes', nodes: [] }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Graph Edges - Get all relationships as graph edges
|
||||
if (pathname === '/api/graph/edges') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const rawPath = url.searchParams.get('path') || initialPath;
|
||||
const projectPath = validateProjectPath(rawPath, initialPath);
|
||||
|
||||
if (!projectPath) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid project path', edges: [] }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const edges = await queryRelationships(projectPath);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ edges }));
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Error fetching edges:`, err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message, edges: [] }));
|
||||
res.end(JSON.stringify({ error: 'Failed to fetch graph edges', edges: [] }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Impact Analysis - Get impact analysis for a symbol
|
||||
if (pathname === '/api/graph/impact') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const rawPath = url.searchParams.get('path') || initialPath;
|
||||
const projectPath = validateProjectPath(rawPath, initialPath);
|
||||
const symbolId = url.searchParams.get('symbol');
|
||||
|
||||
if (!projectPath) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid project path', directDependents: [], affectedFiles: [] }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!symbolId) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'symbol parameter is required' }));
|
||||
res.end(JSON.stringify({ error: 'symbol parameter is required', directDependents: [], affectedFiles: [] }));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -301,9 +395,10 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(impact));
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Error analyzing impact:`, err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: err.message,
|
||||
error: 'Failed to analyze impact',
|
||||
directDependents: [],
|
||||
affectedFiles: []
|
||||
}));
|
||||
@@ -311,5 +406,26 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Search Process - Get search pipeline visualization data (placeholder)
|
||||
if (pathname === '/api/graph/search-process') {
|
||||
// This endpoint returns mock data for the search process visualization
|
||||
// In a real implementation, this would integrate with codex-lens search pipeline
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
stages: [
|
||||
{ id: 1, name: 'Query Parsing', duration: 0, status: 'pending' },
|
||||
{ id: 2, name: 'Vector Search', duration: 0, status: 'pending' },
|
||||
{ id: 3, name: 'Graph Enrichment', duration: 0, status: 'pending' },
|
||||
{ id: 4, name: 'Chunk Hierarchy', duration: 0, status: 'pending' },
|
||||
{ id: 5, name: 'Result Ranking', duration: 0, status: 'pending' }
|
||||
],
|
||||
chunks: [],
|
||||
callers: [],
|
||||
callees: [],
|
||||
message: 'Search process visualization requires an active search query'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -379,7 +379,9 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
|
||||
tool,
|
||||
prompt: analysisPrompt,
|
||||
mode: 'analysis',
|
||||
timeout: 120000
|
||||
timeout: 120000,
|
||||
cd: projectPath,
|
||||
category: 'insights'
|
||||
});
|
||||
|
||||
// Try to parse JSON from response
|
||||
@@ -521,8 +523,9 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
|
||||
filtered = hotEntities.filter((e: any) => new Date(e.last_seen_at) >= weekAgo);
|
||||
}
|
||||
|
||||
// Separate into mostRead and mostEdited
|
||||
// Separate into mostRead, mostEdited, and mostMentioned
|
||||
const fileEntities = filtered.filter((e: any) => e.type === 'file');
|
||||
const topicEntities = filtered.filter((e: any) => e.type === 'topic');
|
||||
|
||||
const mostRead = fileEntities
|
||||
.filter((e: any) => e.stats.read_count > 0)
|
||||
@@ -548,11 +551,23 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
|
||||
lastSeen: e.last_seen_at
|
||||
}));
|
||||
|
||||
const mostMentioned = topicEntities
|
||||
.filter((e: any) => e.stats.mention_count > 0)
|
||||
.sort((a: any, b: any) => b.stats.mention_count - a.stats.mention_count)
|
||||
.slice(0, limit)
|
||||
.map((e: any) => ({
|
||||
topic: e.value,
|
||||
preview: e.value.substring(0, 100) + (e.value.length > 100 ? '...' : ''),
|
||||
heat: e.stats.mention_count,
|
||||
count: e.stats.mention_count,
|
||||
lastSeen: e.last_seen_at
|
||||
}));
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ stats: { mostRead, mostEdited } }));
|
||||
res.end(JSON.stringify({ stats: { mostRead, mostEdited, mostMentioned } }));
|
||||
} catch (error: unknown) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ stats: { mostRead: [], mostEdited: [] } }));
|
||||
res.end(JSON.stringify({ stats: { mostRead: [], mostEdited: [], mostMentioned: [] } }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ const MODULE_CSS_FILES = [
|
||||
'11-memory.css',
|
||||
'11-prompt-history.css',
|
||||
'12-skills-rules.css',
|
||||
'13-claude-manager.css'
|
||||
'13-claude-manager.css',
|
||||
'14-graph-explorer.css'
|
||||
];
|
||||
|
||||
// Modular JS files in dependency order
|
||||
@@ -109,6 +110,7 @@ const MODULE_FILES = [
|
||||
'views/skills-manager.js',
|
||||
'views/rules-manager.js',
|
||||
'views/claude-manager.js',
|
||||
'views/graph-explorer.js',
|
||||
'main.js'
|
||||
];
|
||||
|
||||
|
||||
@@ -2098,6 +2098,7 @@
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 50vw;
|
||||
min-width: 600px;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
background: hsl(var(--card));
|
||||
|
||||
@@ -34,13 +34,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.graph-explorer-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.graph-explorer-header-left h2 {
|
||||
.graph-explorer-header h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -50,6 +44,12 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.graph-explorer-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.graph-explorer-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -190,27 +190,7 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* Graph Main Content
|
||||
* ======================================== */
|
||||
.graph-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Cytoscape Graph Canvas */
|
||||
.cytoscape-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
min-height: 400px;
|
||||
}
|
||||
/* Note: .graph-main and .cytoscape-container styles are defined in the Additional Classes section below */
|
||||
|
||||
#cy {
|
||||
width: 100%;
|
||||
@@ -1134,3 +1114,444 @@
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* Additional Classes for JS Compatibility
|
||||
* ======================================== */
|
||||
|
||||
/* Explorer Tabs */
|
||||
.graph-explorer-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: hsl(var(--hover));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Tab Content */
|
||||
.graph-explorer-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Graph View Layout */
|
||||
.graph-view {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.graph-sidebar {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 1rem 0.5rem 0;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.graph-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.graph-controls-section,
|
||||
.graph-legend-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.graph-controls-section h3,
|
||||
.graph-legend-section h3 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.graph-legend-section {
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
padding-top: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Graph Legend */
|
||||
.graph-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-line {
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-color {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Graph Toolbar */
|
||||
.graph-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.graph-toolbar-left,
|
||||
.graph-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.375rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: hsl(var(--hover));
|
||||
color: hsl(var(--foreground));
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* Filter Checkboxes */
|
||||
.filter-dropdowns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-group > label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-checkbox input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Legend Items */
|
||||
.legend-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Search Process View */
|
||||
.search-process-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-process-header {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-process-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.search-process-header p {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.search-empty-state i {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Search Process Empty State */
|
||||
.search-process-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.search-process-empty i {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-process-empty p {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Search Process Timeline */
|
||||
.search-process-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-step {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.search-step-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-radius: 50%;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-step-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-step-content h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.search-step-content p {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-step-results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Cytoscape Container */
|
||||
.cytoscape-container {
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Cytoscape Empty State */
|
||||
.cytoscape-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.cytoscape-empty i {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cytoscape-empty p {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Graph Empty State */
|
||||
.graph-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.graph-empty-state i {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.graph-empty-state p {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Hidden class */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -52,10 +52,25 @@ function initPathSelector() {
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup function for view transitions
|
||||
function cleanupPreviousView() {
|
||||
// Cleanup graph explorer
|
||||
if (currentView === 'graph-explorer' && typeof window.cleanupGraphExplorer === 'function') {
|
||||
window.cleanupGraphExplorer();
|
||||
}
|
||||
// Hide storage card when leaving cli-manager
|
||||
var storageCard = document.getElementById('storageCard');
|
||||
if (storageCard) {
|
||||
storageCard.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation
|
||||
function initNavigation() {
|
||||
document.querySelectorAll('.nav-item[data-filter]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
cleanupPreviousView();
|
||||
|
||||
setActiveNavItem(item);
|
||||
currentFilter = item.dataset.filter;
|
||||
currentLiteType = null;
|
||||
@@ -70,6 +85,8 @@ function initNavigation() {
|
||||
// Lite Tasks Navigation
|
||||
document.querySelectorAll('.nav-item[data-lite]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
cleanupPreviousView();
|
||||
|
||||
setActiveNavItem(item);
|
||||
currentLiteType = item.dataset.lite;
|
||||
currentFilter = null;
|
||||
@@ -84,6 +101,8 @@ function initNavigation() {
|
||||
// View Navigation (Project Overview, MCP Manager, etc.)
|
||||
document.querySelectorAll('.nav-item[data-view]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
cleanupPreviousView();
|
||||
|
||||
setActiveNavItem(item);
|
||||
currentView = item.dataset.view;
|
||||
currentFilter = null;
|
||||
|
||||
@@ -59,40 +59,22 @@ function renderStorageCard() {
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Build project rows
|
||||
// Build project tree (hierarchical view)
|
||||
let projectRows = '';
|
||||
if (projects && projects.length > 0) {
|
||||
projects.slice(0, 5).forEach(p => {
|
||||
const historyBadge = p.historyRecords > 0
|
||||
? '<span class="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">' + p.historyRecords + '</span>'
|
||||
: '<span class="text-xs text-muted-foreground">-</span>';
|
||||
const tree = buildProjectTree(projects);
|
||||
projectRows = renderProjectTree(tree, 0, formatTimeAgo);
|
||||
|
||||
projectRows += '\
|
||||
<tr class="border-b border-border/50 hover:bg-muted/30">\
|
||||
<td class="py-2 px-2 font-mono text-xs text-muted-foreground">' + escapeHtml(p.id.substring(0, 8)) + '...</td>\
|
||||
<td class="py-2 px-2 text-sm text-right">' + escapeHtml(p.totalSizeFormatted) + '</td>\
|
||||
<td class="py-2 px-2 text-center">' + historyBadge + '</td>\
|
||||
<td class="py-2 px-2 text-xs text-muted-foreground text-right">' + formatTimeAgo(p.lastModified) + '</td>\
|
||||
<td class="py-2 px-1 text-right">\
|
||||
<button onclick="cleanProjectStorage(\'' + escapeHtml(p.id) + '\')" \
|
||||
class="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors" \
|
||||
title="Clean this project storage">\
|
||||
<i data-lucide="trash-2" class="w-3 h-3"></i>\
|
||||
</button>\
|
||||
</td>\
|
||||
</tr>\
|
||||
';
|
||||
});
|
||||
|
||||
if (projects.length > 5) {
|
||||
projectRows += '\
|
||||
<tr>\
|
||||
<td colspan="5" class="py-2 px-2 text-xs text-muted-foreground text-center">\
|
||||
... and ' + (projects.length - 5) + ' more projects\
|
||||
</td>\
|
||||
</tr>\
|
||||
';
|
||||
}
|
||||
// Initially hide all child rows (level > 0)
|
||||
setTimeout(() => {
|
||||
const allRows = document.querySelectorAll('.project-row');
|
||||
allRows.forEach(row => {
|
||||
const level = parseInt(row.getAttribute('data-level'));
|
||||
if (level > 0) {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
} else {
|
||||
projectRows = '\
|
||||
<tr>\
|
||||
@@ -178,6 +160,162 @@ function getTotalRecords() {
|
||||
return storageData.projects.reduce((sum, p) => sum + (p.historyRecords || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build project tree from flat list
|
||||
* Converts flat project list to hierarchical tree structure
|
||||
*/
|
||||
function buildProjectTree(projects) {
|
||||
const tree = [];
|
||||
const map = new Map();
|
||||
|
||||
// Sort by path depth (shallowest first)
|
||||
const sorted = projects.slice().sort((a, b) => {
|
||||
const depthA = (a.id.match(/\//g) || []).length;
|
||||
const depthB = (b.id.match(/\//g) || []).length;
|
||||
return depthA - depthB;
|
||||
});
|
||||
|
||||
for (const project of sorted) {
|
||||
const segments = project.id.split('/');
|
||||
|
||||
if (segments.length === 1) {
|
||||
// Root level project
|
||||
const node = {
|
||||
...project,
|
||||
children: [],
|
||||
isExpanded: false
|
||||
};
|
||||
tree.push(node);
|
||||
map.set(project.id, node);
|
||||
} else {
|
||||
// Sub-project
|
||||
const parentId = segments.slice(0, -1).join('/');
|
||||
const parent = map.get(parentId);
|
||||
|
||||
if (parent) {
|
||||
const node = {
|
||||
...project,
|
||||
children: [],
|
||||
isExpanded: false
|
||||
};
|
||||
parent.children.push(node);
|
||||
map.set(project.id, node);
|
||||
} else {
|
||||
// Orphaned project (parent not found) - add to root
|
||||
const node = {
|
||||
...project,
|
||||
children: [],
|
||||
isExpanded: false
|
||||
};
|
||||
tree.push(node);
|
||||
map.set(project.id, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render project tree recursively
|
||||
*/
|
||||
function renderProjectTree(tree, level = 0, formatTimeAgo) {
|
||||
if (!tree || tree.length === 0) return '';
|
||||
|
||||
let html = '';
|
||||
|
||||
for (const node of tree) {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const indent = level * 20;
|
||||
const projectName = node.id.split('/').pop();
|
||||
|
||||
const historyBadge = node.historyRecords > 0
|
||||
? '<span class="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">' + node.historyRecords + '</span>'
|
||||
: '<span class="text-xs text-muted-foreground">-</span>';
|
||||
|
||||
const toggleIcon = hasChildren
|
||||
? '<i data-lucide="chevron-right" class="w-3 h-3 transition-transform duration-200 toggle-icon"></i>'
|
||||
: '<span class="w-3 h-3 inline-block"></span>';
|
||||
|
||||
html += '\
|
||||
<tr class="border-b border-border/50 hover:bg-muted/30 project-row" data-project-id="' + escapeHtml(node.id) + '" data-level="' + level + '">\
|
||||
<td class="py-2 px-2 font-mono text-xs text-muted-foreground">\
|
||||
<div class="flex items-center gap-1" style="padding-left: ' + indent + 'px">\
|
||||
' + (hasChildren ? '<button class="toggle-btn hover:bg-muted/50 rounded p-0.5" onclick="toggleProjectNode(\'' + escapeHtml(node.id) + '\')">' + toggleIcon + '</button>' : '<span class="w-3 h-3 inline-block"></span>') + '\
|
||||
<span class="truncate max-w-[150px]" title="' + escapeHtml(node.id) + '">' + escapeHtml(projectName) + '</span>\
|
||||
</div>\
|
||||
</td>\
|
||||
<td class="py-2 px-2 text-sm text-right">' + escapeHtml(node.totalSizeFormatted) + '</td>\
|
||||
<td class="py-2 px-2 text-center">' + historyBadge + '</td>\
|
||||
<td class="py-2 px-2 text-xs text-muted-foreground text-right">' + formatTimeAgo(node.lastModified) + '</td>\
|
||||
<td class="py-2 px-1 text-right">\
|
||||
<button onclick="cleanProjectStorage(\'' + escapeHtml(node.id) + '\')" \
|
||||
class="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors" \
|
||||
title="Clean this project storage">\
|
||||
<i data-lucide="trash-2" class="w-3 h-3"></i>\
|
||||
</button>\
|
||||
</td>\
|
||||
</tr>\
|
||||
';
|
||||
|
||||
// Render children (initially hidden)
|
||||
if (hasChildren) {
|
||||
html += renderProjectTree(node.children, level + 1, formatTimeAgo);
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle project node expansion
|
||||
*/
|
||||
function toggleProjectNode(projectId) {
|
||||
const row = document.querySelector('[data-project-id="' + projectId + '"]');
|
||||
if (!row) return;
|
||||
|
||||
const icon = row.querySelector('.toggle-icon');
|
||||
const level = parseInt(row.getAttribute('data-level'));
|
||||
|
||||
// Find all child rows
|
||||
let nextRow = row.nextElementSibling;
|
||||
const childRows = [];
|
||||
|
||||
while (nextRow && nextRow.classList.contains('project-row')) {
|
||||
const nextLevel = parseInt(nextRow.getAttribute('data-level'));
|
||||
if (nextLevel <= level) break;
|
||||
childRows.push(nextRow);
|
||||
nextRow = nextRow.nextElementSibling;
|
||||
}
|
||||
|
||||
// Toggle visibility
|
||||
const isExpanded = row.classList.contains('expanded');
|
||||
|
||||
if (isExpanded) {
|
||||
// Collapse
|
||||
row.classList.remove('expanded');
|
||||
if (icon) icon.style.transform = 'rotate(0deg)';
|
||||
childRows.forEach(child => {
|
||||
child.style.display = 'none';
|
||||
});
|
||||
} else {
|
||||
// Expand (only immediate children)
|
||||
row.classList.add('expanded');
|
||||
if (icon) icon.style.transform = 'rotate(90deg)';
|
||||
childRows.forEach(child => {
|
||||
const childLevel = parseInt(child.getAttribute('data-level'));
|
||||
if (childLevel === level + 1) {
|
||||
child.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reinitialize Lucide icons
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render error state for storage card
|
||||
*/
|
||||
|
||||
@@ -506,6 +506,27 @@ const i18n = {
|
||||
'mcp.codex.enabledTools': 'Tools',
|
||||
'mcp.codex.tools': 'tools enabled',
|
||||
|
||||
// Claude to Codex copy
|
||||
'mcp.claude.copyFromCodex': 'Copy Codex Servers to Claude',
|
||||
'mcp.claude.alreadyAdded': 'Already in Claude',
|
||||
'mcp.claude.copyToClaude': 'Copy to Claude Global',
|
||||
|
||||
// MCP Edit Modal
|
||||
'mcp.editModal.title': 'Edit MCP Server',
|
||||
'mcp.editModal.serverNamePlaceholder': 'server-name',
|
||||
'mcp.editModal.onePerLine': 'one per line',
|
||||
'mcp.editModal.save': 'Save Changes',
|
||||
'mcp.editModal.delete': 'Delete',
|
||||
'mcp.editModal.nameRequired': 'Server name is required',
|
||||
'mcp.editModal.commandRequired': 'Command or URL is required',
|
||||
'mcp.editModal.saved': 'MCP server "{name}" updated',
|
||||
'mcp.editModal.saveFailed': 'Failed to save MCP server',
|
||||
'mcp.editModal.deleteConfirm': 'Delete MCP server "{name}"?',
|
||||
'mcp.editModal.deleted': 'MCP server "{name}" deleted',
|
||||
'mcp.editModal.deleteFailed': 'Failed to delete MCP server',
|
||||
'mcp.clickToEdit': 'Click to edit',
|
||||
'mcp.clickToViewDetails': 'Click to view details',
|
||||
|
||||
// Hook Manager
|
||||
'hook.projectHooks': 'Project Hooks',
|
||||
'hook.projectFile': '.claude/settings.json',
|
||||
@@ -729,6 +750,7 @@ const i18n = {
|
||||
'memory.contextHotspots': 'Context Hotspots',
|
||||
'memory.mostRead': 'Most Read Files',
|
||||
'memory.mostEdited': 'Most Edited Files',
|
||||
'memory.mostMentioned': 'Most Mentioned Topics',
|
||||
'memory.today': 'Today',
|
||||
'memory.week': 'Week',
|
||||
'memory.allTime': 'All Time',
|
||||
@@ -958,6 +980,29 @@ const i18n = {
|
||||
'graph.zoomIn': 'Zoom In',
|
||||
'graph.zoomOut': 'Zoom Out',
|
||||
'graph.resetLayout': 'Reset Layout',
|
||||
'graph.title': 'Code Graph',
|
||||
'graph.filters': 'Filters',
|
||||
'graph.legend': 'Legend',
|
||||
'graph.nodes': 'nodes',
|
||||
'graph.edges': 'edges',
|
||||
'graph.noGraphData': 'No graph data available. Index this project with codex-lens first.',
|
||||
'graph.noSearchData': 'No search process data available.',
|
||||
'graph.center': 'Center',
|
||||
'graph.resetFilters': 'Reset Filters',
|
||||
'graph.cytoscapeNotLoaded': 'Graph library not loaded',
|
||||
'graph.impactAnalysisError': 'Failed to load impact analysis',
|
||||
'graph.searchProcessDesc': 'Visualize how search queries flow through the system',
|
||||
'graph.searchProcessTitle': 'Search Pipeline',
|
||||
'graph.resultsFound': 'results found',
|
||||
'graph.type': 'Type',
|
||||
'graph.line': 'Line',
|
||||
'graph.path': 'Path',
|
||||
'graph.depth': 'Depth',
|
||||
'graph.exports': 'Exports',
|
||||
'graph.imports': 'Imports',
|
||||
'graph.references': 'References',
|
||||
'graph.symbolType': 'Symbol Type',
|
||||
'graph.affectedSymbols': 'Affected Symbols',
|
||||
|
||||
// CLI Sync (used in claude-manager.js)
|
||||
'claude.cliSync': 'CLI Auto-Sync',
|
||||
@@ -1025,7 +1070,7 @@ const i18n = {
|
||||
zh: {
|
||||
// App title and brand
|
||||
'app.title': 'CCW 控制面板',
|
||||
'app.brand': 'Claude Code 工作流',
|
||||
'app.brand': 'Claude Code Workflow',
|
||||
|
||||
// Header
|
||||
'header.project': '项目:',
|
||||
@@ -1498,6 +1543,27 @@ const i18n = {
|
||||
'mcp.codex.enabledTools': '工具',
|
||||
'mcp.codex.tools': '个工具已启用',
|
||||
|
||||
// Claude to Codex copy
|
||||
'mcp.claude.copyFromCodex': '从 Codex 复制服务器到 Claude',
|
||||
'mcp.claude.alreadyAdded': '已在 Claude 中',
|
||||
'mcp.claude.copyToClaude': '复制到 Claude 全局',
|
||||
|
||||
// MCP Edit Modal
|
||||
'mcp.editModal.title': '编辑 MCP 服务器',
|
||||
'mcp.editModal.serverNamePlaceholder': 'server-name',
|
||||
'mcp.editModal.onePerLine': '每行一个',
|
||||
'mcp.editModal.save': '保存更改',
|
||||
'mcp.editModal.delete': '删除',
|
||||
'mcp.editModal.nameRequired': '服务器名称必填',
|
||||
'mcp.editModal.commandRequired': '命令或 URL 必填',
|
||||
'mcp.editModal.saved': 'MCP 服务器 "{name}" 已更新',
|
||||
'mcp.editModal.saveFailed': '保存 MCP 服务器失败',
|
||||
'mcp.editModal.deleteConfirm': '删除 MCP 服务器 "{name}"?',
|
||||
'mcp.editModal.deleted': 'MCP 服务器 "{name}" 已删除',
|
||||
'mcp.editModal.deleteFailed': '删除 MCP 服务器失败',
|
||||
'mcp.clickToEdit': '点击编辑',
|
||||
'mcp.clickToViewDetails': '点击查看详情',
|
||||
|
||||
// Hook Manager
|
||||
'hook.projectHooks': '项目钩子',
|
||||
'hook.projectFile': '.claude/settings.json',
|
||||
@@ -1721,6 +1787,7 @@ const i18n = {
|
||||
'memory.contextHotspots': '上下文热点',
|
||||
'memory.mostRead': '最常读取的文件',
|
||||
'memory.mostEdited': '最常编辑的文件',
|
||||
'memory.mostMentioned': '最常提及的话题',
|
||||
'memory.today': '今天',
|
||||
'memory.week': '本周',
|
||||
'memory.allTime': '全部时间',
|
||||
@@ -1950,6 +2017,29 @@ const i18n = {
|
||||
'graph.zoomIn': '放大',
|
||||
'graph.zoomOut': '缩小',
|
||||
'graph.resetLayout': '重置布局',
|
||||
'graph.title': '代码图谱',
|
||||
'graph.filters': '筛选器',
|
||||
'graph.legend': '图例',
|
||||
'graph.nodes': '个节点',
|
||||
'graph.edges': '条边',
|
||||
'graph.noGraphData': '无图谱数据。请先使用 codex-lens 为此项目建立索引。',
|
||||
'graph.noSearchData': '无搜索过程数据。',
|
||||
'graph.center': '居中',
|
||||
'graph.resetFilters': '重置筛选',
|
||||
'graph.cytoscapeNotLoaded': '图谱库未加载',
|
||||
'graph.impactAnalysisError': '加载影响分析失败',
|
||||
'graph.searchProcessDesc': '可视化搜索查询在系统中的流转过程',
|
||||
'graph.searchProcessTitle': '搜索管道',
|
||||
'graph.resultsFound': '个结果',
|
||||
'graph.type': '类型',
|
||||
'graph.line': '行号',
|
||||
'graph.path': '路径',
|
||||
'graph.depth': '深度',
|
||||
'graph.exports': '导出',
|
||||
'graph.imports': '导入',
|
||||
'graph.references': '引用',
|
||||
'graph.symbolType': '符号类型',
|
||||
'graph.affectedSymbols': '受影响符号',
|
||||
|
||||
// CLI Sync (used in claude-manager.js)
|
||||
'claude.cliSync': 'CLI 自动同步',
|
||||
|
||||
@@ -222,7 +222,7 @@ function renderGraphLegend() {
|
||||
function renderSearchProcessView() {
|
||||
if (!searchProcessData) {
|
||||
return '<div class="search-process-empty">' +
|
||||
'<i data-lucide="search-x" class="w-12 h-12"></i>' +
|
||||
'<i data-lucide="search-x" class="w-8 h-8"></i>' +
|
||||
'<p>' + t('graph.noSearchData') + '</p>' +
|
||||
'</div>';
|
||||
}
|
||||
@@ -280,7 +280,7 @@ function initializeCytoscape() {
|
||||
// Check if Cytoscape.js is loaded
|
||||
if (typeof cytoscape === 'undefined') {
|
||||
container.innerHTML = '<div class="cytoscape-error">' +
|
||||
'<i data-lucide="alert-triangle" class="w-8 h-8"></i>' +
|
||||
'<i data-lucide="alert-triangle" class="w-6 h-6"></i>' +
|
||||
'<p>' + t('graph.cytoscapeNotLoaded') + '</p>' +
|
||||
'</div>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
@@ -289,7 +289,7 @@ function initializeCytoscape() {
|
||||
|
||||
if (graphData.nodes.length === 0) {
|
||||
container.innerHTML = '<div class="cytoscape-empty">' +
|
||||
'<i data-lucide="network" class="w-12 h-12"></i>' +
|
||||
'<i data-lucide="network" class="w-8 h-8"></i>' +
|
||||
'<p>' + t('graph.noGraphData') + '</p>' +
|
||||
'</div>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
@@ -493,6 +493,15 @@ function selectNode(nodeData) {
|
||||
panel.classList.remove('hidden');
|
||||
panel.innerHTML = renderNodeDetails(nodeData);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
// Attach event listener for impact analysis button (prevents XSS)
|
||||
var impactBtn = document.getElementById('impactAnalysisBtn');
|
||||
if (impactBtn) {
|
||||
impactBtn.addEventListener('click', function() {
|
||||
var nodeId = this.getAttribute('data-node-id');
|
||||
if (nodeId) showImpactAnalysis(nodeId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,7 +568,7 @@ function renderNodeDetails(node) {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="node-details-actions">' +
|
||||
'<button class="btn btn-sm btn-primary" onclick="showImpactAnalysis(\'' + escapeHtml(node.id) + '\')">' +
|
||||
'<button class="btn btn-sm btn-primary" id="impactAnalysisBtn" data-node-id="' + escapeHtml(node.id) + '">' +
|
||||
'<i data-lucide="target" class="w-3 h-3"></i> ' + t('graph.impactAnalysis') +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
@@ -629,7 +638,7 @@ function centerCytoscape() {
|
||||
// ========== Impact Analysis ==========
|
||||
async function showImpactAnalysis(symbolId) {
|
||||
try {
|
||||
var response = await fetch('/api/graph/impact/' + encodeURIComponent(symbolId));
|
||||
var response = await fetch('/api/graph/impact?symbol=' + encodeURIComponent(symbolId));
|
||||
if (!response.ok) throw new Error('Failed to fetch impact analysis');
|
||||
var data = await response.json();
|
||||
|
||||
@@ -727,3 +736,22 @@ function hideStatsAndCarousel() {
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (carousel) carousel.style.display = 'none';
|
||||
}
|
||||
|
||||
// ========== Cleanup Function ==========
|
||||
/**
|
||||
* Clean up Cytoscape instance to prevent memory leaks
|
||||
* Should be called when navigating away from the graph explorer view
|
||||
*/
|
||||
function cleanupGraphExplorer() {
|
||||
if (cyInstance) {
|
||||
cyInstance.destroy();
|
||||
cyInstance = null;
|
||||
}
|
||||
selectedNode = null;
|
||||
searchProcessData = null;
|
||||
}
|
||||
|
||||
// Register cleanup on navigation (called by navigation.js before switching views)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.cleanupGraphExplorer = cleanupGraphExplorer;
|
||||
}
|
||||
|
||||
@@ -139,8 +139,9 @@ async function renderMcpManager() {
|
||||
<i data-lucide="bot" class="w-4 h-4 inline mr-1.5"></i>
|
||||
Claude
|
||||
</button>
|
||||
<button class="cli-mode-btn px-4 py-2 text-sm font-medium rounded-md transition-all ${currentCliMode === 'codex' ? 'bg-orange-500 text-white shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick="setCliMode('codex')">
|
||||
<button class="cli-mode-btn px-4 py-2 text-sm font-medium rounded-md transition-all ${currentCliMode === 'codex' ? 'shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick="setCliMode('codex')"
|
||||
style="${currentCliMode === 'codex' ? 'background-color: #f97316; color: white;' : ''}">
|
||||
<i data-lucide="code-2" class="w-4 h-4 inline mr-1.5"></i>
|
||||
Codex
|
||||
</button>
|
||||
@@ -228,6 +229,7 @@ async function renderMcpManager() {
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="bot" class="w-5 h-5 text-primary"></i>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
|
||||
<span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">Claude</span>
|
||||
${alreadyInCodex ? `<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">${t('mcp.codex.alreadyAdded')}</span>` : ''}
|
||||
</div>
|
||||
${!alreadyInCodex ? `
|
||||
@@ -250,6 +252,26 @@ async function renderMcpManager() {
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Available MCP Servers from Other Projects (Codex mode) -->
|
||||
<div class="mcp-section">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">${t('mcp.availableOther')}</h3>
|
||||
<span class="text-sm text-muted-foreground">${otherProjectServers.length} ${t('mcp.serversAvailable')}</span>
|
||||
</div>
|
||||
|
||||
${otherProjectServers.length === 0 ? `
|
||||
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<p class="text-muted-foreground">${t('empty.noAdditionalMcp')}</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="mcp-server-grid grid gap-3">
|
||||
${otherProjectServers.map(([serverName, serverInfo]) => {
|
||||
return renderAvailableServerCardForCodex(serverName, serverInfo);
|
||||
}).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
` : `
|
||||
<!-- CCW Tools MCP Server Card -->
|
||||
<div class="mcp-section mb-6">
|
||||
@@ -486,6 +508,55 @@ async function renderMcpManager() {
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Copy Codex Servers to Claude (Claude mode only) -->
|
||||
${currentCliMode === 'claude' && Object.keys(codexMcpServers || {}).length > 0 ? `
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<i data-lucide="copy" class="w-5 h-5"></i>
|
||||
${t('mcp.claude.copyFromCodex')}
|
||||
</h3>
|
||||
<span class="text-sm text-muted-foreground">${Object.keys(codexMcpServers || {}).length} ${t('mcp.serversAvailable')}</span>
|
||||
</div>
|
||||
<div class="mcp-server-grid grid gap-3">
|
||||
${Object.entries(codexMcpServers || {}).map(([serverName, serverConfig]) => {
|
||||
const alreadyInClaude = mcpUserServers && mcpUserServers[serverName];
|
||||
const isStdio = !!serverConfig.command;
|
||||
const isHttp = !!serverConfig.url;
|
||||
return `
|
||||
<div class="mcp-server-card bg-card border ${alreadyInClaude ? 'border-success/50' : 'border-orange-200 dark:border-orange-800'} border-dashed rounded-lg p-4 hover:shadow-md transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<i data-lucide="code-2" class="w-5 h-5 text-orange-500"></i>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
|
||||
<span class="text-xs px-2 py-0.5 bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300 rounded-full">Codex</span>
|
||||
${isHttp
|
||||
? '<span class="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">HTTP</span>'
|
||||
: '<span class="text-xs px-2 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 rounded-full">STDIO</span>'
|
||||
}
|
||||
${alreadyInClaude ? '<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">' + t('mcp.claude.alreadyAdded') + '</span>' : ''}
|
||||
</div>
|
||||
${!alreadyInClaude ? `
|
||||
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
onclick="copyCodexServerToClaude('${escapeHtml(serverName)}', ${JSON.stringify(serverConfig).replace(/'/g, "'")})"
|
||||
title="${t('mcp.claude.copyToClaude')}">
|
||||
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Claude
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="mcp-server-details text-sm space-y-1">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${isHttp ? t('mcp.url') : t('mcp.cmd')}</span>
|
||||
<span class="truncate" title="${escapeHtml(serverConfig.command || serverConfig.url || 'N/A')}">${escapeHtml(serverConfig.command || serverConfig.url || 'N/A')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- All Projects MCP Overview Table (Claude mode only) -->
|
||||
${currentCliMode === 'claude' ? `
|
||||
<div class="mcp-section mt-6">
|
||||
@@ -676,7 +747,12 @@ function renderGlobalManagementCard(serverName, serverConfig) {
|
||||
const serverType = serverConfig.type || 'stdio';
|
||||
|
||||
return `
|
||||
<div class="mcp-server-card mcp-server-global bg-card border border-success/30 rounded-lg p-4 hover:shadow-md transition-all">
|
||||
<div class="mcp-server-card mcp-server-global bg-card border border-success/30 rounded-lg p-4 hover:shadow-md transition-all cursor-pointer"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
|
||||
data-server-source="global"
|
||||
data-action="view-details"
|
||||
title="${t('mcp.clickToEdit')}">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="globe" class="w-5 h-5 text-success"></i>
|
||||
@@ -706,7 +782,7 @@ function renderGlobalManagementCard(serverName, serverConfig) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 pt-3 border-t border-border flex items-center justify-end">
|
||||
<div class="mt-3 pt-3 border-t border-border flex items-center justify-end" onclick="event.stopPropagation()">
|
||||
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-action="remove-global">
|
||||
@@ -807,6 +883,70 @@ function renderAvailableServerCard(serverName, serverInfo) {
|
||||
`;
|
||||
}
|
||||
|
||||
// Render available server card for Codex mode (with Claude badge and copy to Codex button)
|
||||
function renderAvailableServerCardForCodex(serverName, serverInfo) {
|
||||
const serverConfig = serverInfo.config;
|
||||
const usedIn = serverInfo.usedIn || [];
|
||||
const command = serverConfig.command || serverConfig.url || 'N/A';
|
||||
const args = serverConfig.args || [];
|
||||
|
||||
// Get the actual name to use when adding
|
||||
const originalName = serverInfo.originalName || serverName;
|
||||
const hasVariant = serverInfo.originalName && serverInfo.originalName !== serverName;
|
||||
|
||||
// Get source project info
|
||||
const sourceProject = serverInfo.sourceProject;
|
||||
const sourceProjectName = sourceProject ? (sourceProject.split('\\').pop() || sourceProject.split('/').pop()) : null;
|
||||
|
||||
// Generate args preview
|
||||
const argsPreview = args.length > 0 ? args.slice(0, 3).join(' ') + (args.length > 3 ? '...' : '') : '';
|
||||
|
||||
// Check if already in Codex
|
||||
const alreadyInCodex = codexMcpServers && codexMcpServers[originalName];
|
||||
|
||||
return `
|
||||
<div class="mcp-server-card mcp-server-available bg-card border ${alreadyInCodex ? 'border-success/50' : 'border-border'} border-dashed rounded-lg p-4 hover:shadow-md hover:border-solid transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span><i data-lucide="circle-dashed" class="w-5 h-5 text-muted-foreground"></i></span>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(originalName)}</h4>
|
||||
<span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">Claude</span>
|
||||
${hasVariant ? `
|
||||
<span class="text-xs px-2 py-0.5 bg-warning/20 text-warning rounded-full" title="Different config from: ${escapeHtml(sourceProject || '')}">
|
||||
${escapeHtml(sourceProjectName || 'variant')}
|
||||
</span>
|
||||
` : ''}
|
||||
${alreadyInCodex ? `<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">${t('mcp.codex.alreadyAdded')}</span>` : ''}
|
||||
</div>
|
||||
${!alreadyInCodex ? `
|
||||
<button class="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:opacity-90 transition-opacity"
|
||||
onclick="copyClaudeServerToCodex('${escapeHtml(originalName)}', ${JSON.stringify(serverConfig).replace(/'/g, "'")})"
|
||||
title="${t('mcp.codex.copyToCodex')}">
|
||||
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Codex
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="mcp-server-details text-sm space-y-1">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${t('mcp.cmd')}</span>
|
||||
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
|
||||
</div>
|
||||
${argsPreview ? `
|
||||
<div class="flex items-start gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">${t('mcp.args')}</span>
|
||||
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(argsPreview)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="text-xs">${t('mcp.usedInCount').replace('{count}', usedIn.length).replace('{s}', usedIn.length !== 1 ? 's' : '')}</span>
|
||||
${sourceProjectName ? `<span class="text-xs text-muted-foreground/70">• ${t('mcp.from')} ${escapeHtml(sourceProjectName)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Codex MCP Server Card Renderer
|
||||
// ========================================
|
||||
@@ -825,14 +965,17 @@ function renderCodexServerCard(serverName, serverConfig) {
|
||||
: `<span class="text-xs px-2 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 rounded-full">STDIO</span>`;
|
||||
|
||||
return `
|
||||
<div class="mcp-server-card bg-card border border-orange-200 dark:border-orange-800 rounded-lg p-4 hover:shadow-md transition-all ${!isEnabled ? 'opacity-60' : ''}"
|
||||
<div class="mcp-server-card bg-card border border-orange-200 dark:border-orange-800 rounded-lg p-4 hover:shadow-md transition-all cursor-pointer ${!isEnabled ? 'opacity-60' : ''}"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
|
||||
data-cli-type="codex">
|
||||
data-cli-type="codex"
|
||||
data-action="view-details-codex"
|
||||
title="${t('mcp.clickToEdit')}">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span>${isEnabled ? '<i data-lucide="check-circle" class="w-5 h-5 text-orange-500"></i>' : '<i data-lucide="circle" class="w-5 h-5 text-muted-foreground"></i>'}</span>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
|
||||
<span class="text-xs px-2 py-0.5 bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300 rounded-full">Codex</span>
|
||||
${typeBadge}
|
||||
</div>
|
||||
<label class="mcp-toggle relative inline-flex items-center cursor-pointer" onclick="event.stopPropagation()">
|
||||
@@ -1041,13 +1184,22 @@ function attachMcpEventListeners() {
|
||||
});
|
||||
});
|
||||
|
||||
// View details - click on server card
|
||||
// View details / Edit - click on Claude server card
|
||||
document.querySelectorAll('.mcp-server-card[data-action="view-details"]').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
const serverName = card.dataset.serverName;
|
||||
const serverConfig = JSON.parse(card.dataset.serverConfig);
|
||||
const serverSource = card.dataset.serverSource;
|
||||
showMcpDetails(serverName, serverConfig, serverSource);
|
||||
showMcpEditModal(serverName, serverConfig, serverSource, 'claude');
|
||||
});
|
||||
});
|
||||
|
||||
// View details / Edit - click on Codex server card
|
||||
document.querySelectorAll('.mcp-server-card[data-action="view-details-codex"]').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
const serverName = card.dataset.serverName;
|
||||
const serverConfig = JSON.parse(card.dataset.serverConfig);
|
||||
showMcpEditModal(serverName, serverConfig, 'codex', 'codex');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1068,15 +1220,39 @@ function attachMcpEventListeners() {
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// MCP Details Modal
|
||||
// MCP Edit Modal (replaces Details Modal)
|
||||
// ========================================
|
||||
|
||||
function showMcpDetails(serverName, serverConfig, serverSource) {
|
||||
// Store current editing context
|
||||
let mcpEditContext = {
|
||||
serverName: null,
|
||||
serverConfig: null,
|
||||
serverSource: null,
|
||||
cliType: 'claude'
|
||||
};
|
||||
|
||||
function showMcpDetails(serverName, serverConfig, serverSource, cliType = 'claude') {
|
||||
showMcpEditModal(serverName, serverConfig, serverSource, cliType);
|
||||
}
|
||||
|
||||
function showMcpEditModal(serverName, serverConfig, serverSource, cliType = 'claude') {
|
||||
const modal = document.getElementById('mcpDetailsModal');
|
||||
const modalBody = document.getElementById('mcpDetailsModalBody');
|
||||
|
||||
if (!modal || !modalBody) return;
|
||||
|
||||
// Store editing context
|
||||
mcpEditContext = {
|
||||
serverName,
|
||||
serverConfig: JSON.parse(JSON.stringify(serverConfig)), // Deep clone
|
||||
serverSource,
|
||||
cliType
|
||||
};
|
||||
|
||||
// Check if editable (enterprise is read-only)
|
||||
const isReadOnly = serverSource === 'enterprise';
|
||||
const isCodex = cliType === 'codex';
|
||||
|
||||
// Build source badge
|
||||
let sourceBadge = '';
|
||||
if (serverSource === 'enterprise') {
|
||||
@@ -1085,74 +1261,271 @@ function showMcpDetails(serverName, serverConfig, serverSource) {
|
||||
sourceBadge = `<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-success/10 text-success">${t('mcp.sourceGlobal')}</span>`;
|
||||
} else if (serverSource === 'project') {
|
||||
sourceBadge = `<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-primary/10 text-primary">${t('mcp.sourceProject')}</span>`;
|
||||
} else if (isCodex) {
|
||||
sourceBadge = `<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300">Codex</span>`;
|
||||
}
|
||||
|
||||
// Build environment variables display
|
||||
let envHtml = '';
|
||||
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
|
||||
envHtml = '<div class="mt-4"><h4 class="font-semibold text-sm text-foreground mb-2">' + t('mcp.env') + '</h4><div class="bg-muted rounded-lg p-3 space-y-1 font-mono text-xs">';
|
||||
for (const [key, value] of Object.entries(serverConfig.env)) {
|
||||
envHtml += `<div class="flex items-start gap-2"><span class="text-muted-foreground shrink-0">${escapeHtml(key)}:</span><span class="text-foreground break-all">${escapeHtml(value)}</span></div>`;
|
||||
}
|
||||
envHtml += '</div></div>';
|
||||
} else {
|
||||
envHtml = '<div class="mt-4"><h4 class="font-semibold text-sm text-foreground mb-2">' + t('mcp.env') + '</h4><p class="text-sm text-muted-foreground">' + t('mcp.detailsModal.noEnv') + '</p></div>';
|
||||
}
|
||||
// Format args and env for textarea
|
||||
const argsText = (serverConfig.args || []).join('\n');
|
||||
const envText = Object.entries(serverConfig.env || {}).map(([k, v]) => `${k}=${v}`).join('\n');
|
||||
|
||||
// Build edit form HTML
|
||||
modalBody.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<!-- Server Name and Source -->
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide">${t('mcp.detailsModal.serverName')}</label>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<h3 class="text-xl font-bold text-foreground">${escapeHtml(serverName)}</h3>
|
||||
<input type="text" id="mcpEditName" value="${escapeHtml(serverName)}"
|
||||
class="text-lg font-bold text-foreground bg-transparent border-b border-border focus:border-primary outline-none px-1 py-0.5 flex-1"
|
||||
${isReadOnly ? 'disabled' : ''}
|
||||
placeholder="${t('mcp.editModal.serverNamePlaceholder')}">
|
||||
${sourceBadge}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration -->
|
||||
<!-- Command/URL -->
|
||||
<div>
|
||||
<h4 class="font-semibold text-sm text-foreground mb-2">${t('mcp.detailsModal.configuration')}</h4>
|
||||
<div class="space-y-2">
|
||||
<!-- Command -->
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="font-mono text-xs bg-muted px-2 py-1 rounded shrink-0">${t('mcp.cmd')}</span>
|
||||
<code class="text-sm font-mono text-foreground break-all">${escapeHtml(serverConfig.command || serverConfig.url || 'N/A')}</code>
|
||||
</div>
|
||||
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1 block">
|
||||
${serverConfig.url ? t('mcp.url') : t('mcp.cmd')}
|
||||
</label>
|
||||
<input type="text" id="mcpEditCommand" value="${escapeHtml(serverConfig.command || serverConfig.url || '')}"
|
||||
class="w-full px-3 py-2 text-sm font-mono bg-muted border border-border rounded-lg focus:border-primary outline-none"
|
||||
${isReadOnly ? 'disabled' : ''}
|
||||
placeholder="${serverConfig.url ? 'https://...' : 'npx, node, python...'}">
|
||||
</div>
|
||||
|
||||
<!-- Arguments -->
|
||||
${serverConfig.args && serverConfig.args.length > 0 ? `
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="font-mono text-xs bg-muted px-2 py-1 rounded shrink-0">${t('mcp.args')}</span>
|
||||
<div class="flex-1 space-y-1">
|
||||
${serverConfig.args.map((arg, index) => `
|
||||
<div class="text-sm font-mono text-foreground flex items-center gap-2">
|
||||
<span class="text-muted-foreground">[${index}]</span>
|
||||
<code class="break-all">${escapeHtml(arg)}</code>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<!-- Arguments -->
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1 block">
|
||||
${t('mcp.args')} <span class="font-normal">(${t('mcp.editModal.onePerLine')})</span>
|
||||
</label>
|
||||
<textarea id="mcpEditArgs" rows="3"
|
||||
class="w-full px-3 py-2 text-sm font-mono bg-muted border border-border rounded-lg focus:border-primary outline-none resize-none"
|
||||
${isReadOnly ? 'disabled' : ''}
|
||||
placeholder="-y package-name">${escapeHtml(argsText)}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Environment Variables -->
|
||||
${envHtml}
|
||||
|
||||
<!-- Raw JSON -->
|
||||
<div>
|
||||
<h4 class="font-semibold text-sm text-foreground mb-2">Raw JSON</h4>
|
||||
<pre class="bg-muted rounded-lg p-3 text-xs font-mono overflow-x-auto">${escapeHtml(JSON.stringify(serverConfig, null, 2))}</pre>
|
||||
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1 block">
|
||||
${t('mcp.env')} <span class="font-normal">(KEY=VALUE ${t('mcp.editModal.onePerLine')})</span>
|
||||
</label>
|
||||
<textarea id="mcpEditEnv" rows="3"
|
||||
class="w-full px-3 py-2 text-sm font-mono bg-muted border border-border rounded-lg focus:border-primary outline-none resize-none"
|
||||
${isReadOnly ? 'disabled' : ''}
|
||||
placeholder="API_KEY=your-key DEBUG=true">${escapeHtml(envText)}</textarea>
|
||||
</div>
|
||||
|
||||
${isCodex ? `
|
||||
<!-- Codex-specific: enabled_tools -->
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1 block">
|
||||
${t('mcp.codex.enabledTools')} <span class="font-normal">(${t('mcp.editModal.onePerLine')})</span>
|
||||
</label>
|
||||
<textarea id="mcpEditEnabledTools" rows="2"
|
||||
class="w-full px-3 py-2 text-sm font-mono bg-muted border border-border rounded-lg focus:border-primary outline-none resize-none"
|
||||
${isReadOnly ? 'disabled' : ''}
|
||||
placeholder="tool1 tool2">${escapeHtml((serverConfig.enabled_tools || []).join('\n'))}</textarea>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Raw JSON Preview (collapsible) -->
|
||||
<details class="group">
|
||||
<summary class="text-xs font-semibold text-muted-foreground uppercase tracking-wide cursor-pointer flex items-center gap-1">
|
||||
<i data-lucide="chevron-right" class="w-3 h-3 transition-transform group-open:rotate-90"></i>
|
||||
Raw JSON
|
||||
</summary>
|
||||
<pre id="mcpEditJsonPreview" class="mt-2 bg-muted rounded-lg p-3 text-xs font-mono overflow-x-auto">${escapeHtml(JSON.stringify(serverConfig, null, 2))}</pre>
|
||||
</details>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
${!isReadOnly ? `
|
||||
<div class="flex items-center justify-between pt-4 border-t border-border">
|
||||
<div class="flex items-center gap-2">
|
||||
${serverSource === 'project' || isCodex ? `
|
||||
<button onclick="deleteMcpFromEdit()" class="px-4 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors flex items-center gap-1.5">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
${t('mcp.editModal.delete')}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="closeMcpEditModal()" class="px-4 py-2 text-sm text-muted-foreground hover:bg-muted rounded-lg transition-colors">
|
||||
${t('common.cancel')}
|
||||
</button>
|
||||
<button onclick="saveMcpEdit()" class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1.5">
|
||||
<i data-lucide="check" class="w-4 h-4"></i>
|
||||
${t('mcp.editModal.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="flex items-center justify-end pt-4 border-t border-border">
|
||||
<button onclick="closeMcpEditModal()" class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-muted/80 transition-colors">
|
||||
${t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Update modal title
|
||||
const modalTitle = modal.querySelector('h2');
|
||||
if (modalTitle) {
|
||||
modalTitle.textContent = isReadOnly ? t('mcp.detailsModal.title') : t('mcp.editModal.title');
|
||||
}
|
||||
|
||||
// Show modal
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Re-initialize Lucide icons in modal
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
// Add input listeners to update JSON preview
|
||||
if (!isReadOnly) {
|
||||
['mcpEditCommand', 'mcpEditArgs', 'mcpEditEnv', 'mcpEditEnabledTools'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.addEventListener('input', updateMcpEditJsonPreview);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeMcpEditModal() {
|
||||
const modal = document.getElementById('mcpDetailsModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
mcpEditContext = { serverName: null, serverConfig: null, serverSource: null, cliType: 'claude' };
|
||||
}
|
||||
|
||||
function updateMcpEditJsonPreview() {
|
||||
const preview = document.getElementById('mcpEditJsonPreview');
|
||||
if (!preview) return;
|
||||
|
||||
const config = buildConfigFromEditForm();
|
||||
preview.textContent = JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
function buildConfigFromEditForm() {
|
||||
const command = document.getElementById('mcpEditCommand')?.value.trim() || '';
|
||||
const argsText = document.getElementById('mcpEditArgs')?.value.trim() || '';
|
||||
const envText = document.getElementById('mcpEditEnv')?.value.trim() || '';
|
||||
const enabledToolsEl = document.getElementById('mcpEditEnabledTools');
|
||||
|
||||
// Build config
|
||||
const config = {};
|
||||
|
||||
// Command or URL
|
||||
if (mcpEditContext.serverConfig?.url) {
|
||||
config.url = command;
|
||||
} else {
|
||||
config.command = command;
|
||||
}
|
||||
|
||||
// Args
|
||||
if (argsText) {
|
||||
config.args = argsText.split('\n').map(a => a.trim()).filter(a => a);
|
||||
}
|
||||
|
||||
// Env
|
||||
if (envText) {
|
||||
config.env = {};
|
||||
envText.split('\n').forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && trimmed.includes('=')) {
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
const value = trimmed.substring(eqIndex + 1).trim();
|
||||
if (key) {
|
||||
config.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Codex-specific: enabled_tools
|
||||
if (enabledToolsEl) {
|
||||
const toolsText = enabledToolsEl.value.trim();
|
||||
if (toolsText) {
|
||||
config.enabled_tools = toolsText.split('\n').map(t => t.trim()).filter(t => t);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function saveMcpEdit() {
|
||||
const newName = document.getElementById('mcpEditName')?.value.trim();
|
||||
if (!newName) {
|
||||
showRefreshToast(t('mcp.editModal.nameRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newConfig = buildConfigFromEditForm();
|
||||
|
||||
if (!newConfig.command && !newConfig.url) {
|
||||
showRefreshToast(t('mcp.editModal.commandRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const { serverName, serverSource, cliType } = mcpEditContext;
|
||||
const nameChanged = newName !== serverName;
|
||||
|
||||
try {
|
||||
if (cliType === 'codex') {
|
||||
// Codex MCP update
|
||||
// If name changed, remove old and add new
|
||||
if (nameChanged) {
|
||||
await removeCodexMcpServer(serverName);
|
||||
}
|
||||
await addCodexMcpServer(newName, newConfig);
|
||||
} else if (serverSource === 'global') {
|
||||
// Global MCP update
|
||||
if (nameChanged) {
|
||||
await removeGlobalMcpServer(serverName);
|
||||
}
|
||||
await addGlobalMcpServer(newName, newConfig);
|
||||
} else if (serverSource === 'project') {
|
||||
// Project MCP update
|
||||
if (nameChanged) {
|
||||
await removeMcpServerFromProject(serverName);
|
||||
}
|
||||
await copyMcpServerToProject(newName, newConfig, 'mcp');
|
||||
}
|
||||
|
||||
closeMcpEditModal();
|
||||
showRefreshToast(t('mcp.editModal.saved', { name: newName }), 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to save MCP edit:', err);
|
||||
showRefreshToast(t('mcp.editModal.saveFailed') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMcpFromEdit() {
|
||||
const { serverName, serverSource, cliType } = mcpEditContext;
|
||||
|
||||
if (!confirm(t('mcp.editModal.deleteConfirm', { name: serverName }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (cliType === 'codex') {
|
||||
await removeCodexMcpServer(serverName);
|
||||
} else if (serverSource === 'global') {
|
||||
await removeGlobalMcpServer(serverName);
|
||||
} else if (serverSource === 'project') {
|
||||
await removeMcpServerFromProject(serverName);
|
||||
}
|
||||
|
||||
closeMcpEditModal();
|
||||
showRefreshToast(t('mcp.editModal.deleted', { name: serverName }), 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to delete MCP:', err);
|
||||
showRefreshToast(t('mcp.editModal.deleteFailed') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
@@ -345,6 +345,7 @@ function renderHotspotsColumn() {
|
||||
|
||||
var mostRead = memoryStats.mostRead || [];
|
||||
var mostEdited = memoryStats.mostEdited || [];
|
||||
var mostMentioned = memoryStats.mostMentioned || [];
|
||||
|
||||
container.innerHTML = '<div class="memory-section">' +
|
||||
'<div class="section-header">' +
|
||||
@@ -371,6 +372,10 @@ function renderHotspotsColumn() {
|
||||
'<h4 class="hotspot-list-title"><i data-lucide="pencil" class="w-3.5 h-3.5"></i> ' + t('memory.mostEdited') + '</h4>' +
|
||||
renderHotspotList(mostEdited, 'edit') +
|
||||
'</div>' +
|
||||
'<div class="hotspot-list-container">' +
|
||||
'<h4 class="hotspot-list-title"><i data-lucide="message-circle" class="w-3.5 h-3.5"></i> ' + t('memory.mostMentioned') + '</h4>' +
|
||||
renderTopicList(mostMentioned) +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
@@ -380,7 +385,7 @@ function renderHotspotsColumn() {
|
||||
function renderHotspotList(items, type) {
|
||||
if (!items || items.length === 0) {
|
||||
return '<div class="hotspot-empty">' +
|
||||
'<i data-lucide="inbox" class="w-6 h-6"></i>' +
|
||||
'<i data-lucide="inbox" class="w-5 h-5"></i>' +
|
||||
'<p>' + t('memory.noData') + '</p>' +
|
||||
'</div>';
|
||||
}
|
||||
@@ -407,6 +412,34 @@ function renderHotspotList(items, type) {
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderTopicList(items) {
|
||||
if (!items || items.length === 0) {
|
||||
return '<div class="hotspot-empty">' +
|
||||
'<i data-lucide="inbox" class="w-5 h-5"></i>' +
|
||||
'<p>' + t('memory.noData') + '</p>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return '<div class="hotspot-list topic-list">' +
|
||||
items.map(function(item, index) {
|
||||
var heat = item.heat || item.count || 0;
|
||||
var heatClass = heat > 10 ? 'high' : heat > 5 ? 'medium' : 'low';
|
||||
var preview = item.preview || item.topic || 'Unknown';
|
||||
|
||||
return '<div class="hotspot-item topic-item">' +
|
||||
'<div class="hotspot-rank">' + (index + 1) + '</div>' +
|
||||
'<div class="hotspot-info">' +
|
||||
'<div class="hotspot-name topic-preview" title="' + escapeHtml(item.topic || '') + '">' + escapeHtml(preview) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="hotspot-heat ' + heatClass + '">' +
|
||||
'<span class="heat-badge">' + heat + '</span>' +
|
||||
'<i data-lucide="message-circle" class="w-3 h-3"></i>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ========== Center Column: Memory Graph ==========
|
||||
// Store graph state for zoom/pan
|
||||
var graphZoom = null;
|
||||
@@ -458,7 +491,7 @@ function renderMemoryGraph(graphData) {
|
||||
var container = document.getElementById('memoryGraphSvg');
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="graph-empty-state">' +
|
||||
'<i data-lucide="network" class="w-12 h-12"></i>' +
|
||||
'<i data-lucide="network" class="w-8 h-8"></i>' +
|
||||
'<p>' + t('memory.noGraphData') + '</p>' +
|
||||
'</div>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
@@ -471,7 +504,7 @@ function renderMemoryGraph(graphData) {
|
||||
var container = document.getElementById('memoryGraphSvg');
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="graph-error">' +
|
||||
'<i data-lucide="alert-triangle" class="w-8 h-8"></i>' +
|
||||
'<i data-lucide="alert-triangle" class="w-6 h-6"></i>' +
|
||||
'<p>' + t('memory.d3NotLoaded') + '</p>' +
|
||||
'</div>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
@@ -767,7 +800,7 @@ function renderContextColumn() {
|
||||
function renderContextTimeline(prompts) {
|
||||
if (!prompts || prompts.length === 0) {
|
||||
return '<div class="context-empty">' +
|
||||
'<i data-lucide="inbox" class="w-8 h-8"></i>' +
|
||||
'<i data-lucide="inbox" class="w-6 h-6"></i>' +
|
||||
'<p>' + t('memory.noRecentActivity') + '</p>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
</button>
|
||||
<div class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
<i data-lucide="workflow" class="w-6 h-6"></i>
|
||||
<span class="hidden sm:inline">Claude Code Workflow</span>
|
||||
<span data-i18n="app.brand">Claude Code Workflow</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export type CliToolName = 'gemini' | 'qwen' | 'codex';
|
||||
export const PREDEFINED_MODELS: Record<CliToolName, string[]> = {
|
||||
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
|
||||
qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'],
|
||||
codex: ['gpt5-codex', 'gpt-4.1', 'o4-mini', 'o3']
|
||||
codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3']
|
||||
};
|
||||
|
||||
export const DEFAULT_CONFIG: CliConfig = {
|
||||
@@ -45,8 +45,8 @@ export const DEFAULT_CONFIG: CliConfig = {
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
primaryModel: 'gpt5-codex',
|
||||
secondaryModel: 'gpt5-codex'
|
||||
primaryModel: 'gpt-5.2',
|
||||
secondaryModel: 'gpt-5.2'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,6 +319,8 @@ function buildCommand(params: {
|
||||
break;
|
||||
|
||||
case 'codex':
|
||||
// Codex does NOT support stdin - prompt must be passed as command line argument
|
||||
useStdin = false;
|
||||
// Native resume: codex resume <uuid> [prompt] or --last
|
||||
if (nativeResume?.enabled) {
|
||||
args.push('resume');
|
||||
@@ -343,6 +345,10 @@ function buildCommand(params: {
|
||||
args.push('--add-dir', addDir);
|
||||
}
|
||||
}
|
||||
// Add prompt as positional argument for resume
|
||||
if (prompt) {
|
||||
args.push(prompt);
|
||||
}
|
||||
} else {
|
||||
// Standard exec mode
|
||||
args.push('exec');
|
||||
@@ -362,6 +368,10 @@ function buildCommand(params: {
|
||||
args.push('--add-dir', addDir);
|
||||
}
|
||||
}
|
||||
// Add prompt as positional argument (codex exec "prompt")
|
||||
if (prompt) {
|
||||
args.push(prompt);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -379,9 +389,11 @@ function buildCommand(params: {
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
// Permission modes for write/auto
|
||||
// Permission modes: write/auto → bypassPermissions, analysis → default
|
||||
if (mode === 'write' || mode === 'auto') {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
args.push('--permission-mode', 'bypassPermissions');
|
||||
} else {
|
||||
args.push('--permission-mode', 'default');
|
||||
}
|
||||
// Output format for better parsing
|
||||
args.push('--output-format', 'text');
|
||||
@@ -570,7 +582,7 @@ async function executeCliTool(
|
||||
|
||||
// Determine working directory early (needed for conversation lookup)
|
||||
const workingDir = cd || process.cwd();
|
||||
const historyDir = ensureHistoryDir(workingDir);
|
||||
ensureHistoryDir(workingDir); // Ensure history directory exists
|
||||
|
||||
// Get SQLite store for native session lookup
|
||||
const store = await getSqliteStore(workingDir);
|
||||
@@ -722,16 +734,8 @@ async function executeCliTool(
|
||||
}
|
||||
}
|
||||
|
||||
// Determine effective model (use config's primaryModel if not explicitly provided)
|
||||
let effectiveModel = model;
|
||||
if (!effectiveModel) {
|
||||
try {
|
||||
effectiveModel = getPrimaryModel(workingDir, tool);
|
||||
} catch {
|
||||
// Config not available, use default (let the CLI tool use its own default)
|
||||
effectiveModel = undefined;
|
||||
}
|
||||
}
|
||||
// Only pass model if explicitly provided - let CLI tools use their own defaults
|
||||
const effectiveModel = model;
|
||||
|
||||
// Build command
|
||||
const { command, args, useStdin } = buildCommand({
|
||||
@@ -864,7 +868,7 @@ async function executeCliTool(
|
||||
// Save all source conversations
|
||||
try {
|
||||
for (const conv of savedConversations) {
|
||||
saveConversation(historyDir, conv);
|
||||
saveConversation(workingDir, conv);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CLI Executor] Failed to save merged histories:', (err as Error).message);
|
||||
@@ -906,7 +910,7 @@ async function executeCliTool(
|
||||
};
|
||||
// Save merged conversation
|
||||
try {
|
||||
saveConversation(historyDir, conversation);
|
||||
saveConversation(workingDir, conversation);
|
||||
} catch (err) {
|
||||
console.error('[CLI Executor] Failed to save merged conversation:', (err as Error).message);
|
||||
}
|
||||
@@ -937,7 +941,7 @@ async function executeCliTool(
|
||||
};
|
||||
// Try to save conversation to history
|
||||
try {
|
||||
saveConversation(historyDir, conversation);
|
||||
saveConversation(workingDir, conversation);
|
||||
} catch (err) {
|
||||
// Non-fatal: continue even if history save fails
|
||||
console.error('[CLI Executor] Failed to save history:', (err as Error).message);
|
||||
@@ -945,7 +949,8 @@ async function executeCliTool(
|
||||
}
|
||||
|
||||
// Track native session after execution (async, non-blocking)
|
||||
trackNewSession(tool, new Date(startTime), workingDir)
|
||||
// Pass prompt for precise matching in parallel execution scenarios
|
||||
trackNewSession(tool, new Date(startTime), workingDir, prompt)
|
||||
.then((nativeSession) => {
|
||||
if (nativeSession) {
|
||||
// Save native session mapping
|
||||
@@ -1211,8 +1216,8 @@ export function getExecutionHistory(baseDir: string, options: {
|
||||
* Get conversation detail by ID (returns ConversationRecord)
|
||||
*/
|
||||
export function getConversationDetail(baseDir: string, conversationId: string): ConversationRecord | null {
|
||||
const paths = StoragePaths.project(baseDir);
|
||||
return loadConversation(paths.cliHistory, conversationId);
|
||||
// Pass baseDir directly - loadConversation will resolve the correct storage path
|
||||
return loadConversation(baseDir, conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
|
||||
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
|
||||
import { StoragePaths, ensureStorageDir, getProjectId } from '../config/storage-paths.js';
|
||||
|
||||
// Types
|
||||
export interface ConversationTurn {
|
||||
@@ -239,10 +239,12 @@ export class CliHistoryStore {
|
||||
*/
|
||||
private migrateSchema(): void {
|
||||
try {
|
||||
// Check if category column exists
|
||||
// Check if columns exist
|
||||
const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>;
|
||||
const hasCategory = tableInfo.some(col => col.name === 'category');
|
||||
const hasParentExecutionId = tableInfo.some(col => col.name === 'parent_execution_id');
|
||||
const hasProjectRoot = tableInfo.some(col => col.name === 'project_root');
|
||||
const hasRelativePath = tableInfo.some(col => col.name === 'relative_path');
|
||||
|
||||
if (!hasCategory) {
|
||||
console.log('[CLI History] Migrating database: adding category column...');
|
||||
@@ -270,6 +272,28 @@ export class CliHistoryStore {
|
||||
}
|
||||
console.log('[CLI History] Migration complete: parent_execution_id column added');
|
||||
}
|
||||
|
||||
// Add hierarchical storage support columns
|
||||
if (!hasProjectRoot) {
|
||||
console.log('[CLI History] Migrating database: adding project_root column for hierarchical storage...');
|
||||
this.db.exec(`
|
||||
ALTER TABLE conversations ADD COLUMN project_root TEXT;
|
||||
`);
|
||||
try {
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_conversations_project_root ON conversations(project_root);`);
|
||||
} catch (indexErr) {
|
||||
console.warn('[CLI History] Project root index creation warning:', (indexErr as Error).message);
|
||||
}
|
||||
console.log('[CLI History] Migration complete: project_root column added');
|
||||
}
|
||||
|
||||
if (!hasRelativePath) {
|
||||
console.log('[CLI History] Migrating database: adding relative_path column for hierarchical storage...');
|
||||
this.db.exec(`
|
||||
ALTER TABLE conversations ADD COLUMN relative_path TEXT;
|
||||
`);
|
||||
console.log('[CLI History] Migration complete: relative_path column added');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CLI History] Migration error:', (err as Error).message);
|
||||
// Don't throw - allow the store to continue working with existing schema
|
||||
@@ -1115,17 +1139,21 @@ export class CliHistoryStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance cache
|
||||
// Singleton instance cache - keyed by normalized project ID for consistency
|
||||
const storeCache = new Map<string, CliHistoryStore>();
|
||||
|
||||
/**
|
||||
* Get or create a store instance for a directory
|
||||
* Uses normalized project ID as cache key to handle path casing differences
|
||||
*/
|
||||
export function getHistoryStore(baseDir: string): CliHistoryStore {
|
||||
if (!storeCache.has(baseDir)) {
|
||||
storeCache.set(baseDir, new CliHistoryStore(baseDir));
|
||||
// Use getProjectId to normalize path for consistent cache key
|
||||
const cacheKey = getProjectId(baseDir);
|
||||
|
||||
if (!storeCache.has(cacheKey)) {
|
||||
storeCache.set(cacheKey, new CliHistoryStore(baseDir));
|
||||
}
|
||||
return storeCache.get(baseDir)!;
|
||||
return storeCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,18 +70,60 @@ abstract class SessionDiscoverer {
|
||||
|
||||
/**
|
||||
* Track new session created during execution
|
||||
* @param beforeTimestamp - Filter sessions created after this time
|
||||
* @param workingDir - Project working directory
|
||||
* @param prompt - Optional prompt content for precise matching (fallback)
|
||||
*/
|
||||
async trackNewSession(
|
||||
beforeTimestamp: Date,
|
||||
workingDir: string
|
||||
workingDir: string,
|
||||
prompt?: string
|
||||
): Promise<NativeSession | null> {
|
||||
const sessions = this.getSessions({
|
||||
workingDir,
|
||||
afterTimestamp: beforeTimestamp,
|
||||
limit: 1
|
||||
limit: 10 // Get more candidates for prompt matching
|
||||
});
|
||||
return sessions.length > 0 ? sessions[0] : null;
|
||||
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
// If only one session or no prompt provided, return the latest
|
||||
if (sessions.length === 1 || !prompt) {
|
||||
return sessions[0];
|
||||
}
|
||||
|
||||
// Try to match by prompt content (fallback for parallel execution)
|
||||
const matched = this.matchSessionByPrompt(sessions, prompt);
|
||||
return matched || sessions[0]; // Fallback to latest if no match
|
||||
}
|
||||
|
||||
/**
|
||||
* Match session by prompt content
|
||||
* Searches for the prompt in session's user messages
|
||||
*/
|
||||
matchSessionByPrompt(sessions: NativeSession[], prompt: string): NativeSession | null {
|
||||
// Normalize prompt for comparison (first 200 chars)
|
||||
const promptPrefix = prompt.substring(0, 200).trim();
|
||||
if (!promptPrefix) return null;
|
||||
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
const userMessage = this.extractFirstUserMessage(session.filePath);
|
||||
if (userMessage && userMessage.includes(promptPrefix)) {
|
||||
return session;
|
||||
}
|
||||
} catch {
|
||||
// Skip sessions that can't be read
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first user message from session file
|
||||
* Override in subclass for tool-specific format
|
||||
*/
|
||||
abstract extractFirstUserMessage(filePath: string): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,6 +199,23 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
|
||||
const sessions = this.getSessions();
|
||||
return sessions.find(s => s.sessionId === sessionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first user message from Gemini session file
|
||||
* Format: { "messages": [{ "type": "user", "content": "..." }] }
|
||||
*/
|
||||
extractFirstUserMessage(filePath: string): string | null {
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
if (content.messages && Array.isArray(content.messages)) {
|
||||
const userMsg = content.messages.find((m: { type: string }) => m.type === 'user');
|
||||
return userMsg?.content || null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,6 +389,46 @@ class QwenSessionDiscoverer extends SessionDiscoverer {
|
||||
const sessions = this.getSessions();
|
||||
return sessions.find(s => s.sessionId === sessionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first user message from Qwen session file
|
||||
* New format (.jsonl): { type: "user", message: { role: "user", parts: [{ text: "..." }] } }
|
||||
* Legacy format (.json): { "messages": [{ "type": "user", "content": "..." }] }
|
||||
*/
|
||||
extractFirstUserMessage(filePath: string): string | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
|
||||
// Check if JSONL (new format) or JSON (legacy)
|
||||
if (filePath.endsWith('.jsonl')) {
|
||||
// JSONL format - find first user message
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// New Qwen format: { type: "user", message: { parts: [{ text: "..." }] } }
|
||||
if (entry.type === 'user' && entry.message?.parts?.[0]?.text) {
|
||||
return entry.message.parts[0].text;
|
||||
}
|
||||
// Alternative format
|
||||
if (entry.role === 'user' && entry.content) {
|
||||
return entry.content;
|
||||
}
|
||||
} catch { /* skip invalid lines */ }
|
||||
}
|
||||
} else {
|
||||
// Legacy JSON format
|
||||
const data = JSON.parse(content);
|
||||
if (data.messages && Array.isArray(data.messages)) {
|
||||
const userMsg = data.messages.find((m: { type: string }) => m.type === 'user');
|
||||
return userMsg?.content || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -430,6 +529,32 @@ class CodexSessionDiscoverer extends SessionDiscoverer {
|
||||
const sessions = this.getSessions();
|
||||
return sessions.find(s => s.sessionId === sessionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first user message from Codex session file (.jsonl)
|
||||
* Format: {"type":"event_msg","payload":{"type":"user_message","message":"..."}}
|
||||
*/
|
||||
extractFirstUserMessage(filePath: string): string | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Look for user_message event
|
||||
if (entry.type === 'event_msg' &&
|
||||
entry.payload?.type === 'user_message' &&
|
||||
entry.payload?.message) {
|
||||
return entry.payload.message;
|
||||
}
|
||||
} catch { /* skip invalid lines */ }
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -462,15 +587,17 @@ class ClaudeSessionDiscoverer extends SessionDiscoverer {
|
||||
}
|
||||
|
||||
for (const projectHash of projectDirs) {
|
||||
const sessionsDir = join(this.basePath, projectHash, 'sessions');
|
||||
if (!existsSync(sessionsDir)) continue;
|
||||
// Claude Code stores session files directly in project folder (not in 'sessions' subdirectory)
|
||||
// e.g., ~/.claude/projects/D--Claude-dms3/<uuid>.jsonl
|
||||
const projectDir = join(this.basePath, projectHash);
|
||||
if (!existsSync(projectDir)) continue;
|
||||
|
||||
const sessionFiles = readdirSync(sessionsDir)
|
||||
const sessionFiles = readdirSync(projectDir)
|
||||
.filter(f => f.endsWith('.jsonl') || f.endsWith('.json'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(sessionsDir, f),
|
||||
stat: statSync(join(sessionsDir, f))
|
||||
path: join(projectDir, f),
|
||||
stat: statSync(join(projectDir, f))
|
||||
}))
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
|
||||
@@ -521,6 +648,35 @@ class ClaudeSessionDiscoverer extends SessionDiscoverer {
|
||||
const sessions = this.getSessions();
|
||||
return sessions.find(s => s.sessionId === sessionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first user message from Claude Code session file (.jsonl)
|
||||
* Format: {"type":"user","message":{"role":"user","content":"..."},"isMeta":false,...}
|
||||
*/
|
||||
extractFirstUserMessage(filePath: string): string | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Claude Code format: type="user", message.role="user", message.content="..."
|
||||
// Skip meta messages and command messages
|
||||
if (entry.type === 'user' &&
|
||||
entry.message?.role === 'user' &&
|
||||
entry.message?.content &&
|
||||
!entry.isMeta &&
|
||||
!entry.message.content.startsWith('<command-')) {
|
||||
return entry.message.content;
|
||||
}
|
||||
} catch { /* skip invalid lines */ }
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton discoverers
|
||||
@@ -564,15 +720,20 @@ export function findNativeSessionById(
|
||||
|
||||
/**
|
||||
* Track new session created during execution
|
||||
* @param tool - CLI tool name (gemini, qwen, codex, claude)
|
||||
* @param beforeTimestamp - Filter sessions created after this time
|
||||
* @param workingDir - Project working directory
|
||||
* @param prompt - Optional prompt for precise matching in parallel execution
|
||||
*/
|
||||
export async function trackNewSession(
|
||||
tool: string,
|
||||
beforeTimestamp: Date,
|
||||
workingDir: string
|
||||
workingDir: string,
|
||||
prompt?: string
|
||||
): Promise<NativeSession | null> {
|
||||
const discoverer = discoverers[tool];
|
||||
if (!discoverer) return null;
|
||||
return discoverer.trackNewSession(beforeTimestamp, workingDir);
|
||||
return discoverer.trackNewSession(beforeTimestamp, workingDir, prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -137,47 +137,112 @@ function getDbRecordCount(dbPath: string, tableName: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics for a specific project by ID
|
||||
* Check if a directory is a project data directory
|
||||
* A project data directory contains at least one of: cli-history, memory, cache, config
|
||||
*/
|
||||
export function getProjectStorageStats(projectId: string): ProjectStorageStats {
|
||||
const paths = StoragePaths.projectById(projectId);
|
||||
function isProjectDataDirectory(dirPath: string): boolean {
|
||||
const dataMarkers = ['cli-history', 'memory', 'cache', 'config'];
|
||||
return dataMarkers.some(marker => existsSync(join(dirPath, marker)));
|
||||
}
|
||||
|
||||
const cliHistorySize = getDirSize(paths.cliHistory);
|
||||
const memorySize = getDirSize(paths.memory);
|
||||
const cacheSize = getDirSize(paths.cache);
|
||||
const configSize = getDirSize(paths.config);
|
||||
/**
|
||||
* Get storage statistics for a specific project by path
|
||||
* @param projectId - Project ID (can be hierarchical like "parent/child")
|
||||
* @param projectDir - Actual directory path in storage
|
||||
*/
|
||||
function getProjectStats(projectId: string, projectDir: string): ProjectStorageStats {
|
||||
const cliHistoryDir = join(projectDir, 'cli-history');
|
||||
const memoryDir = join(projectDir, 'memory');
|
||||
const cacheDir = join(projectDir, 'cache');
|
||||
const configDir = join(projectDir, 'config');
|
||||
|
||||
const cliHistorySize = getDirSize(cliHistoryDir);
|
||||
const memorySize = getDirSize(memoryDir);
|
||||
const cacheSize = getDirSize(cacheDir);
|
||||
const configSize = getDirSize(configDir);
|
||||
|
||||
let recordCount: number | undefined;
|
||||
if (existsSync(paths.historyDb)) {
|
||||
recordCount = getDbRecordCount(paths.historyDb, 'conversations');
|
||||
const historyDb = join(cliHistoryDir, 'history.db');
|
||||
if (existsSync(historyDb)) {
|
||||
recordCount = getDbRecordCount(historyDb, 'conversations');
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
totalSize: cliHistorySize + memorySize + cacheSize + configSize,
|
||||
cliHistory: {
|
||||
exists: existsSync(paths.cliHistory),
|
||||
exists: existsSync(cliHistoryDir),
|
||||
size: cliHistorySize,
|
||||
recordCount
|
||||
},
|
||||
memory: {
|
||||
exists: existsSync(paths.memory),
|
||||
exists: existsSync(memoryDir),
|
||||
size: memorySize
|
||||
},
|
||||
cache: {
|
||||
exists: existsSync(paths.cache),
|
||||
exists: existsSync(cacheDir),
|
||||
size: cacheSize
|
||||
},
|
||||
config: {
|
||||
exists: existsSync(paths.config),
|
||||
exists: existsSync(configDir),
|
||||
size: configSize
|
||||
},
|
||||
lastModified: getLatestModTime(paths.root)
|
||||
lastModified: getLatestModTime(projectDir)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics for a specific project by ID (legacy)
|
||||
*/
|
||||
export function getProjectStorageStats(projectId: string): ProjectStorageStats {
|
||||
const paths = StoragePaths.projectById(projectId);
|
||||
return getProjectStats(projectId, paths.root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan project directory for hierarchical structure
|
||||
* @param basePath - Base directory to scan
|
||||
* @param relativePath - Relative path from projects root
|
||||
* @param results - Array to accumulate results
|
||||
*/
|
||||
function scanProjectDirectory(
|
||||
basePath: string,
|
||||
relativePath: string,
|
||||
results: ProjectStorageStats[]
|
||||
): void {
|
||||
if (!existsSync(basePath)) return;
|
||||
|
||||
try {
|
||||
const entries = readdirSync(basePath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const fullPath = join(basePath, entry.name);
|
||||
const currentRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
|
||||
// Check if this is a project data directory
|
||||
if (isProjectDataDirectory(fullPath)) {
|
||||
const projectId = currentRelPath;
|
||||
const stats = getProjectStats(projectId, fullPath);
|
||||
results.push(stats);
|
||||
}
|
||||
|
||||
// Recursively scan subdirectories (excluding data directories)
|
||||
const dataDirs = ['cli-history', 'memory', 'cache', 'config'];
|
||||
if (!dataDirs.includes(entry.name)) {
|
||||
scanProjectDirectory(fullPath, currentRelPath, results);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore read errors
|
||||
if (process.env.DEBUG) console.error(`[Storage] Failed to scan ${basePath}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all storage statistics
|
||||
* Supports hierarchical project structure
|
||||
*/
|
||||
export function getStorageStats(): StorageStats {
|
||||
const rootPath = CCW_HOME;
|
||||
@@ -187,19 +252,10 @@ export function getStorageStats(): StorageStats {
|
||||
const mcpTemplatesPath = StoragePaths.global.mcpTemplates();
|
||||
const globalDbSize = getFileSize(mcpTemplatesPath);
|
||||
|
||||
// Projects
|
||||
// Projects - use recursive scanning for hierarchical structure
|
||||
const projects: ProjectStorageStats[] = [];
|
||||
if (existsSync(projectsDir)) {
|
||||
try {
|
||||
const entries = readdirSync(projectsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
projects.push(getProjectStorageStats(entry.name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors
|
||||
}
|
||||
scanProjectDirectory(projectsDir, '', projects);
|
||||
}
|
||||
|
||||
// Sort by last modified (most recent first)
|
||||
|
||||
293
ccw/tests/storage-paths.test.js
Normal file
293
ccw/tests/storage-paths.test.js
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Storage Paths Hierarchical Structure Tests
|
||||
* Tests for hierarchical storage path generation and migration
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { join, resolve } from 'path';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
|
||||
// Mock CCW_HOME for testing
|
||||
const TEST_CCW_HOME = join(homedir(), '.ccw-test');
|
||||
process.env.CCW_DATA_DIR = TEST_CCW_HOME;
|
||||
|
||||
// Import after setting env var
|
||||
import {
|
||||
detectHierarchy,
|
||||
getProjectPaths,
|
||||
clearHierarchyCache,
|
||||
getProjectId
|
||||
} from '../src/config/storage-paths.js';
|
||||
|
||||
describe('Storage Paths - Hierarchical Structure', () => {
|
||||
beforeEach(() => {
|
||||
// Clean test directory
|
||||
if (existsSync(TEST_CCW_HOME)) {
|
||||
rmSync(TEST_CCW_HOME, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TEST_CCW_HOME, { recursive: true });
|
||||
clearHierarchyCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
if (existsSync(TEST_CCW_HOME)) {
|
||||
rmSync(TEST_CCW_HOME, { recursive: true, force: true });
|
||||
}
|
||||
clearHierarchyCache();
|
||||
});
|
||||
|
||||
describe('Project ID Generation', () => {
|
||||
it('should generate consistent project IDs', () => {
|
||||
const path1 = 'D:\\Claude_dms3';
|
||||
const path2 = 'D:\\Claude_dms3';
|
||||
|
||||
const id1 = getProjectId(path1);
|
||||
const id2 = getProjectId(path2);
|
||||
|
||||
expect(id1).toBe(id2);
|
||||
expect(id1).toContain('d--claude_dms3');
|
||||
});
|
||||
|
||||
it('should handle different path formats', () => {
|
||||
// Test Windows path
|
||||
const winId = getProjectId('D:\\Claude_dms3');
|
||||
expect(winId).toBeTruthy();
|
||||
|
||||
// Test Unix-like path
|
||||
const unixId = getProjectId('/home/user/project');
|
||||
expect(unixId).toBeTruthy();
|
||||
|
||||
// Different paths should have different IDs
|
||||
expect(winId).not.toBe(unixId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hierarchy Detection', () => {
|
||||
it('should detect no parent for root project', () => {
|
||||
const hierarchy = detectHierarchy('D:\\Claude_dms3');
|
||||
|
||||
expect(hierarchy.parentId).toBeNull();
|
||||
expect(hierarchy.relativePath).toBe('');
|
||||
expect(hierarchy.currentId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should detect parent when parent storage exists', () => {
|
||||
// Create parent storage
|
||||
const parentPath = 'D:\\Claude_dms3';
|
||||
const parentId = getProjectId(parentPath);
|
||||
const parentStorageDir = join(TEST_CCW_HOME, 'projects', parentId);
|
||||
mkdirSync(parentStorageDir, { recursive: true });
|
||||
|
||||
// Detect hierarchy for child
|
||||
const childPath = 'D:\\Claude_dms3\\ccw';
|
||||
const hierarchy = detectHierarchy(childPath);
|
||||
|
||||
expect(hierarchy.parentId).toBe(parentId);
|
||||
expect(hierarchy.relativePath).toBe('ccw');
|
||||
});
|
||||
|
||||
it('should detect nested hierarchy', () => {
|
||||
// Create parent storage
|
||||
const rootPath = 'D:\\Claude_dms3';
|
||||
const rootId = getProjectId(rootPath);
|
||||
const rootStorageDir = join(TEST_CCW_HOME, 'projects', rootId);
|
||||
mkdirSync(rootStorageDir, { recursive: true });
|
||||
|
||||
// Detect hierarchy for nested child
|
||||
const nestedPath = 'D:\\Claude_dms3\\ccw\\src';
|
||||
const hierarchy = detectHierarchy(nestedPath);
|
||||
|
||||
expect(hierarchy.parentId).toBe(rootId);
|
||||
expect(hierarchy.relativePath).toBe('ccw/src');
|
||||
});
|
||||
|
||||
it('should cache detection results', () => {
|
||||
const path = 'D:\\Claude_dms3\\ccw';
|
||||
|
||||
const result1 = detectHierarchy(path);
|
||||
const result2 = detectHierarchy(path);
|
||||
|
||||
// Should return exact same object (cached)
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
|
||||
it('should clear cache when requested', () => {
|
||||
const path = 'D:\\Claude_dms3\\ccw';
|
||||
|
||||
const result1 = detectHierarchy(path);
|
||||
clearHierarchyCache();
|
||||
const result2 = detectHierarchy(path);
|
||||
|
||||
// Should return different object instances after cache clear
|
||||
expect(result1).not.toBe(result2);
|
||||
// But same values
|
||||
expect(result1.currentId).toBe(result2.currentId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hierarchical Path Generation', () => {
|
||||
it('should generate flat path for root project', () => {
|
||||
const projectPath = 'D:\\Claude_dms3';
|
||||
const paths = getProjectPaths(projectPath);
|
||||
|
||||
expect(paths.root).toContain('projects');
|
||||
expect(paths.root).toContain('d--claude_dms3');
|
||||
expect(paths.root).not.toContain('ccw');
|
||||
});
|
||||
|
||||
it('should generate hierarchical path when parent exists', () => {
|
||||
// Create parent storage
|
||||
const parentPath = 'D:\\Claude_dms3';
|
||||
const parentId = getProjectId(parentPath);
|
||||
const parentStorageDir = join(TEST_CCW_HOME, 'projects', parentId);
|
||||
mkdirSync(parentStorageDir, { recursive: true });
|
||||
|
||||
// Generate paths for child
|
||||
const childPath = 'D:\\Claude_dms3\\ccw';
|
||||
const paths = getProjectPaths(childPath);
|
||||
|
||||
expect(paths.root).toContain(parentId);
|
||||
expect(paths.root).toContain('ccw');
|
||||
expect(paths.root.endsWith('ccw')).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate nested hierarchical paths', () => {
|
||||
// Create parent storage
|
||||
const parentPath = 'D:\\Claude_dms3';
|
||||
const parentId = getProjectId(parentPath);
|
||||
const parentStorageDir = join(TEST_CCW_HOME, 'projects', parentId);
|
||||
mkdirSync(parentStorageDir, { recursive: true });
|
||||
|
||||
// Generate paths for nested child
|
||||
const nestedPath = 'D:\\Claude_dms3\\ccw\\src';
|
||||
const paths = getProjectPaths(nestedPath);
|
||||
|
||||
expect(paths.root).toContain(parentId);
|
||||
expect(paths.root).toContain('ccw');
|
||||
expect(paths.root).toContain('src');
|
||||
expect(paths.root.endsWith('src')).toBe(true);
|
||||
});
|
||||
|
||||
it('should include all required subdirectories', () => {
|
||||
const projectPath = 'D:\\Claude_dms3';
|
||||
const paths = getProjectPaths(projectPath);
|
||||
|
||||
expect(paths.cliHistory).toContain('cli-history');
|
||||
expect(paths.memory).toContain('memory');
|
||||
expect(paths.cache).toContain('cache');
|
||||
expect(paths.config).toContain('config');
|
||||
expect(paths.historyDb).toContain('history.db');
|
||||
expect(paths.memoryDb).toContain('memory.db');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Migration from Flat to Hierarchical', () => {
|
||||
it('should migrate flat structure to hierarchical', () => {
|
||||
// Setup: Create parent storage
|
||||
const parentPath = 'D:\\Claude_dms3';
|
||||
const parentId = getProjectId(parentPath);
|
||||
const parentStorageDir = join(TEST_CCW_HOME, 'projects', parentId);
|
||||
mkdirSync(parentStorageDir, { recursive: true });
|
||||
|
||||
// Create old flat structure for child
|
||||
const childPath = 'D:\\Claude_dms3\\ccw';
|
||||
const childId = getProjectId(childPath);
|
||||
const flatStorageDir = join(TEST_CCW_HOME, 'projects', childId);
|
||||
const flatCliHistoryDir = join(flatStorageDir, 'cli-history');
|
||||
mkdirSync(flatCliHistoryDir, { recursive: true });
|
||||
|
||||
// Create a test file to verify migration
|
||||
const testFile = join(flatCliHistoryDir, 'test.txt');
|
||||
writeFileSync(testFile, 'test data');
|
||||
|
||||
// Trigger migration by calling getProjectPaths
|
||||
const paths = getProjectPaths(childPath);
|
||||
|
||||
// Verify hierarchical path structure
|
||||
expect(paths.root).toContain('ccw');
|
||||
expect(paths.root.endsWith('ccw')).toBe(true);
|
||||
|
||||
// Verify data was migrated
|
||||
const migratedFile = join(paths.cliHistory, 'test.txt');
|
||||
expect(existsSync(migratedFile)).toBe(true);
|
||||
|
||||
// Verify old flat structure was deleted
|
||||
expect(existsSync(flatStorageDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle migration failures gracefully', () => {
|
||||
// Create scenario that might fail migration
|
||||
const parentPath = 'D:\\Claude_dms3';
|
||||
const parentId = getProjectId(parentPath);
|
||||
const parentStorageDir = join(TEST_CCW_HOME, 'projects', parentId);
|
||||
mkdirSync(parentStorageDir, { recursive: true });
|
||||
|
||||
const childPath = 'D:\\Claude_dms3\\ccw';
|
||||
|
||||
// Should not throw error even if migration fails
|
||||
expect(() => {
|
||||
const paths = getProjectPaths(childPath);
|
||||
expect(paths).toBeTruthy();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Normalization', () => {
|
||||
it('should normalize Windows path separators', () => {
|
||||
const hierarchy = detectHierarchy('D:\\Claude_dms3\\ccw\\src');
|
||||
|
||||
// Relative path should use forward slashes
|
||||
if (hierarchy.relativePath) {
|
||||
expect(hierarchy.relativePath).not.toContain('\\');
|
||||
expect(hierarchy.relativePath).toContain('/');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle trailing slashes', () => {
|
||||
const path1 = 'D:\\Claude_dms3\\ccw';
|
||||
const path2 = 'D:\\Claude_dms3\\ccw\\';
|
||||
|
||||
const id1 = getProjectId(path1);
|
||||
const id2 = getProjectId(path2);
|
||||
|
||||
// Should produce same ID regardless of trailing slash
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle very deep nesting', () => {
|
||||
// Create deep parent storage
|
||||
const parentPath = 'D:\\Claude_dms3';
|
||||
const parentId = getProjectId(parentPath);
|
||||
const parentStorageDir = join(TEST_CCW_HOME, 'projects', parentId);
|
||||
mkdirSync(parentStorageDir, { recursive: true });
|
||||
|
||||
// Generate paths for deeply nested child
|
||||
const deepPath = 'D:\\Claude_dms3\\a\\b\\c\\d\\e';
|
||||
const paths = getProjectPaths(deepPath);
|
||||
|
||||
expect(paths.root).toContain(parentId);
|
||||
expect(paths.root).toContain('a');
|
||||
expect(paths.root).toContain('e');
|
||||
});
|
||||
|
||||
it('should handle special characters in path names', () => {
|
||||
const specialPath = 'D:\\Claude_dms3\\my-project_v2';
|
||||
const id = getProjectId(specialPath);
|
||||
|
||||
expect(id).toBeTruthy();
|
||||
expect(id).toContain('my-project_v2');
|
||||
});
|
||||
|
||||
it('should handle relative paths by resolving them', () => {
|
||||
const relativePath = './ccw';
|
||||
const paths = getProjectPaths(relativePath);
|
||||
|
||||
// Should resolve to absolute path
|
||||
expect(paths.root).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user