From 0311d63b7d4da5df53bba11c3d9cd711bfa7f87c Mon Sep 17 00:00:00 2001 From: catlog22 Date: Thu, 18 Dec 2025 14:58:20 +0800 Subject: [PATCH] feat: Enhance graph exploration with file and module filtering options --- .claude/workflows/context-requirements.md | 4 +- ccw/src/core/routes/graph-routes.ts | 128 ++++++++++++++++-- ccw/src/templates/dashboard-js/i18n.js | 12 ++ .../dashboard-js/views/core-memory.js | 2 +- .../dashboard-js/views/graph-explorer.js | 110 ++++++++++++++- ccw/src/tools/cli-executor.ts | 25 +--- 6 files changed, 245 insertions(+), 36 deletions(-) diff --git a/.claude/workflows/context-requirements.md b/.claude/workflows/context-requirements.md index bc19f335..932a2cf1 100644 --- a/.claude/workflows/context-requirements.md +++ b/.claude/workflows/context-requirements.md @@ -15,11 +15,9 @@ Before implementation, always: ```javascript smart_search(query="authentication logic") // Auto mode (recommended) smart_search(action="init", path=".") // First-time setup -smart_search(query="LoginUser", mode="exact") // Precise matching -smart_search(query="import", mode="ripgrep") // Fast, no index ``` -**Modes**: `auto` (intelligent routing), `hybrid` (best quality), `exact` (FTS), `ripgrep` (fast) +**Modes**: `auto` (intelligent routing), `hybrid` (best quality), `exact` (FTS) --- diff --git a/ccw/src/core/routes/graph-routes.ts b/ccw/src/core/routes/graph-routes.ts index 0059b293..554b63a8 100644 --- a/ccw/src/core/routes/graph-routes.ts +++ b/ccw/src/core/routes/graph-routes.ts @@ -167,8 +167,11 @@ function mapRelationType(relType: string): string { /** * Query symbols from all codex-lens databases (hierarchical structure) + * @param projectPath Root project path + * @param fileFilter Optional file path filter (supports wildcards) + * @param moduleFilter Optional module/directory filter */ -async function querySymbols(projectPath: string): Promise { +async function querySymbols(projectPath: string, fileFilter?: string, moduleFilter?: string): Promise { const mapper = new PathMapper(); const rootDbPath = mapper.sourceToIndexDb(projectPath); const indexRoot = rootDbPath.replace(/[\\/]_index\.db$/, ''); @@ -190,7 +193,21 @@ async function querySymbols(projectPath: string): Promise { try { const db = Database(dbPath, { readonly: true }); - const rows = db.prepare(` + // Build WHERE clause for filtering + let whereClause = ''; + const params: string[] = []; + + if (fileFilter) { + const sanitized = sanitizeForLike(fileFilter); + whereClause = 'WHERE f.full_path LIKE ?'; + params.push(`%${sanitized}%`); + } else if (moduleFilter) { + const sanitized = sanitizeForLike(moduleFilter); + whereClause = 'WHERE f.full_path LIKE ?'; + params.push(`${sanitized}%`); + } + + const query = ` SELECT s.id, s.name, @@ -199,8 +216,11 @@ async function querySymbols(projectPath: string): Promise { f.full_path as file FROM symbols s JOIN files f ON s.file_id = f.id + ${whereClause} ORDER BY f.full_path, s.start_line - `).all(); + `; + + const rows = params.length > 0 ? db.prepare(query).all(...params) : db.prepare(query).all(); db.close(); @@ -223,8 +243,11 @@ async function querySymbols(projectPath: string): Promise { /** * Query code relationships from all codex-lens databases (hierarchical structure) + * @param projectPath Root project path + * @param fileFilter Optional file path filter (supports wildcards) + * @param moduleFilter Optional module/directory filter */ -async function queryRelationships(projectPath: string): Promise { +async function queryRelationships(projectPath: string, fileFilter?: string, moduleFilter?: string): Promise { const mapper = new PathMapper(); const rootDbPath = mapper.sourceToIndexDb(projectPath); const indexRoot = rootDbPath.replace(/[\\/]_index\.db$/, ''); @@ -246,7 +269,21 @@ async function queryRelationships(projectPath: string): Promise { try { const db = Database(dbPath, { readonly: true }); - const rows = db.prepare(` + // Build WHERE clause for filtering + let whereClause = ''; + const params: string[] = []; + + if (fileFilter) { + const sanitized = sanitizeForLike(fileFilter); + whereClause = 'WHERE f.full_path LIKE ?'; + params.push(`%${sanitized}%`); + } else if (moduleFilter) { + const sanitized = sanitizeForLike(moduleFilter); + whereClause = 'WHERE f.full_path LIKE ?'; + params.push(`${sanitized}%`); + } + + const query = ` SELECT s.name as source_name, s.start_line as source_line, @@ -257,8 +294,11 @@ async function queryRelationships(projectPath: string): Promise { FROM code_relationships r JOIN symbols s ON r.source_symbol_id = s.id JOIN files f ON s.file_id = f.id + ${whereClause} ORDER BY f.full_path, s.start_line - `).all(); + `; + + const rows = params.length > 0 ? db.prepare(query).all(...params) : db.prepare(query).all(); db.close(); @@ -384,6 +424,8 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise { const projectPath = validateProjectPath(rawPath, initialPath); const limitStr = url.searchParams.get('limit') || '1000'; const limit = Math.min(parseInt(limitStr, 10) || 1000, 5000); // Max 5000 nodes + const fileFilter = url.searchParams.get('file') || undefined; + const moduleFilter = url.searchParams.get('module') || undefined; if (!projectPath) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -392,14 +434,15 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise { } try { - const allNodes = await querySymbols(projectPath); + const allNodes = await querySymbols(projectPath, fileFilter, moduleFilter); const nodes = allNodes.slice(0, limit); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ nodes, total: allNodes.length, limit, - hasMore: allNodes.length > limit + hasMore: allNodes.length > limit, + filters: { file: fileFilter, module: moduleFilter } })); } catch (err) { console.error(`[Graph] Error fetching nodes:`, err); @@ -415,6 +458,8 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise { const projectPath = validateProjectPath(rawPath, initialPath); const limitStr = url.searchParams.get('limit') || '2000'; const limit = Math.min(parseInt(limitStr, 10) || 2000, 10000); // Max 10000 edges + const fileFilter = url.searchParams.get('file') || undefined; + const moduleFilter = url.searchParams.get('module') || undefined; if (!projectPath) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -423,14 +468,15 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise { } try { - const allEdges = await queryRelationships(projectPath); + const allEdges = await queryRelationships(projectPath, fileFilter, moduleFilter); const edges = allEdges.slice(0, limit); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ edges, total: allEdges.length, limit, - hasMore: allEdges.length > limit + hasMore: allEdges.length > limit, + filters: { file: fileFilter, module: moduleFilter } })); } catch (err) { console.error(`[Graph] Error fetching edges:`, err); @@ -440,6 +486,68 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise { return true; } + // API: Get available files and modules for filtering + if (pathname === '/api/graph/files') { + 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', files: [], modules: [] })); + return true; + } + + try { + const mapper = new PathMapper(); + const rootDbPath = mapper.sourceToIndexDb(projectPath); + const indexRoot = rootDbPath.replace(/[\\/]_index\.db$/, ''); + + if (!existsSync(indexRoot)) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ files: [], modules: [] })); + return true; + } + + const dbPaths = findAllIndexDbs(indexRoot); + const filesSet = new Set(); + const modulesSet = new Set(); + + for (const dbPath of dbPaths) { + try { + const db = Database(dbPath, { readonly: true }); + const rows = db.prepare(`SELECT DISTINCT full_path FROM files`).all(); + db.close(); + + rows.forEach((row: any) => { + const filePath = row.full_path; + filesSet.add(filePath); + + // Extract module path (directory) + const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); + if (lastSlash > 0) { + const modulePath = filePath.substring(0, lastSlash); + modulesSet.add(modulePath); + } + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[Graph] Failed to query files from ${dbPath}: ${message}`); + } + } + + const files = Array.from(filesSet).sort(); + const modules = Array.from(modulesSet).sort(); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ files, modules })); + } catch (err) { + console.error(`[Graph] Error fetching files:`, err); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to fetch files and modules', files: [], modules: [] })); + } + return true; + } + // API: Impact Analysis - Get impact analysis for a symbol if (pathname === '/api/graph/impact') { const rawPath = url.searchParams.get('path') || initialPath; diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 1fdacc79..fb696190 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -1108,6 +1108,12 @@ const i18n = { 'graph.references': 'references', 'graph.affectedSymbols': 'Affected Symbols', 'graph.depth': 'Depth', + 'graph.scope': 'Scope', + 'graph.allFiles': 'All Files', + 'graph.byModule': 'By Module', + 'graph.byFile': 'By File', + 'graph.selectModule': 'Select a module...', + 'graph.selectFile': 'Select a file...', // CLI Sync (used in claude-manager.js) 'claude.cliSync': 'CLI Auto-Sync', @@ -2300,6 +2306,12 @@ const i18n = { 'graph.references': '引用', 'graph.symbolType': '符号类型', 'graph.affectedSymbols': '受影响符号', + 'graph.scope': '范围', + 'graph.allFiles': '所有文件', + 'graph.byModule': '按模块', + 'graph.byFile': '按文件', + 'graph.selectModule': '选择模块...', + 'graph.selectFile': '选择文件...', // CLI Sync (used in claude-manager.js) 'claude.cliSync': 'CLI 自动同步', diff --git a/ccw/src/templates/dashboard-js/views/core-memory.js b/ccw/src/templates/dashboard-js/views/core-memory.js index 6b4aab08..aa940b9d 100644 --- a/ccw/src/templates/dashboard-js/views/core-memory.js +++ b/ccw/src/templates/dashboard-js/views/core-memory.js @@ -8,7 +8,7 @@ var coreMemGraphZoom = null; var coreMemGraphSimulation = null; async function renderCoreMemoryView() { - const content = document.getElementById('content'); + const content = document.getElementById('mainContent'); hideStatsAndCarousel(); // Fetch core memories diff --git a/ccw/src/templates/dashboard-js/views/graph-explorer.js b/ccw/src/templates/dashboard-js/views/graph-explorer.js index 308ae9c9..d64217a4 100644 --- a/ccw/src/templates/dashboard-js/views/graph-explorer.js +++ b/ccw/src/templates/dashboard-js/views/graph-explorer.js @@ -20,6 +20,11 @@ var edgeFilters = { }; var selectedNode = null; var searchProcessData = null; +var availableFiles = []; +var availableModules = []; +var selectedFile = null; +var selectedModule = null; +var filterMode = 'all'; // 'all', 'file', 'module' // ========== Node/Edge Colors ========== var NODE_COLORS = { @@ -53,7 +58,8 @@ async function renderGraphExplorer() { // Load data await Promise.all([ loadGraphData(), - loadSearchProcessData() + loadSearchProcessData(), + loadFilesAndModules() ]); // Render layout @@ -71,11 +77,22 @@ async function renderGraphExplorer() { // ========== Data Loading ========== async function loadGraphData() { try { - var nodesResp = await fetch('/api/graph/nodes'); + // Build query parameters based on filter mode + var queryParams = new URLSearchParams(); + if (filterMode === 'file' && selectedFile) { + queryParams.set('file', selectedFile); + } else if (filterMode === 'module' && selectedModule) { + queryParams.set('module', selectedModule); + } + + var nodesUrl = '/api/graph/nodes' + (queryParams.toString() ? '?' + queryParams.toString() : ''); + var edgesUrl = '/api/graph/edges' + (queryParams.toString() ? '?' + queryParams.toString() : ''); + + var nodesResp = await fetch(nodesUrl); if (!nodesResp.ok) throw new Error('Failed to load graph nodes'); var nodesData = await nodesResp.json(); - var edgesResp = await fetch('/api/graph/edges'); + var edgesResp = await fetch(edgesUrl); if (!edgesResp.ok) throw new Error('Failed to load graph edges'); var edgesData = await edgesResp.json(); @@ -91,6 +108,23 @@ async function loadGraphData() { } } +async function loadFilesAndModules() { + try { + var response = await fetch('/api/graph/files'); + if (!response.ok) throw new Error('Failed to load files and modules'); + var data = await response.json(); + + availableFiles = data.files || []; + availableModules = data.modules || []; + return { files: availableFiles, modules: availableModules }; + } catch (err) { + console.error('Failed to load files and modules:', err); + availableFiles = []; + availableModules = []; + return { files: [], modules: [] }; + } +} + async function loadCoreMemoryGraphData() { try { var response = await fetch('/api/core-memory/graph'); @@ -219,6 +253,40 @@ function renderGraphView() { function renderFilterDropdowns() { return '
' + + // Scope filter + '
' + + '' + + '
' + + '' + + '' + + '' + + '
' + + // Module selector (shown when filterMode === 'module') + (filterMode === 'module' ? + '' : '') + + // File selector (shown when filterMode === 'file') + (filterMode === 'file' ? + '' : '') + + '
' + '
' + '' + Object.keys(NODE_COLORS).map(function(type) { @@ -845,6 +913,42 @@ function cleanupGraphExplorer() { searchProcessData = null; } +// ========== Scope Filter Actions ========== +async function changeScopeMode(mode) { + filterMode = mode; + selectedFile = null; + selectedModule = null; + + // Re-render the filter panel + var sidebar = document.querySelector('.graph-sidebar'); + if (sidebar) { + var controlsSection = sidebar.querySelector('.graph-controls-section'); + if (controlsSection) { + controlsSection.innerHTML = '

' + t('graph.filters') + '

' + renderFilterDropdowns(); + if (window.lucide) lucide.createIcons(); + } + } + + // If mode is 'all', reload graph immediately + if (mode === 'all') { + await refreshGraphData(); + } +} + +async function selectModule(modulePath) { + selectedModule = modulePath; + if (modulePath) { + await refreshGraphData(); + } +} + +async function selectFile(filePath) { + selectedFile = filePath; + if (filePath) { + await refreshGraphData(); + } +} + // Register cleanup on navigation (called by navigation.js before switching views) if (typeof window !== 'undefined') { window.cleanupGraphExplorer = cleanupGraphExplorer; diff --git a/ccw/src/tools/cli-executor.ts b/ccw/src/tools/cli-executor.ts index 93b67aa0..58805f3c 100644 --- a/ccw/src/tools/cli-executor.ts +++ b/ccw/src/tools/cli-executor.ts @@ -213,8 +213,9 @@ async function checkToolAvailability(tool: string): Promise { const isWindows = process.platform === 'win32'; const command = isWindows ? 'where' : 'which'; + // Direct spawn - where/which are system commands that don't need shell wrapper const child = spawn(command, [tool], { - shell: isWindows, + shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -757,25 +758,11 @@ async function executeCliTool( const startTime = Date.now(); return new Promise((resolve, reject) => { - const isWindows = process.platform === 'win32'; - - // On Windows with shell:true, we need to properly quote args containing spaces - let spawnArgs = args; - - if (isWindows) { - // Quote arguments containing spaces for cmd.exe - spawnArgs = args.map(arg => { - if (arg.includes(' ') || arg.includes('"')) { - // Escape existing quotes and wrap in quotes - return `"${arg.replace(/"/g, '\\"')}"`; - } - return arg; - }); - } - - const child = spawn(command, spawnArgs, { + // Direct spawn without shell - CLI tools (codex/gemini/qwen) don't need shell wrapper + // This avoids Windows cmd.exe ENOENT errors and simplifies argument handling + const child = spawn(command, args, { cwd: workingDir, - shell: isWindows, + shell: false, stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });