feat: Enhance graph exploration with file and module filtering options

This commit is contained in:
catlog22
2025-12-18 14:58:20 +08:00
parent 440314c16d
commit 0311d63b7d
6 changed files with 245 additions and 36 deletions

View File

@@ -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)
---

View File

@@ -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<GraphNode[]> {
async function querySymbols(projectPath: string, fileFilter?: string, moduleFilter?: string): Promise<GraphNode[]> {
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<GraphNode[]> {
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<GraphNode[]> {
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<GraphNode[]> {
/**
* 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<GraphEdge[]> {
async function queryRelationships(projectPath: string, fileFilter?: string, moduleFilter?: string): Promise<GraphEdge[]> {
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<GraphEdge[]> {
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<GraphEdge[]> {
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<boolean> {
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<boolean> {
}
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<boolean> {
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<boolean> {
}
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<boolean> {
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<string>();
const modulesSet = new Set<string>();
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;

View File

@@ -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 自动同步',

View File

@@ -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

View File

@@ -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 '<div class="filter-dropdowns">' +
// Scope filter
'<div class="filter-group scope-filter">' +
'<label>' + t('graph.scope') + '</label>' +
'<div class="scope-selector">' +
'<label class="filter-radio">' +
'<input type="radio" name="scopeMode" value="all" ' + (filterMode === 'all' ? 'checked' : '') + ' onchange="changeScopeMode(\'all\')">' +
'<span>' + t('graph.allFiles') + '</span>' +
'</label>' +
'<label class="filter-radio">' +
'<input type="radio" name="scopeMode" value="module" ' + (filterMode === 'module' ? 'checked' : '') + ' onchange="changeScopeMode(\'module\')">' +
'<span>' + t('graph.byModule') + '</span>' +
'</label>' +
'<label class="filter-radio">' +
'<input type="radio" name="scopeMode" value="file" ' + (filterMode === 'file' ? 'checked' : '') + ' onchange="changeScopeMode(\'file\')">' +
'<span>' + t('graph.byFile') + '</span>' +
'</label>' +
'</div>' +
// Module selector (shown when filterMode === 'module')
(filterMode === 'module' ?
'<select id="moduleSelect" class="filter-select" onchange="selectModule(this.value)">' +
'<option value="">' + t('graph.selectModule') + '</option>' +
availableModules.map(function(module) {
return '<option value="' + escapeHtml(module) + '" ' + (selectedModule === module ? 'selected' : '') + '>' + escapeHtml(module) + '</option>';
}).join('') +
'</select>' : '') +
// File selector (shown when filterMode === 'file')
(filterMode === 'file' ?
'<select id="fileSelect" class="filter-select" onchange="selectFile(this.value)">' +
'<option value="">' + t('graph.selectFile') + '</option>' +
availableFiles.map(function(file) {
return '<option value="' + escapeHtml(file) + '" ' + (selectedFile === file ? 'selected' : '') + '>' + escapeHtml(file) + '</option>';
}).join('') +
'</select>' : '') +
'</div>' +
'<div class="filter-group">' +
'<label>' + t('graph.nodeTypes') + '</label>' +
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 = '<h3>' + t('graph.filters') + '</h3>' + 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;

View File

@@ -213,8 +213,9 @@ async function checkToolAvailability(tool: string): Promise<ToolAvailability> {
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']
});