mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat(graph-explorer): implement interactive code relationship visualization with Cytoscape.js
- Added main render function to initialize the graph explorer view. - Implemented data loading functions for graph nodes, edges, and search process data. - Created UI layout with tabs for graph view and search process view. - Developed filtering options for nodes and edges with corresponding UI elements. - Integrated Cytoscape.js for graph visualization, including node and edge styling. - Added functionality for node selection and details display. - Implemented impact analysis feature with modal display for results. - Included utility functions for refreshing data and managing UI states.
This commit is contained in:
166
ccw/src/core/routes/graph-routes.md
Normal file
166
ccw/src/core/routes/graph-routes.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Graph Routes API Documentation
|
||||
|
||||
## Overview
|
||||
The Graph Routes module provides REST API endpoints for querying and visualizing code structure data from CodexLens indices. It exposes symbols and their relationships as graph nodes and edges.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /api/graph/nodes
|
||||
Query all symbols from the CodexLens SQLite database and return them as graph nodes.
|
||||
|
||||
**Query Parameters:**
|
||||
- `path` (optional): Project path. Defaults to server's initial path.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "src/file.ts:functionName:10",
|
||||
"name": "functionName",
|
||||
"type": "FUNCTION",
|
||||
"file": "src/file.ts",
|
||||
"line": 10,
|
||||
"docstring": "function_type",
|
||||
"tokenCount": 45
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Node Types:**
|
||||
- `FUNCTION`: Functions and procedures
|
||||
- `CLASS`: Classes, interfaces, and type definitions
|
||||
- `METHOD`: Class methods
|
||||
- `VARIABLE`: Variables and constants
|
||||
- `MODULE`: Module-level definitions
|
||||
|
||||
---
|
||||
|
||||
### GET /api/graph/edges
|
||||
Query all code relationships from the CodexLens SQLite database and return them as graph edges.
|
||||
|
||||
**Query Parameters:**
|
||||
- `path` (optional): Project path. Defaults to server's initial path.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"edges": [
|
||||
{
|
||||
"source": "src/file.ts:caller:10",
|
||||
"target": "module.callee",
|
||||
"type": "CALLS",
|
||||
"sourceLine": 10,
|
||||
"sourceFile": "src/file.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Edge Types:**
|
||||
- `CALLS`: Function/method invocations
|
||||
- `IMPORTS`: Import/require relationships
|
||||
- `INHERITS`: Class inheritance
|
||||
|
||||
---
|
||||
|
||||
### GET /api/graph/impact
|
||||
Get impact analysis for a symbol - determines what code is affected if this symbol changes.
|
||||
|
||||
**Query Parameters:**
|
||||
- `path` (optional): Project path. Defaults to server's initial path.
|
||||
- `symbol` (required): Symbol ID in format `file:name:line`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"directDependents": [
|
||||
"src/other.ts:caller:20",
|
||||
"src/another.ts:user:35"
|
||||
],
|
||||
"affectedFiles": [
|
||||
"src/other.ts",
|
||||
"src/another.ts"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### PathMapper
|
||||
Maps source code paths to CodexLens index database paths following the storage structure:
|
||||
- Windows: `D:\path\to\project` → `~/.codexlens/indexes/D/path/to/project/_index.db`
|
||||
- Unix: `/home/user/project` → `~/.codexlens/indexes/home/user/project/_index.db`
|
||||
|
||||
### Database Schema
|
||||
Queries two main tables:
|
||||
1. **symbols** - Code symbol definitions
|
||||
- `id`, `file_id`, `name`, `kind`, `start_line`, `end_line`, `token_count`, `symbol_type`
|
||||
2. **code_relationships** - Inter-symbol dependencies
|
||||
- `id`, `source_symbol_id`, `target_qualified_name`, `relationship_type`, `source_line`, `target_file`
|
||||
|
||||
### Error Handling
|
||||
- Returns empty arrays (`[]`) if index database doesn't exist
|
||||
- Returns 500 with error message on database query failures
|
||||
- Returns 400 if required parameters are missing
|
||||
|
||||
### Type Mappings
|
||||
|
||||
**Symbol Kinds → Node Types:**
|
||||
```typescript
|
||||
{
|
||||
'function' → 'FUNCTION',
|
||||
'class' → 'CLASS',
|
||||
'method' → 'METHOD',
|
||||
'variable' → 'VARIABLE',
|
||||
'module' → 'MODULE',
|
||||
'interface' → 'CLASS',
|
||||
'type' → 'CLASS'
|
||||
}
|
||||
```
|
||||
|
||||
**Relationship Types → Edge Types:**
|
||||
```typescript
|
||||
{
|
||||
'call' → 'CALLS',
|
||||
'import' → 'IMPORTS',
|
||||
'inherits' → 'INHERITS',
|
||||
'uses' → 'CALLS'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Fetch All Nodes
|
||||
```bash
|
||||
curl "http://localhost:3000/api/graph/nodes?path=/path/to/project"
|
||||
```
|
||||
|
||||
### Fetch All Edges
|
||||
```bash
|
||||
curl "http://localhost:3000/api/graph/edges?path=/path/to/project"
|
||||
```
|
||||
|
||||
### Analyze Impact
|
||||
```bash
|
||||
curl "http://localhost:3000/api/graph/impact?path=/path/to/project&symbol=src/file.ts:functionName:10"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with CodexLens
|
||||
|
||||
This module requires:
|
||||
1. CodexLens to be installed and initialized (`/api/codexlens/init`)
|
||||
2. Project to be indexed (creates `_index.db` files)
|
||||
3. better-sqlite3 package for direct database access
|
||||
|
||||
## Dependencies
|
||||
- `better-sqlite3`: Direct SQLite database access
|
||||
- `codex-lens`: Python package providing the indexing infrastructure
|
||||
- `path`, `fs`, `os`: Node.js built-in modules for file system operations
|
||||
315
ccw/src/core/routes/graph-routes.ts
Normal file
315
ccw/src/core/routes/graph-routes.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
// @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 { existsSync } from 'fs';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export interface RouteContext {
|
||||
pathname: string;
|
||||
url: URL;
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
initialPath: string;
|
||||
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
|
||||
broadcastToClients: (data: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PathMapper utility class (simplified from codex-lens Python implementation)
|
||||
* Maps source paths to index database paths
|
||||
*/
|
||||
class PathMapper {
|
||||
private indexRoot: string;
|
||||
|
||||
constructor(indexRoot?: string) {
|
||||
this.indexRoot = indexRoot || join(homedir(), '.codexlens', 'indexes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path to cross-platform storage format
|
||||
* Windows: D:\path\to\dir → D/path/to/dir
|
||||
* Unix: /home/user/path → home/user/path
|
||||
*/
|
||||
normalizePath(sourcePath: string): string {
|
||||
const resolved = sourcePath.replace(/\\/g, '/');
|
||||
|
||||
// Handle Windows paths with drive letters
|
||||
if (process.platform === 'win32' && /^[A-Za-z]:/.test(resolved)) {
|
||||
const drive = resolved[0]; // D
|
||||
const rest = resolved.slice(2); // /path/to/dir
|
||||
return `${drive}${rest}`.replace(/^\//, '');
|
||||
}
|
||||
|
||||
// Handle Unix paths - remove leading slash
|
||||
return resolved.replace(/^\//, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert source path to index database path
|
||||
*/
|
||||
sourceToIndexDb(sourcePath: string): string {
|
||||
const normalized = this.normalizePath(sourcePath);
|
||||
return join(this.indexRoot, normalized, '_index.db');
|
||||
}
|
||||
}
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
file: string;
|
||||
line: number;
|
||||
docstring?: string;
|
||||
tokenCount?: number;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
sourceLine: number;
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
interface ImpactAnalysis {
|
||||
directDependents: string[];
|
||||
affectedFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map codex-lens symbol kinds to graph node types
|
||||
*/
|
||||
function mapSymbolKind(kind: string): string {
|
||||
const kindMap: Record<string, string> = {
|
||||
'function': 'FUNCTION',
|
||||
'class': 'CLASS',
|
||||
'method': 'METHOD',
|
||||
'variable': 'VARIABLE',
|
||||
'module': 'MODULE',
|
||||
'interface': 'CLASS', // TypeScript interfaces as CLASS
|
||||
'type': 'CLASS', // Type aliases as CLASS
|
||||
};
|
||||
return kindMap[kind.toLowerCase()] || 'VARIABLE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map codex-lens relationship types to graph edge types
|
||||
*/
|
||||
function mapRelationType(relType: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'call': 'CALLS',
|
||||
'import': 'IMPORTS',
|
||||
'inherits': 'INHERITS',
|
||||
'uses': 'CALLS', // Fallback uses → CALLS
|
||||
};
|
||||
return typeMap[relType.toLowerCase()] || 'CALLS';
|
||||
}
|
||||
|
||||
/**
|
||||
* Query symbols from codex-lens database
|
||||
*/
|
||||
async function querySymbols(projectPath: string): Promise<GraphNode[]> {
|
||||
const mapper = new PathMapper();
|
||||
const dbPath = mapper.sourceToIndexDb(projectPath);
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const db = Database(dbPath, { readonly: true });
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.kind,
|
||||
s.start_line,
|
||||
s.token_count,
|
||||
s.symbol_type,
|
||||
f.path as file
|
||||
FROM symbols s
|
||||
JOIN files f ON s.file_id = f.id
|
||||
ORDER BY f.path, s.start_line
|
||||
`).all();
|
||||
|
||||
db.close();
|
||||
|
||||
return rows.map((row: any) => ({
|
||||
id: `${row.file}:${row.name}:${row.start_line}`,
|
||||
name: row.name,
|
||||
type: mapSymbolKind(row.kind),
|
||||
file: row.file,
|
||||
line: row.start_line,
|
||||
docstring: row.symbol_type || undefined,
|
||||
tokenCount: row.token_count || undefined,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Failed to query symbols: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query code relationships from codex-lens database
|
||||
*/
|
||||
async function queryRelationships(projectPath: string): Promise<GraphEdge[]> {
|
||||
const mapper = new PathMapper();
|
||||
const dbPath = mapper.sourceToIndexDb(projectPath);
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const db = Database(dbPath, { readonly: true });
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
s.name as source_name,
|
||||
s.start_line as source_line,
|
||||
f.path as source_file,
|
||||
r.target_qualified_name,
|
||||
r.relationship_type,
|
||||
r.target_file
|
||||
FROM code_relationships r
|
||||
JOIN symbols s ON r.source_symbol_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
ORDER BY f.path, s.start_line
|
||||
`).all();
|
||||
|
||||
db.close();
|
||||
|
||||
return rows.map((row: any) => ({
|
||||
source: `${row.source_file}:${row.source_name}:${row.source_line}`,
|
||||
target: row.target_qualified_name,
|
||||
type: mapRelationType(row.relationship_type),
|
||||
sourceLine: row.source_line,
|
||||
sourceFile: row.source_file,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Failed to query relationships: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform impact analysis for a symbol
|
||||
* Find all symbols that depend on this symbol (direct and transitive)
|
||||
*/
|
||||
async function analyzeImpact(projectPath: string, symbolId: string): Promise<ImpactAnalysis> {
|
||||
const mapper = new PathMapper();
|
||||
const dbPath = mapper.sourceToIndexDb(projectPath);
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
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;
|
||||
|
||||
// Find all symbols that reference this symbol
|
||||
const rows = db.prepare(`
|
||||
SELECT DISTINCT
|
||||
s.name as dependent_name,
|
||||
f.path as dependent_file,
|
||||
s.start_line as dependent_line
|
||||
FROM code_relationships r
|
||||
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}%`);
|
||||
|
||||
db.close();
|
||||
|
||||
const directDependents = rows.map((row: any) =>
|
||||
`${row.dependent_file}:${row.dependent_name}:${row.dependent_line}`
|
||||
);
|
||||
|
||||
const affectedFiles = [...new Set(rows.map((row: any) => row.dependent_file))];
|
||||
|
||||
return {
|
||||
directDependents,
|
||||
affectedFiles,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[Graph] Failed to analyze impact: ${err.message}`);
|
||||
return { directDependents: [], affectedFiles: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Graph routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res, initialPath } = ctx;
|
||||
|
||||
// API: Graph Nodes - Get all symbols as graph nodes
|
||||
if (pathname === '/api/graph/nodes') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
try {
|
||||
const nodes = await querySymbols(projectPath);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ nodes }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message, nodes: [] }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Graph Edges - Get all relationships as graph edges
|
||||
if (pathname === '/api/graph/edges') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
try {
|
||||
const edges = await queryRelationships(projectPath);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ edges }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message, 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 symbolId = url.searchParams.get('symbol');
|
||||
|
||||
if (!symbolId) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'symbol parameter is required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const impact = await analyzeImpact(projectPath, symbolId);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(impact));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: err.message,
|
||||
directDependents: [],
|
||||
affectedFiles: []
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { handleMemoryRoutes } from './routes/memory-routes.js';
|
||||
import { handleMcpRoutes } from './routes/mcp-routes.js';
|
||||
import { handleHooksRoutes } from './routes/hooks-routes.js';
|
||||
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
|
||||
import { handleGraphRoutes } from './routes/graph-routes.js';
|
||||
import { handleSystemRoutes } from './routes/system-routes.js';
|
||||
import { handleFilesRoutes } from './routes/files-routes.js';
|
||||
import { handleSkillsRoutes } from './routes/skills-routes.js';
|
||||
@@ -270,6 +271,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleCodexLensRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Graph routes (/api/graph/*)
|
||||
if (pathname.startsWith('/api/graph/')) {
|
||||
if (await handleGraphRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// CCW routes (/api/ccw/*)
|
||||
if (pathname.startsWith('/api/ccw/')) {
|
||||
if (await handleCcwRoutes(routeContext)) return;
|
||||
|
||||
32
ccw/src/templates/assets/js/cytoscape.min.js
vendored
Normal file
32
ccw/src/templates/assets/js/cytoscape.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1136
ccw/src/templates/dashboard-css/14-graph-explorer.css
Normal file
1136
ccw/src/templates/dashboard-css/14-graph-explorer.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -114,6 +114,8 @@ function initNavigation() {
|
||||
renderRulesManager();
|
||||
} else if (currentView === 'claude-manager') {
|
||||
renderClaudeManager();
|
||||
} else if (currentView === 'graph-explorer') {
|
||||
renderGraphExplorer();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -148,6 +150,8 @@ function updateContentTitle() {
|
||||
titleEl.textContent = t('title.rulesManager');
|
||||
} else if (currentView === 'claude-manager') {
|
||||
titleEl.textContent = t('title.claudeManager');
|
||||
} else if (currentView === 'graph-explorer') {
|
||||
titleEl.textContent = t('title.graphExplorer');
|
||||
} else if (currentView === 'liteTasks') {
|
||||
const names = { 'lite-plan': t('title.litePlanSessions'), 'lite-fix': t('title.liteFixSessions') };
|
||||
titleEl.textContent = names[currentLiteType] || t('title.liteTasks');
|
||||
|
||||
@@ -937,6 +937,28 @@ const i18n = {
|
||||
'claudeManager.saved': 'File saved successfully',
|
||||
'claudeManager.saveError': 'Failed to save file',
|
||||
|
||||
// Graph Explorer
|
||||
'nav.graphExplorer': 'Graph',
|
||||
'title.graphExplorer': 'Code Graph Explorer',
|
||||
'graph.codeRelations': 'Code Relations',
|
||||
'graph.searchProcess': 'Search Process',
|
||||
'graph.nodeTypes': 'Node Types',
|
||||
'graph.edgeTypes': 'Edge Types',
|
||||
'graph.noProject': 'No project selected',
|
||||
'graph.selectProject': 'Select a project to view its code graph',
|
||||
'graph.loading': 'Loading graph data...',
|
||||
'graph.noData': 'No graph data available',
|
||||
'graph.indexProject': 'Please index this project with codex-lens first',
|
||||
'graph.nodeDetails': 'Node Details',
|
||||
'graph.selectNode': 'Select a node to view details',
|
||||
'graph.impactAnalysis': 'Impact Analysis',
|
||||
'graph.directDependents': 'Direct Dependents',
|
||||
'graph.affectedFiles': 'Affected Files',
|
||||
'graph.fitView': 'Fit View',
|
||||
'graph.zoomIn': 'Zoom In',
|
||||
'graph.zoomOut': 'Zoom Out',
|
||||
'graph.resetLayout': 'Reset Layout',
|
||||
|
||||
// CLI Sync (used in claude-manager.js)
|
||||
'claude.cliSync': 'CLI Auto-Sync',
|
||||
'claude.tool': 'Tool',
|
||||
@@ -1907,6 +1929,28 @@ const i18n = {
|
||||
'claudeManager.saved': '文件保存成功',
|
||||
'claudeManager.saveError': '文件保存失败',
|
||||
|
||||
// Graph Explorer
|
||||
'nav.graphExplorer': '图谱',
|
||||
'title.graphExplorer': '代码图谱浏览器',
|
||||
'graph.codeRelations': '代码关系',
|
||||
'graph.searchProcess': '搜索过程',
|
||||
'graph.nodeTypes': '节点类型',
|
||||
'graph.edgeTypes': '边类型',
|
||||
'graph.noProject': '未选择项目',
|
||||
'graph.selectProject': '选择一个项目以查看其代码图谱',
|
||||
'graph.loading': '正在加载图谱数据...',
|
||||
'graph.noData': '无图谱数据',
|
||||
'graph.indexProject': '请先使用 codex-lens 为此项目建立索引',
|
||||
'graph.nodeDetails': '节点详情',
|
||||
'graph.selectNode': '选择节点以查看详情',
|
||||
'graph.impactAnalysis': '影响分析',
|
||||
'graph.directDependents': '直接依赖',
|
||||
'graph.affectedFiles': '受影响文件',
|
||||
'graph.fitView': '适应视图',
|
||||
'graph.zoomIn': '放大',
|
||||
'graph.zoomOut': '缩小',
|
||||
'graph.resetLayout': '重置布局',
|
||||
|
||||
// CLI Sync (used in claude-manager.js)
|
||||
'claude.cliSync': 'CLI 自动同步',
|
||||
'claude.tool': '工具',
|
||||
|
||||
729
ccw/src/templates/dashboard-js/views/graph-explorer.js
Normal file
729
ccw/src/templates/dashboard-js/views/graph-explorer.js
Normal file
@@ -0,0 +1,729 @@
|
||||
// Graph Explorer View
|
||||
// Interactive code relationship visualization using Cytoscape.js
|
||||
|
||||
// ========== State Variables ==========
|
||||
var graphData = { nodes: [], edges: [] };
|
||||
var cyInstance = null;
|
||||
var activeTab = 'graph';
|
||||
var nodeFilters = {
|
||||
MODULE: true,
|
||||
CLASS: true,
|
||||
FUNCTION: true,
|
||||
METHOD: true,
|
||||
VARIABLE: false
|
||||
};
|
||||
var edgeFilters = {
|
||||
CALLS: true,
|
||||
IMPORTS: true,
|
||||
INHERITS: true
|
||||
};
|
||||
var selectedNode = null;
|
||||
var searchProcessData = null;
|
||||
|
||||
// ========== Node/Edge Colors ==========
|
||||
var NODE_COLORS = {
|
||||
MODULE: '#8B5CF6',
|
||||
CLASS: '#3B82F6',
|
||||
FUNCTION: '#10B981',
|
||||
METHOD: '#F59E0B',
|
||||
VARIABLE: '#6B7280'
|
||||
};
|
||||
|
||||
var EDGE_COLORS = {
|
||||
CALLS: '#10B981',
|
||||
IMPORTS: '#3B82F6',
|
||||
INHERITS: '#F59E0B'
|
||||
};
|
||||
|
||||
// ========== Main Render Function ==========
|
||||
async function renderGraphExplorer() {
|
||||
var container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
// Hide stats grid and carousel
|
||||
hideStatsAndCarousel();
|
||||
|
||||
// Show loading state
|
||||
container.innerHTML = '<div class="graph-explorer-view loading">' +
|
||||
'<div class="loading-spinner"><i data-lucide="loader-2" class="w-8 h-8 animate-spin"></i></div>' +
|
||||
'<p>' + t('common.loading') + '</p>' +
|
||||
'</div>';
|
||||
|
||||
// Load data
|
||||
await Promise.all([
|
||||
loadGraphData(),
|
||||
loadSearchProcessData()
|
||||
]);
|
||||
|
||||
// Render layout
|
||||
container.innerHTML = renderGraphLayout();
|
||||
|
||||
// Initialize Cytoscape.js after DOM is ready
|
||||
setTimeout(function() {
|
||||
if (activeTab === 'graph') {
|
||||
initializeCytoscape();
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ========== Data Loading ==========
|
||||
async function loadGraphData() {
|
||||
try {
|
||||
var nodesResp = await fetch('/api/graph/nodes');
|
||||
if (!nodesResp.ok) throw new Error('Failed to load graph nodes');
|
||||
var nodesData = await nodesResp.json();
|
||||
|
||||
var edgesResp = await fetch('/api/graph/edges');
|
||||
if (!edgesResp.ok) throw new Error('Failed to load graph edges');
|
||||
var edgesData = await edgesResp.json();
|
||||
|
||||
graphData = {
|
||||
nodes: nodesData.nodes || [],
|
||||
edges: edgesData.edges || []
|
||||
};
|
||||
return graphData;
|
||||
} catch (err) {
|
||||
console.error('Failed to load graph data:', err);
|
||||
graphData = { nodes: [], edges: [] };
|
||||
return graphData;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSearchProcessData() {
|
||||
try {
|
||||
var response = await fetch('/api/graph/search-process');
|
||||
if (!response.ok) throw new Error('Failed to load search process data');
|
||||
var data = await response.json();
|
||||
searchProcessData = data.searchProcess || null;
|
||||
return searchProcessData;
|
||||
} catch (err) {
|
||||
console.error('Failed to load search process data:', err);
|
||||
searchProcessData = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== UI Layout ==========
|
||||
function renderGraphLayout() {
|
||||
return '<div class="graph-explorer-view">' +
|
||||
'<div class="graph-explorer-header">' +
|
||||
'<h2><i data-lucide="network" class="w-5 h-5"></i> ' + t('graph.title') + '</h2>' +
|
||||
'<div class="graph-explorer-tabs">' +
|
||||
'<button class="tab-btn ' + (activeTab === 'graph' ? 'active' : '') + '" onclick="switchGraphTab(\'graph\')">' +
|
||||
'<i data-lucide="git-branch" class="w-4 h-4"></i> ' + t('graph.codeRelations') +
|
||||
'</button>' +
|
||||
'<button class="tab-btn ' + (activeTab === 'search' ? 'active' : '') + '" onclick="switchGraphTab(\'search\')">' +
|
||||
'<i data-lucide="search" class="w-4 h-4"></i> ' + t('graph.searchProcess') +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="graph-explorer-content">' +
|
||||
'<div id="graphTab" class="tab-content ' + (activeTab === 'graph' ? 'active' : '') + '">' +
|
||||
renderGraphView() +
|
||||
'</div>' +
|
||||
'<div id="searchTab" class="tab-content ' + (activeTab === 'search' ? 'active' : '') + '">' +
|
||||
renderSearchProcessView() +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderGraphView() {
|
||||
return '<div class="graph-view">' +
|
||||
'<div class="graph-sidebar">' +
|
||||
'<div class="graph-controls-section">' +
|
||||
'<h3>' + t('graph.filters') + '</h3>' +
|
||||
renderFilterDropdowns() +
|
||||
'</div>' +
|
||||
'<div class="graph-legend-section">' +
|
||||
'<h3>' + t('graph.legend') + '</h3>' +
|
||||
renderGraphLegend() +
|
||||
'</div>' +
|
||||
'<div id="nodeDetailsPanel" class="node-details-panel hidden">' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="graph-main">' +
|
||||
'<div class="graph-toolbar">' +
|
||||
'<div class="graph-toolbar-left">' +
|
||||
'<span class="graph-stats">' +
|
||||
'<i data-lucide="circle" class="w-3 h-3"></i> ' +
|
||||
graphData.nodes.length + ' ' + t('graph.nodes') +
|
||||
'</span>' +
|
||||
'<span class="graph-stats">' +
|
||||
'<i data-lucide="arrow-right" class="w-3 h-3"></i> ' +
|
||||
graphData.edges.length + ' ' + t('graph.edges') +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="graph-toolbar-right">' +
|
||||
'<button class="btn-icon" onclick="fitCytoscape()" title="' + t('graph.fitView') + '">' +
|
||||
'<i data-lucide="maximize-2" class="w-4 h-4"></i>' +
|
||||
'</button>' +
|
||||
'<button class="btn-icon" onclick="centerCytoscape()" title="' + t('graph.center') + '">' +
|
||||
'<i data-lucide="crosshair" class="w-4 h-4"></i>' +
|
||||
'</button>' +
|
||||
'<button class="btn-icon" onclick="resetGraphFilters()" title="' + t('graph.resetFilters') + '">' +
|
||||
'<i data-lucide="filter-x" class="w-4 h-4"></i>' +
|
||||
'</button>' +
|
||||
'<button class="btn-icon" onclick="refreshGraphData()" title="' + t('common.refresh') + '">' +
|
||||
'<i data-lucide="refresh-cw" class="w-4 h-4"></i>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="cytoscapeContainer" class="cytoscape-container"></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderFilterDropdowns() {
|
||||
return '<div class="filter-dropdowns">' +
|
||||
'<div class="filter-group">' +
|
||||
'<label>' + t('graph.nodeTypes') + '</label>' +
|
||||
Object.keys(NODE_COLORS).map(function(type) {
|
||||
return '<label class="filter-checkbox">' +
|
||||
'<input type="checkbox" ' + (nodeFilters[type] ? 'checked' : '') + ' onchange="toggleNodeFilter(\'' + type + '\', this.checked)">' +
|
||||
'<span class="filter-color" style="background-color: ' + NODE_COLORS[type] + '"></span>' +
|
||||
'<span>' + type + '</span>' +
|
||||
'</label>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'<div class="filter-group">' +
|
||||
'<label>' + t('graph.edgeTypes') + '</label>' +
|
||||
Object.keys(EDGE_COLORS).map(function(type) {
|
||||
return '<label class="filter-checkbox">' +
|
||||
'<input type="checkbox" ' + (edgeFilters[type] ? 'checked' : '') + ' onchange="toggleEdgeFilter(\'' + type + '\', this.checked)">' +
|
||||
'<span class="filter-color" style="background-color: ' + EDGE_COLORS[type] + '"></span>' +
|
||||
'<span>' + type + '</span>' +
|
||||
'</label>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderGraphLegend() {
|
||||
return '<div class="graph-legend">' +
|
||||
'<div class="legend-title">' + t('graph.nodeTypes') + '</div>' +
|
||||
Object.keys(NODE_COLORS).map(function(type) {
|
||||
return '<div class="legend-item">' +
|
||||
'<span class="legend-dot" style="background-color: ' + NODE_COLORS[type] + '"></span>' +
|
||||
'<span>' + type + '</span>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'<div class="legend-title" style="margin-top: 1rem;">' + t('graph.edgeTypes') + '</div>' +
|
||||
Object.keys(EDGE_COLORS).map(function(type) {
|
||||
return '<div class="legend-item">' +
|
||||
'<span class="legend-line" style="background-color: ' + EDGE_COLORS[type] + '"></span>' +
|
||||
'<span>' + type + '</span>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderSearchProcessView() {
|
||||
if (!searchProcessData) {
|
||||
return '<div class="search-process-empty">' +
|
||||
'<i data-lucide="search-x" class="w-12 h-12"></i>' +
|
||||
'<p>' + t('graph.noSearchData') + '</p>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return '<div class="search-process-view">' +
|
||||
'<div class="search-process-header">' +
|
||||
'<h3>' + t('graph.searchProcessTitle') + '</h3>' +
|
||||
'<p class="search-process-desc">' + t('graph.searchProcessDesc') + '</p>' +
|
||||
'</div>' +
|
||||
'<div class="search-process-timeline">' +
|
||||
(searchProcessData.steps || []).map(function(step, index) {
|
||||
return '<div class="search-step">' +
|
||||
'<div class="search-step-number">' + (index + 1) + '</div>' +
|
||||
'<div class="search-step-content">' +
|
||||
'<h4>' + escapeHtml(step.name || 'Step ' + (index + 1)) + '</h4>' +
|
||||
'<p>' + escapeHtml(step.description || '') + '</p>' +
|
||||
(step.results ? '<div class="search-step-results">' +
|
||||
'<span class="result-count">' + (step.results.length || 0) + ' ' + t('graph.resultsFound') + '</span>' +
|
||||
'</div>' : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ========== Tab Switching ==========
|
||||
function switchGraphTab(tab) {
|
||||
activeTab = tab;
|
||||
|
||||
// Update tab buttons
|
||||
var tabBtns = document.querySelectorAll('.graph-explorer-tabs .tab-btn');
|
||||
tabBtns.forEach(function(btn) {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
event.target.closest('.tab-btn').classList.add('active');
|
||||
|
||||
// Update tab content
|
||||
document.getElementById('graphTab').classList.toggle('active', tab === 'graph');
|
||||
document.getElementById('searchTab').classList.toggle('active', tab === 'search');
|
||||
|
||||
// Initialize Cytoscape if switching to graph tab
|
||||
if (tab === 'graph' && !cyInstance) {
|
||||
setTimeout(function() {
|
||||
initializeCytoscape();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Cytoscape.js Integration ==========
|
||||
function initializeCytoscape() {
|
||||
var container = document.getElementById('cytoscapeContainer');
|
||||
if (!container) return;
|
||||
|
||||
// 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>' +
|
||||
'<p>' + t('graph.cytoscapeNotLoaded') + '</p>' +
|
||||
'</div>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
if (graphData.nodes.length === 0) {
|
||||
container.innerHTML = '<div class="cytoscape-empty">' +
|
||||
'<i data-lucide="network" class="w-12 h-12"></i>' +
|
||||
'<p>' + t('graph.noGraphData') + '</p>' +
|
||||
'</div>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform data for Cytoscape
|
||||
var elements = transformDataForCytoscape();
|
||||
|
||||
// Create Cytoscape instance
|
||||
cyInstance = cytoscape({
|
||||
container: container,
|
||||
elements: elements,
|
||||
style: getCytoscapeStyles(),
|
||||
layout: {
|
||||
name: 'cose',
|
||||
idealEdgeLength: 100,
|
||||
nodeOverlap: 20,
|
||||
refresh: 20,
|
||||
fit: true,
|
||||
padding: 30,
|
||||
randomize: false,
|
||||
componentSpacing: 100,
|
||||
nodeRepulsion: 400000,
|
||||
edgeElasticity: 100,
|
||||
nestingFactor: 5,
|
||||
gravity: 80,
|
||||
numIter: 1000,
|
||||
initialTemp: 200,
|
||||
coolingFactor: 0.95,
|
||||
minTemp: 1.0
|
||||
},
|
||||
minZoom: 0.1,
|
||||
maxZoom: 3,
|
||||
wheelSensitivity: 0.2
|
||||
});
|
||||
|
||||
// Bind events
|
||||
cyInstance.on('tap', 'node', function(evt) {
|
||||
var node = evt.target;
|
||||
selectNode(node.data());
|
||||
});
|
||||
|
||||
cyInstance.on('tap', function(evt) {
|
||||
if (evt.target === cyInstance) {
|
||||
deselectNode();
|
||||
}
|
||||
});
|
||||
|
||||
// Fit view after layout
|
||||
setTimeout(function() {
|
||||
fitCytoscape();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function transformDataForCytoscape() {
|
||||
var elements = [];
|
||||
|
||||
// Filter nodes
|
||||
var filteredNodes = graphData.nodes.filter(function(node) {
|
||||
var type = node.type || 'MODULE';
|
||||
return nodeFilters[type];
|
||||
});
|
||||
|
||||
// Add nodes
|
||||
filteredNodes.forEach(function(node) {
|
||||
elements.push({
|
||||
group: 'nodes',
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.name || node.id,
|
||||
type: node.type || 'MODULE',
|
||||
symbolType: node.symbolType,
|
||||
path: node.path,
|
||||
lineNumber: node.lineNumber,
|
||||
imports: node.imports || 0,
|
||||
exports: node.exports || 0,
|
||||
references: node.references || 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Create node ID set for filtering edges
|
||||
var nodeIdSet = new Set(filteredNodes.map(function(n) { return n.id; }));
|
||||
|
||||
// Filter edges
|
||||
var filteredEdges = graphData.edges.filter(function(edge) {
|
||||
var type = edge.type || 'CALLS';
|
||||
return edgeFilters[type] &&
|
||||
nodeIdSet.has(edge.source) &&
|
||||
nodeIdSet.has(edge.target);
|
||||
});
|
||||
|
||||
// Add edges
|
||||
filteredEdges.forEach(function(edge, index) {
|
||||
elements.push({
|
||||
group: 'edges',
|
||||
data: {
|
||||
id: 'edge-' + index,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.type || 'CALLS',
|
||||
weight: edge.weight || 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function getCytoscapeStyles() {
|
||||
var styles = [
|
||||
// Node styles by type
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'background-color': function(ele) {
|
||||
return NODE_COLORS[ele.data('type')] || '#6B7280';
|
||||
},
|
||||
'label': 'data(label)',
|
||||
'width': function(ele) {
|
||||
var refs = ele.data('references') || 0;
|
||||
return Math.max(20, Math.min(60, 20 + refs * 2));
|
||||
},
|
||||
'height': function(ele) {
|
||||
var refs = ele.data('references') || 0;
|
||||
return Math.max(20, Math.min(60, 20 + refs * 2));
|
||||
},
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'font-size': '10px',
|
||||
'color': '#000',
|
||||
'text-outline-color': '#fff',
|
||||
'text-outline-width': 2,
|
||||
'overlay-padding': 6
|
||||
}
|
||||
},
|
||||
// Selected node
|
||||
{
|
||||
selector: 'node:selected',
|
||||
style: {
|
||||
'border-width': 3,
|
||||
'border-color': '#000',
|
||||
'overlay-color': '#000',
|
||||
'overlay-opacity': 0.2
|
||||
}
|
||||
},
|
||||
// Edge styles by type
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': function(ele) {
|
||||
return Math.max(1, ele.data('weight') || 1);
|
||||
},
|
||||
'line-color': function(ele) {
|
||||
return EDGE_COLORS[ele.data('type')] || '#6B7280';
|
||||
},
|
||||
'target-arrow-color': function(ele) {
|
||||
return EDGE_COLORS[ele.data('type')] || '#6B7280';
|
||||
},
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'arrow-scale': 1.2,
|
||||
'opacity': 0.6
|
||||
}
|
||||
},
|
||||
// Selected edge
|
||||
{
|
||||
selector: 'edge:selected',
|
||||
style: {
|
||||
'line-color': '#000',
|
||||
'target-arrow-color': '#000',
|
||||
'width': 3,
|
||||
'opacity': 1
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return styles;
|
||||
}
|
||||
|
||||
// ========== Node Selection ==========
|
||||
function selectNode(nodeData) {
|
||||
selectedNode = nodeData;
|
||||
|
||||
// Highlight in cytoscape
|
||||
if (cyInstance) {
|
||||
cyInstance.nodes().removeClass('selected');
|
||||
var node = cyInstance.getElementById(nodeData.id);
|
||||
if (node) {
|
||||
node.addClass('selected');
|
||||
// Highlight connected edges
|
||||
cyInstance.edges().removeClass('highlighted');
|
||||
node.connectedEdges().addClass('highlighted');
|
||||
}
|
||||
}
|
||||
|
||||
// Show details panel
|
||||
var panel = document.getElementById('nodeDetailsPanel');
|
||||
if (panel) {
|
||||
panel.classList.remove('hidden');
|
||||
panel.innerHTML = renderNodeDetails(nodeData);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
function deselectNode() {
|
||||
selectedNode = null;
|
||||
|
||||
// Remove highlights
|
||||
if (cyInstance) {
|
||||
cyInstance.nodes().removeClass('selected');
|
||||
cyInstance.edges().removeClass('highlighted');
|
||||
}
|
||||
|
||||
// Hide details panel
|
||||
var panel = document.getElementById('nodeDetailsPanel');
|
||||
if (panel) {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodeDetails(node) {
|
||||
var typeIcon = node.type === 'MODULE' ? 'package' :
|
||||
node.type === 'CLASS' ? 'box' :
|
||||
node.type === 'FUNCTION' ? 'code' :
|
||||
node.type === 'METHOD' ? 'code-2' :
|
||||
'variable';
|
||||
|
||||
return '<div class="node-details-content">' +
|
||||
'<div class="node-details-header">' +
|
||||
'<h4><i data-lucide="' + typeIcon + '" class="w-4 h-4"></i> ' + escapeHtml(node.label || node.id) + '</h4>' +
|
||||
'<button class="btn-icon btn-sm" onclick="deselectNode()" title="' + t('common.close') + '">' +
|
||||
'<i data-lucide="x" class="w-3 h-3"></i>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="node-details-meta">' +
|
||||
'<div class="meta-item">' +
|
||||
'<span class="meta-label">' + t('graph.type') + '</span>' +
|
||||
'<span class="meta-value">' + (node.type || 'MODULE') + '</span>' +
|
||||
'</div>' +
|
||||
(node.symbolType ? '<div class="meta-item">' +
|
||||
'<span class="meta-label">' + t('graph.symbolType') + '</span>' +
|
||||
'<span class="meta-value">' + escapeHtml(node.symbolType) + '</span>' +
|
||||
'</div>' : '') +
|
||||
(node.path ? '<div class="meta-item">' +
|
||||
'<span class="meta-label">' + t('graph.path') + '</span>' +
|
||||
'<span class="meta-value path-value">' + escapeHtml(node.path) + '</span>' +
|
||||
'</div>' : '') +
|
||||
(node.lineNumber ? '<div class="meta-item">' +
|
||||
'<span class="meta-label">' + t('graph.line') + '</span>' +
|
||||
'<span class="meta-value">' + node.lineNumber + '</span>' +
|
||||
'</div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="node-details-stats">' +
|
||||
'<div class="stat-item">' +
|
||||
'<i data-lucide="download" class="w-3 h-3"></i>' +
|
||||
'<span>' + (node.imports || 0) + ' ' + t('graph.imports') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="stat-item">' +
|
||||
'<i data-lucide="upload" class="w-3 h-3"></i>' +
|
||||
'<span>' + (node.exports || 0) + ' ' + t('graph.exports') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="stat-item">' +
|
||||
'<i data-lucide="link" class="w-3 h-3"></i>' +
|
||||
'<span>' + (node.references || 0) + ' ' + t('graph.references') + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="node-details-actions">' +
|
||||
'<button class="btn btn-sm btn-primary" onclick="showImpactAnalysis(\'' + escapeHtml(node.id) + '\')">' +
|
||||
'<i data-lucide="target" class="w-3 h-3"></i> ' + t('graph.impactAnalysis') +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ========== Filter Actions ==========
|
||||
function toggleNodeFilter(type, checked) {
|
||||
nodeFilters[type] = checked;
|
||||
refreshCytoscape();
|
||||
}
|
||||
|
||||
function toggleEdgeFilter(type, checked) {
|
||||
edgeFilters[type] = checked;
|
||||
refreshCytoscape();
|
||||
}
|
||||
|
||||
function resetGraphFilters() {
|
||||
// Reset all filters to true
|
||||
Object.keys(nodeFilters).forEach(function(key) {
|
||||
nodeFilters[key] = true;
|
||||
});
|
||||
Object.keys(edgeFilters).forEach(function(key) {
|
||||
edgeFilters[key] = true;
|
||||
});
|
||||
|
||||
// Update checkboxes
|
||||
var checkboxes = document.querySelectorAll('.filter-checkbox input[type="checkbox"]');
|
||||
checkboxes.forEach(function(cb) {
|
||||
cb.checked = true;
|
||||
});
|
||||
|
||||
refreshCytoscape();
|
||||
}
|
||||
|
||||
function refreshCytoscape() {
|
||||
if (!cyInstance) return;
|
||||
|
||||
var elements = transformDataForCytoscape();
|
||||
cyInstance.elements().remove();
|
||||
cyInstance.add(elements);
|
||||
cyInstance.layout({
|
||||
name: 'cose',
|
||||
idealEdgeLength: 100,
|
||||
nodeOverlap: 20,
|
||||
refresh: 20,
|
||||
fit: true,
|
||||
padding: 30
|
||||
}).run();
|
||||
|
||||
deselectNode();
|
||||
}
|
||||
|
||||
// ========== Cytoscape Controls ==========
|
||||
function fitCytoscape() {
|
||||
if (cyInstance) {
|
||||
cyInstance.fit(null, 30);
|
||||
}
|
||||
}
|
||||
|
||||
function centerCytoscape() {
|
||||
if (cyInstance) {
|
||||
cyInstance.center();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Impact Analysis ==========
|
||||
async function showImpactAnalysis(symbolId) {
|
||||
try {
|
||||
var response = await fetch('/api/graph/impact/' + encodeURIComponent(symbolId));
|
||||
if (!response.ok) throw new Error('Failed to fetch impact analysis');
|
||||
var data = await response.json();
|
||||
|
||||
// Show modal with impact analysis results
|
||||
showImpactModal(data.impact);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch impact analysis:', err);
|
||||
if (window.showToast) {
|
||||
showToast(t('graph.impactAnalysisError'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showImpactModal(impact) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.innerHTML = '<div class="modal-container">' +
|
||||
'<div class="modal-header">' +
|
||||
'<h3><i data-lucide="target" class="w-4 h-4"></i> ' + t('graph.impactAnalysis') + '</h3>' +
|
||||
'<button class="btn-icon" onclick="closeImpactModal()">' +
|
||||
'<i data-lucide="x" class="w-4 h-4"></i>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<div class="impact-summary">' +
|
||||
'<div class="impact-stat">' +
|
||||
'<span class="impact-stat-value">' + (impact.affectedFiles || 0) + '</span>' +
|
||||
'<span class="impact-stat-label">' + t('graph.affectedFiles') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="impact-stat">' +
|
||||
'<span class="impact-stat-value">' + (impact.affectedSymbols || 0) + '</span>' +
|
||||
'<span class="impact-stat-label">' + t('graph.affectedSymbols') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="impact-stat">' +
|
||||
'<span class="impact-stat-value">' + (impact.depth || 0) + '</span>' +
|
||||
'<span class="impact-stat-label">' + t('graph.depth') + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(impact.files && impact.files.length > 0 ? '<div class="impact-files">' +
|
||||
'<h4>' + t('graph.affectedFiles') + '</h4>' +
|
||||
'<div class="impact-files-list">' +
|
||||
impact.files.map(function(file) {
|
||||
return '<div class="impact-file-item">' +
|
||||
'<i data-lucide="file" class="w-3 h-3"></i>' +
|
||||
'<span>' + escapeHtml(file) + '</span>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'</div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-secondary" onclick="closeImpactModal()">' + t('common.close') + '</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
document.body.appendChild(modal);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
// Close on overlay click
|
||||
modal.addEventListener('click', function(e) {
|
||||
if (e.target === modal) {
|
||||
closeImpactModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeImpactModal() {
|
||||
var modal = document.querySelector('.modal-overlay');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Data Refresh ==========
|
||||
async function refreshGraphData() {
|
||||
if (window.showToast) {
|
||||
showToast(t('common.refreshing'), 'info');
|
||||
}
|
||||
|
||||
await loadGraphData();
|
||||
|
||||
if (activeTab === 'graph' && cyInstance) {
|
||||
refreshCytoscape();
|
||||
}
|
||||
|
||||
if (window.showToast) {
|
||||
showToast(t('common.refreshed'), 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Utility ==========
|
||||
function hideStatsAndCarousel() {
|
||||
var statsGrid = document.getElementById('statsGrid');
|
||||
var carousel = document.getElementById('carouselContainer');
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (carousel) carousel.style.display = 'none';
|
||||
}
|
||||
@@ -331,6 +331,10 @@
|
||||
<i data-lucide="history" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1" data-i18n="nav.history">History</span>
|
||||
</li>
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="graph-explorer" data-tooltip="Code Graph Explorer">
|
||||
<i data-lucide="git-branch" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1" data-i18n="nav.graphExplorer">Graph</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -806,6 +810,8 @@
|
||||
<script src="./assets/js/lucide.min.js"></script>
|
||||
<!-- D3.js for Flowchart (本地) -->
|
||||
<script src="./assets/js/d3.min.js"></script>
|
||||
<!-- Cytoscape.js for Graph Visualization (本地) -->
|
||||
<script src="./assets/js/cytoscape.min.js"></script>
|
||||
<!-- Marked.js for Markdown rendering (本地) -->
|
||||
<script src="./assets/js/marked.min.js"></script>
|
||||
<!-- Highlight.js for Syntax Highlighting (本地) -->
|
||||
|
||||
Reference in New Issue
Block a user