mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat: Enhance graph exploration with file and module filtering options
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 自动同步',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user