mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
GitNexus V2 - Complete refactor
This commit is contained in:
commit
21719396fa
51 changed files with 10003 additions and 0 deletions
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# TypeScript build info
|
||||
*.tsbuildinfo
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Misc
|
||||
*.local
|
||||
|
||||
15
index.html
Normal file
15
index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GitNexus</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4924
package-lock.json
generated
Normal file
4924
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
48
package.json
Normal file
48
package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"axios": "^1.13.2",
|
||||
"comlink": "^4.4.2",
|
||||
"d3": "^7.9.0",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-layout-force": "^0.2.4",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"jszip": "^3.10.1",
|
||||
"kuzu-wasm": "^0.11.1",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"uuid": "^13.0.0",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"web-tree-sitter": "^0.20.8",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.28.5",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
BIN
public/wasm/javascript/tree-sitter-javascript.wasm
Normal file
BIN
public/wasm/javascript/tree-sitter-javascript.wasm
Normal file
Binary file not shown.
BIN
public/wasm/kuzu-wasm.wasm
Normal file
BIN
public/wasm/kuzu-wasm.wasm
Normal file
Binary file not shown.
BIN
public/wasm/python/tree-sitter-python.wasm
Normal file
BIN
public/wasm/python/tree-sitter-python.wasm
Normal file
Binary file not shown.
BIN
public/wasm/tree-sitter.wasm
Normal file
BIN
public/wasm/tree-sitter.wasm
Normal file
Binary file not shown.
BIN
public/wasm/typescript/tree-sitter-tsx.wasm
Normal file
BIN
public/wasm/typescript/tree-sitter-tsx.wasm
Normal file
Binary file not shown.
BIN
public/wasm/typescript/tree-sitter-typescript.wasm
Normal file
BIN
public/wasm/typescript/tree-sitter-typescript.wasm
Normal file
Binary file not shown.
109
src/App.tsx
Normal file
109
src/App.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
import { AppStateProvider, useAppState } from './hooks/useAppState';
|
||||
import { useIngestionWorker } from './hooks/useIngestionWorker';
|
||||
import { DropZone } from './components/DropZone';
|
||||
import { LoadingOverlay } from './components/LoadingOverlay';
|
||||
import { Header } from './components/Header';
|
||||
import { GraphCanvas, GraphCanvasHandle } from './components/GraphCanvas';
|
||||
import { RightPanel } from './components/RightPanel';
|
||||
import { StatusBar } from './components/StatusBar';
|
||||
import { FileTreePanel } from './components/FileTreePanel';
|
||||
|
||||
const AppContent = () => {
|
||||
const {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
setGraph,
|
||||
setFileContents,
|
||||
setProgress,
|
||||
setProjectName,
|
||||
progress,
|
||||
isRightPanelOpen,
|
||||
} = useAppState();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
|
||||
// Use Web Worker for ingestion (prevents UI freezing)
|
||||
const { runPipelineInWorker } = useIngestionWorker();
|
||||
|
||||
const handleFileSelect = useCallback(async (file: File) => {
|
||||
// Extract project name from filename
|
||||
const projectName = file.name.replace('.zip', '');
|
||||
setProjectName(projectName);
|
||||
|
||||
// Switch to loading view
|
||||
setViewMode('loading');
|
||||
|
||||
try {
|
||||
// Run pipeline in Web Worker (non-blocking!)
|
||||
const result = await runPipelineInWorker(file, (progress) => {
|
||||
setProgress(progress);
|
||||
});
|
||||
|
||||
// Store results
|
||||
setGraph(result.graph);
|
||||
setFileContents(result.fileContents);
|
||||
|
||||
// Switch to exploring view
|
||||
setViewMode('exploring');
|
||||
} catch (error) {
|
||||
console.error('Pipeline error:', error);
|
||||
setProgress({
|
||||
phase: 'error',
|
||||
percent: 0,
|
||||
message: 'Error processing file',
|
||||
detail: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
// Go back to onboarding after a delay
|
||||
setTimeout(() => {
|
||||
setViewMode('onboarding');
|
||||
setProgress(null);
|
||||
}, 3000);
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineInWorker]);
|
||||
|
||||
const handleFocusNode = useCallback((nodeId: string) => {
|
||||
graphCanvasRef.current?.focusNode(nodeId);
|
||||
}, []);
|
||||
|
||||
// Render based on view mode
|
||||
if (viewMode === 'onboarding') {
|
||||
return <DropZone onFileSelect={handleFileSelect} />;
|
||||
}
|
||||
|
||||
if (viewMode === 'loading' && progress) {
|
||||
return <LoadingOverlay progress={progress} />;
|
||||
}
|
||||
|
||||
// Exploring view
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-void overflow-hidden">
|
||||
<Header onFocusNode={handleFocusNode} />
|
||||
|
||||
<main className="flex-1 flex min-h-0">
|
||||
{/* Left Panel - File Tree */}
|
||||
<FileTreePanel onFocusNode={handleFocusNode} />
|
||||
|
||||
{/* Graph area - takes remaining space */}
|
||||
<div className="flex-1 relative min-w-0">
|
||||
<GraphCanvas ref={graphCanvasRef} />
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Code & Chat (tabbed) */}
|
||||
{isRightPanelOpen && <RightPanel />}
|
||||
</main>
|
||||
|
||||
<StatusBar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AppStateProvider>
|
||||
<AppContent />
|
||||
</AppStateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
119
src/components/DropZone.tsx
Normal file
119
src/components/DropZone.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useState, useCallback, DragEvent } from 'react';
|
||||
import { Upload, FileArchive } from 'lucide-react';
|
||||
|
||||
interface DropZoneProps {
|
||||
onFileSelect: (file: File) => void;
|
||||
}
|
||||
|
||||
export const DropZone = ({ onFileSelect }: DropZoneProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.name.endsWith('.zip')) {
|
||||
onFileSelect(file);
|
||||
} else {
|
||||
alert('Please drop a .zip file');
|
||||
}
|
||||
}
|
||||
}, [onFileSelect]);
|
||||
|
||||
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.name.endsWith('.zip')) {
|
||||
onFileSelect(file);
|
||||
} else {
|
||||
alert('Please select a .zip file');
|
||||
}
|
||||
}
|
||||
}, [onFileSelect]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-8 bg-void">
|
||||
{/* Background gradient effects */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-node-interface/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`
|
||||
relative w-full max-w-lg p-16
|
||||
bg-surface border-2 border-dashed rounded-3xl
|
||||
transition-all duration-300 cursor-pointer
|
||||
${isDragging
|
||||
? 'border-accent bg-elevated scale-105 shadow-glow'
|
||||
: 'border-border-default hover:border-accent/50 hover:bg-elevated/50 animate-breathe'
|
||||
}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`
|
||||
mx-auto w-20 h-20 mb-6
|
||||
flex items-center justify-center
|
||||
bg-gradient-to-br from-accent to-node-interface
|
||||
rounded-2xl shadow-glow
|
||||
transition-transform duration-300
|
||||
${isDragging ? 'scale-110' : ''}
|
||||
`}>
|
||||
{isDragging ? (
|
||||
<Upload className="w-10 h-10 text-white" />
|
||||
) : (
|
||||
<FileArchive className="w-10 h-10 text-white" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
|
||||
{isDragging ? 'Drop it here!' : 'Drop your codebase'}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary text-center mb-6">
|
||||
Drag & drop a .zip file to generate a knowledge graph
|
||||
</p>
|
||||
|
||||
{/* Hints */}
|
||||
<div className="flex items-center justify-center gap-3 text-xs text-text-muted">
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
.zip
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
up to 50MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
465
src/components/FileTreePanel.tsx
Normal file
465
src/components/FileTreePanel.tsx
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FileCode,
|
||||
Search,
|
||||
Filter,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
Box,
|
||||
Braces,
|
||||
Variable,
|
||||
Hash,
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { FILTERABLE_LABELS, NODE_COLORS } from '../lib/constants';
|
||||
import { GraphNode, NodeLabel } from '../core/graph/types';
|
||||
|
||||
// Tree node structure
|
||||
interface TreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'folder' | 'file';
|
||||
path: string;
|
||||
children: TreeNode[];
|
||||
graphNode?: GraphNode;
|
||||
}
|
||||
|
||||
// Build tree from graph nodes
|
||||
const buildFileTree = (nodes: GraphNode[]): TreeNode[] => {
|
||||
const root: TreeNode[] = [];
|
||||
const pathMap = new Map<string, TreeNode>();
|
||||
|
||||
// Filter to only folders and files
|
||||
const fileNodes = nodes.filter(n => n.label === 'Folder' || n.label === 'File');
|
||||
|
||||
// Sort by path to ensure parents come before children
|
||||
fileNodes.sort((a, b) => a.properties.filePath.localeCompare(b.properties.filePath));
|
||||
|
||||
fileNodes.forEach(node => {
|
||||
const parts = node.properties.filePath.split('/').filter(Boolean);
|
||||
let currentPath = '';
|
||||
let currentLevel = root;
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
|
||||
let existing = pathMap.get(currentPath);
|
||||
|
||||
if (!existing) {
|
||||
const isLastPart = index === parts.length - 1;
|
||||
const isFile = isLastPart && node.label === 'File';
|
||||
|
||||
existing = {
|
||||
id: isLastPart ? node.id : currentPath,
|
||||
name: part,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
path: currentPath,
|
||||
children: [],
|
||||
graphNode: isLastPart ? node : undefined,
|
||||
};
|
||||
|
||||
pathMap.set(currentPath, existing);
|
||||
currentLevel.push(existing);
|
||||
}
|
||||
|
||||
currentLevel = existing.children;
|
||||
});
|
||||
});
|
||||
|
||||
return root;
|
||||
};
|
||||
|
||||
// Tree item component
|
||||
interface TreeItemProps {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
searchQuery: string;
|
||||
onNodeClick: (node: TreeNode) => void;
|
||||
expandedPaths: Set<string>;
|
||||
toggleExpanded: (path: string) => void;
|
||||
selectedPath: string | null;
|
||||
}
|
||||
|
||||
const TreeItem = ({
|
||||
node,
|
||||
depth,
|
||||
searchQuery,
|
||||
onNodeClick,
|
||||
expandedPaths,
|
||||
toggleExpanded,
|
||||
selectedPath,
|
||||
}: TreeItemProps) => {
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const isSelected = selectedPath === node.path;
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
// Filter children based on search
|
||||
const filteredChildren = useMemo(() => {
|
||||
if (!searchQuery) return node.children;
|
||||
return node.children.filter(child =>
|
||||
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
child.children.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
}, [node.children, searchQuery]);
|
||||
|
||||
// Check if this node matches search
|
||||
const matchesSearch = searchQuery && node.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const handleClick = () => {
|
||||
if (hasChildren) {
|
||||
toggleExpanded(node.path);
|
||||
}
|
||||
onNodeClick(node);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`
|
||||
w-full flex items-center gap-1.5 px-2 py-1 text-left text-sm
|
||||
hover:bg-hover transition-colors rounded
|
||||
${isSelected ? 'bg-accent/20 text-accent' : 'text-text-secondary hover:text-text-primary'}
|
||||
${matchesSearch ? 'bg-accent/10' : ''}
|
||||
`}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
{/* Expand/collapse icon */}
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-text-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
|
||||
{/* Node icon */}
|
||||
{node.type === 'folder' ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
||||
)
|
||||
) : (
|
||||
<FileCode className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.File }} />
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span className="truncate font-mono text-xs">{node.name}</span>
|
||||
</button>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && filteredChildren.length > 0 && (
|
||||
<div>
|
||||
{filteredChildren.map(child => (
|
||||
<TreeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
searchQuery={searchQuery}
|
||||
onNodeClick={onNodeClick}
|
||||
expandedPaths={expandedPaths}
|
||||
toggleExpanded={toggleExpanded}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Icon for node types
|
||||
const getNodeTypeIcon = (label: NodeLabel) => {
|
||||
switch (label) {
|
||||
case 'Folder': return Folder;
|
||||
case 'File': return FileCode;
|
||||
case 'Class': return Box;
|
||||
case 'Function': return Braces;
|
||||
case 'Method': return Braces;
|
||||
case 'Interface': return Hash;
|
||||
case 'Import': return FileCode;
|
||||
default: return Variable;
|
||||
}
|
||||
};
|
||||
|
||||
interface FileTreePanelProps {
|
||||
onFocusNode: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
const { graph, visibleLabels, toggleLabelVisibility, selectedNode, setSelectedNode, openCodePanel, depthFilter, setDepthFilter } = useAppState();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState<'files' | 'filters'>('files');
|
||||
|
||||
// Build file tree from graph
|
||||
const fileTree = useMemo(() => {
|
||||
if (!graph) return [];
|
||||
return buildFileTree(graph.nodes);
|
||||
}, [graph]);
|
||||
|
||||
// Auto-expand first level on initial load
|
||||
useEffect(() => {
|
||||
if (fileTree.length > 0 && expandedPaths.size === 0) {
|
||||
const firstLevel = new Set(fileTree.map(n => n.path));
|
||||
setExpandedPaths(firstLevel);
|
||||
}
|
||||
}, [fileTree.length]); // Only run when tree first loads
|
||||
|
||||
const toggleExpanded = useCallback((path: string) => {
|
||||
setExpandedPaths(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNodeClick = useCallback((treeNode: TreeNode) => {
|
||||
if (treeNode.graphNode) {
|
||||
// Only focus if selecting a different node
|
||||
const isSameNode = selectedNode?.id === treeNode.graphNode.id;
|
||||
setSelectedNode(treeNode.graphNode);
|
||||
openCodePanel();
|
||||
if (!isSameNode) {
|
||||
onFocusNode(treeNode.graphNode.id);
|
||||
}
|
||||
}
|
||||
}, [setSelectedNode, openCodePanel, onFocusNode, selectedNode]);
|
||||
|
||||
const selectedPath = selectedNode?.properties.filePath || null;
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="h-full w-12 bg-surface border-r border-border-subtle flex flex-col items-center py-3 gap-2">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className="p-2 text-text-secondary hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
title="Expand Panel"
|
||||
>
|
||||
<PanelLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="w-6 h-px bg-border-subtle my-1" />
|
||||
<button
|
||||
onClick={() => { setIsCollapsed(false); setActiveTab('files'); }}
|
||||
className={`p-2 rounded transition-colors ${activeTab === 'files' ? 'text-accent bg-accent/10' : 'text-text-secondary hover:text-text-primary hover:bg-hover'}`}
|
||||
title="File Explorer"
|
||||
>
|
||||
<Folder className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsCollapsed(false); setActiveTab('filters'); }}
|
||||
className={`p-2 rounded transition-colors ${activeTab === 'filters' ? 'text-accent bg-accent/10' : 'text-text-secondary hover:text-text-primary hover:bg-hover'}`}
|
||||
title="Filters"
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-64 bg-surface border-r border-border-subtle flex flex-col animate-slide-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('files')}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
activeTab === 'files'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
Explorer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('filters')}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
activeTab === 'filters'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
title="Collapse Panel"
|
||||
>
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'files' && (
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="px-3 py-2 border-b border-border-subtle">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 bg-elevated border border-border-subtle rounded text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File tree */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin py-2">
|
||||
{fileTree.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-text-muted text-xs">
|
||||
No files loaded
|
||||
</div>
|
||||
) : (
|
||||
fileTree.map(node => (
|
||||
<TreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
searchQuery={searchQuery}
|
||||
onNodeClick={handleNodeClick}
|
||||
expandedPaths={expandedPaths}
|
||||
toggleExpanded={toggleExpanded}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'filters' && (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-3">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
|
||||
Node Types
|
||||
</h3>
|
||||
<p className="text-[11px] text-text-muted mb-3">
|
||||
Toggle visibility of node types in the graph
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{FILTERABLE_LABELS.map((label) => {
|
||||
const Icon = getNodeTypeIcon(label);
|
||||
const isVisible = visibleLabels.includes(label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => toggleLabelVisibility(label)}
|
||||
className={`
|
||||
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
|
||||
${isVisible
|
||||
? 'bg-elevated text-text-primary'
|
||||
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded flex items-center justify-center ${isVisible ? '' : 'opacity-40'}`}
|
||||
style={{ backgroundColor: `${NODE_COLORS[label]}20` }}
|
||||
>
|
||||
<Icon className="w-3 h-3" style={{ color: NODE_COLORS[label] }} />
|
||||
</div>
|
||||
<span className="text-xs flex-1">{label}</span>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Depth Filter */}
|
||||
<div className="mt-6 pt-4 border-t border-border-subtle">
|
||||
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
|
||||
<Target className="w-3 h-3 inline mr-1.5" />
|
||||
Focus Depth
|
||||
</h3>
|
||||
<p className="text-[11px] text-text-muted mb-3">
|
||||
Show nodes within N hops of selection
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[
|
||||
{ value: null, label: 'All' },
|
||||
{ value: 1, label: '1 hop' },
|
||||
{ value: 2, label: '2 hops' },
|
||||
{ value: 3, label: '3 hops' },
|
||||
{ value: 5, label: '5 hops' },
|
||||
].map(({ value, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setDepthFilter(value)}
|
||||
className={`
|
||||
px-2 py-1 text-xs rounded transition-colors
|
||||
${depthFilter === value
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-elevated text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{depthFilter !== null && !selectedNode && (
|
||||
<p className="mt-2 text-[10px] text-amber-400">
|
||||
Select a node to apply depth filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 pt-4 border-t border-border-subtle">
|
||||
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-3">
|
||||
Color Legend
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['Folder', 'File', 'Class', 'Function', 'Interface', 'Method'] as NodeLabel[]).map(label => (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: NODE_COLORS[label] }}
|
||||
/>
|
||||
<span className="text-[10px] text-text-muted">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats footer */}
|
||||
{graph && (
|
||||
<div className="px-3 py-2 border-t border-border-subtle bg-elevated/50">
|
||||
<div className="flex items-center justify-between text-[10px] text-text-muted">
|
||||
<span>{graph.nodes.length} nodes</span>
|
||||
<span>{graph.relationships.length} edges</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
246
src/components/GraphCanvas.tsx
Normal file
246
src/components/GraphCanvas.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause } from 'lucide-react';
|
||||
import { useSigma } from '../hooks/useSigma';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { knowledgeGraphToGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter';
|
||||
import Graph from 'graphology';
|
||||
|
||||
export interface GraphCanvasHandle {
|
||||
focusNode: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
const { graph, setSelectedNode, selectedNode: appSelectedNode, visibleLabels, openCodePanel, depthFilter } = useAppState();
|
||||
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
|
||||
|
||||
const handleNodeClick = useCallback((nodeId: string) => {
|
||||
if (!graph) return;
|
||||
const node = graph.nodes.find(n => n.id === nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
openCodePanel();
|
||||
}
|
||||
}, [graph, setSelectedNode, openCodePanel]);
|
||||
|
||||
const handleNodeHover = useCallback((nodeId: string | null) => {
|
||||
if (!nodeId || !graph) {
|
||||
setHoveredNodeName(null);
|
||||
return;
|
||||
}
|
||||
const node = graph.nodes.find(n => n.id === nodeId);
|
||||
if (node) {
|
||||
setHoveredNodeName(node.properties.name);
|
||||
}
|
||||
}, [graph]);
|
||||
|
||||
const handleStageClick = useCallback(() => {
|
||||
setSelectedNode(null);
|
||||
}, [setSelectedNode]);
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
sigmaRef,
|
||||
setGraph: setSigmaGraph,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
focusNode,
|
||||
isLayoutRunning,
|
||||
startLayout,
|
||||
stopLayout,
|
||||
selectedNode: sigmaSelectedNode,
|
||||
setSelectedNode: setSigmaSelectedNode,
|
||||
} = useSigma({
|
||||
onNodeClick: handleNodeClick,
|
||||
onNodeHover: handleNodeHover,
|
||||
onStageClick: handleStageClick,
|
||||
});
|
||||
|
||||
// Expose focusNode to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusNode: (nodeId: string) => {
|
||||
// Also update app state so the selection syncs properly
|
||||
if (graph) {
|
||||
const node = graph.nodes.find(n => n.id === nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
openCodePanel();
|
||||
}
|
||||
}
|
||||
focusNode(nodeId);
|
||||
}
|
||||
}), [focusNode, graph, setSelectedNode, openCodePanel]);
|
||||
|
||||
// Update Sigma graph when KnowledgeGraph changes
|
||||
useEffect(() => {
|
||||
if (!graph) return;
|
||||
const sigmaGraph = knowledgeGraphToGraphology(graph);
|
||||
setSigmaGraph(sigmaGraph);
|
||||
}, [graph, setSigmaGraph]);
|
||||
|
||||
// Update node visibility when filters change
|
||||
useEffect(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
if (!sigma) return;
|
||||
|
||||
const sigmaGraph = sigma.getGraph() as Graph<SigmaNodeAttributes, SigmaEdgeAttributes>;
|
||||
if (sigmaGraph.order === 0) return; // Don't filter empty graph
|
||||
|
||||
filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels);
|
||||
sigma.refresh();
|
||||
}, [visibleLabels, depthFilter, appSelectedNode, sigmaRef]);
|
||||
|
||||
// Sync app selected node with sigma
|
||||
useEffect(() => {
|
||||
if (appSelectedNode) {
|
||||
setSigmaSelectedNode(appSelectedNode.id);
|
||||
} else {
|
||||
setSigmaSelectedNode(null);
|
||||
}
|
||||
}, [appSelectedNode, setSigmaSelectedNode]);
|
||||
|
||||
// Focus on selected node
|
||||
const handleFocusSelected = useCallback(() => {
|
||||
if (appSelectedNode) {
|
||||
focusNode(appSelectedNode.id);
|
||||
}
|
||||
}, [appSelectedNode, focusNode]);
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelectedNode(null);
|
||||
setSigmaSelectedNode(null);
|
||||
resetZoom();
|
||||
}, [setSelectedNode, setSigmaSelectedNode, resetZoom]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-void">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `
|
||||
radial-gradient(circle at 50% 50%, rgba(124, 58, 237, 0.03) 0%, transparent 70%),
|
||||
linear-gradient(to bottom, #06060a, #0a0a10)
|
||||
`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sigma container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="sigma-container w-full h-full cursor-grab active:cursor-grabbing"
|
||||
/>
|
||||
|
||||
{/* Hovered node tooltip - only show when NOT selected */}
|
||||
{hoveredNodeName && !sigmaSelectedNode && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1.5 bg-elevated/95 border border-border-subtle rounded-lg backdrop-blur-sm z-20 pointer-events-none animate-fade-in">
|
||||
<span className="font-mono text-sm text-text-primary">{hoveredNodeName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection info bar */}
|
||||
{sigmaSelectedNode && appSelectedNode && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-accent/20 border border-accent/30 rounded-xl backdrop-blur-sm z-20 animate-slide-up">
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
|
||||
<span className="font-mono text-sm text-text-primary">
|
||||
{appSelectedNode.properties.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
({appSelectedNode.label})
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClearSelection}
|
||||
className="ml-2 px-2 py-0.5 text-xs text-text-secondary hover:text-text-primary hover:bg-white/10 rounded transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph Controls - Bottom Right */}
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-1 z-10">
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
|
||||
title="Fit to Screen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-border-subtle my-1" />
|
||||
|
||||
{/* Focus on selected */}
|
||||
{appSelectedNode && (
|
||||
<button
|
||||
onClick={handleFocusSelected}
|
||||
className="w-9 h-9 flex items-center justify-center bg-accent/20 border border-accent/30 rounded-md text-accent hover:bg-accent/30 transition-colors"
|
||||
title="Focus on Selected Node"
|
||||
>
|
||||
<Focus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Clear selection */}
|
||||
{sigmaSelectedNode && (
|
||||
<button
|
||||
onClick={handleClearSelection}
|
||||
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
|
||||
title="Clear Selection"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-border-subtle my-1" />
|
||||
|
||||
{/* Layout control */}
|
||||
<button
|
||||
onClick={isLayoutRunning ? stopLayout : startLayout}
|
||||
className={`
|
||||
w-9 h-9 flex items-center justify-center border rounded-md transition-all
|
||||
${isLayoutRunning
|
||||
? 'bg-accent border-accent text-white shadow-glow animate-pulse'
|
||||
: 'bg-elevated border-border-subtle text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}
|
||||
`}
|
||||
title={isLayoutRunning ? 'Stop Layout' : 'Run Layout Again'}
|
||||
>
|
||||
{isLayoutRunning ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Layout running indicator */}
|
||||
{isLayoutRunning && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-3 py-1.5 bg-emerald-500/20 border border-emerald-500/30 rounded-full backdrop-blur-sm z-10 animate-fade-in">
|
||||
<div className="w-2 h-2 bg-emerald-400 rounded-full animate-ping" />
|
||||
<span className="text-xs text-emerald-400 font-medium">Layout optimizing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
GraphCanvas.displayName = 'GraphCanvas';
|
||||
219
src/components/Header.tsx
Normal file
219
src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { Search, Settings, HelpCircle, Sparkles } from 'lucide-react';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { GraphNode } from '../core/graph/types';
|
||||
|
||||
// Color mapping for node types in search results
|
||||
const NODE_TYPE_COLORS: Record<string, string> = {
|
||||
Folder: '#6366f1',
|
||||
File: '#3b82f6',
|
||||
Function: '#10b981',
|
||||
Class: '#f59e0b',
|
||||
Method: '#14b8a6',
|
||||
Interface: '#ec4899',
|
||||
Variable: '#64748b',
|
||||
Import: '#475569',
|
||||
Type: '#a78bfa',
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export const Header = ({ onFocusNode }: HeaderProps) => {
|
||||
const { projectName, graph, openChatPanel, isRightPanelOpen, rightPanelTab } = useAppState();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const nodeCount = graph?.nodes.length ?? 0;
|
||||
const edgeCount = graph?.relationships.length ?? 0;
|
||||
|
||||
// Search results - filter nodes by name
|
||||
const searchResults = useMemo(() => {
|
||||
if (!graph || !searchQuery.trim()) return [];
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return graph.nodes
|
||||
.filter(node => node.properties.name.toLowerCase().includes(query))
|
||||
.slice(0, 10); // Limit to 10 results
|
||||
}, [graph, searchQuery]);
|
||||
|
||||
// Handle clicking outside to close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut (Cmd+K / Ctrl+K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setIsSearchOpen(true);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setIsSearchOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Handle keyboard navigation in results
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isSearchOpen || searchResults.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(i => Math.min(i + 1, searchResults.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(i => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const selected = searchResults[selectedIndex];
|
||||
if (selected) {
|
||||
handleSelectNode(selected);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectNode = (node: GraphNode) => {
|
||||
// onFocusNode handles both camera focus AND selection in useSigma
|
||||
onFocusNode?.(node.id);
|
||||
setSearchQuery('');
|
||||
setIsSearchOpen(false);
|
||||
setSelectedIndex(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-5 py-3 bg-deep border-b border-dashed border-border-subtle">
|
||||
{/* Left section */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-md shadow-glow text-white text-sm font-bold">
|
||||
◇
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] tracking-tight">GitNexus</span>
|
||||
</div>
|
||||
|
||||
{/* Project badge */}
|
||||
{projectName && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary">
|
||||
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
|
||||
<span className="truncate max-w-[200px]">{projectName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center - Search */}
|
||||
<div className="flex-1 max-w-md mx-6 relative" ref={searchRef}>
|
||||
<div className="flex items-center gap-2.5 px-3.5 py-2 bg-surface border border-border-subtle rounded-lg transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
|
||||
<Search className="w-4 h-4 text-text-muted flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search nodes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setIsSearchOpen(true);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onFocus={() => setIsSearchOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted"
|
||||
/>
|
||||
<kbd className="px-1.5 py-0.5 bg-elevated border border-border-subtle rounded text-[10px] text-text-muted font-mono">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
{isSearchOpen && searchQuery.trim() && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-surface border border-border-subtle rounded-lg shadow-xl overflow-hidden z-50">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
No nodes found for "{searchQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{searchResults.map((node, index) => (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => handleSelectNode(node)}
|
||||
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-accent/20 text-text-primary'
|
||||
: 'hover:bg-hover text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{/* Node type indicator */}
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: NODE_TYPE_COLORS[node.label] || '#6b7280' }}
|
||||
/>
|
||||
{/* Node name */}
|
||||
<span className="flex-1 truncate text-sm font-medium">
|
||||
{node.properties.name}
|
||||
</span>
|
||||
{/* Node type badge */}
|
||||
<span className="text-xs text-text-muted px-2 py-0.5 bg-elevated rounded">
|
||||
{node.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Stats */}
|
||||
{graph && (
|
||||
<div className="flex items-center gap-4 mr-4 text-xs text-text-muted">
|
||||
<span>{nodeCount} nodes</span>
|
||||
<span>{edgeCount} edges</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon buttons */}
|
||||
<button className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors">
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<button className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors">
|
||||
<HelpCircle className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
|
||||
{/* AI Button */}
|
||||
<button
|
||||
onClick={openChatPanel}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${isRightPanelOpen && rightPanelTab === 'chat'
|
||||
? 'bg-accent text-white shadow-glow'
|
||||
: 'bg-gradient-to-r from-accent to-accent-dim text-white shadow-glow hover:shadow-lg hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Nexus AI</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
66
src/components/LoadingOverlay.tsx
Normal file
66
src/components/LoadingOverlay.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { PipelineProgress } from '../types/pipeline';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
progress: PipelineProgress;
|
||||
}
|
||||
|
||||
export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center bg-void z-50">
|
||||
{/* Background gradient effects */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-accent/10 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-1/3 right-1/3 w-96 h-96 bg-node-interface/10 rounded-full blur-3xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Pulsing orb */}
|
||||
<div className="relative mb-10">
|
||||
<div className="w-28 h-28 bg-gradient-to-br from-accent to-node-interface rounded-full animate-pulse-glow" />
|
||||
<div className="absolute inset-0 w-28 h-28 bg-gradient-to-br from-accent to-node-interface rounded-full blur-xl opacity-50" />
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-80 mb-4">
|
||||
<div className="h-1.5 bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<div className="text-center">
|
||||
<p className="font-mono text-sm text-text-secondary mb-1">
|
||||
{progress.message}
|
||||
<span className="animate-pulse">|</span>
|
||||
</p>
|
||||
{progress.detail && (
|
||||
<p className="font-mono text-xs text-text-muted truncate max-w-md">
|
||||
{progress.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{progress.stats && (
|
||||
<div className="mt-8 flex items-center gap-6 text-xs text-text-muted">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-node-file rounded-full" />
|
||||
<span>{progress.stats.filesProcessed} / {progress.stats.totalFiles} files</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-node-function rounded-full" />
|
||||
<span>{progress.stats.nodesCreated} nodes</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Percent */}
|
||||
<p className="mt-4 font-mono text-3xl font-semibold text-text-primary">
|
||||
{progress.percent}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
381
src/components/RightPanel.tsx
Normal file
381
src/components/RightPanel.tsx
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { X, Send, Sparkles, User, FileCode, Hash, GitBranch, Code, MessageSquare, PanelRightClose } from 'lucide-react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { NODE_COLORS } from '../lib/constants';
|
||||
|
||||
// Custom syntax theme
|
||||
const customTheme = {
|
||||
...vscDarkPlus,
|
||||
'pre[class*="language-"]': {
|
||||
...vscDarkPlus['pre[class*="language-"]'],
|
||||
background: '#0a0a10',
|
||||
margin: 0,
|
||||
padding: '16px 0',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
},
|
||||
'code[class*="language-"]': {
|
||||
...vscDarkPlus['code[class*="language-"]'],
|
||||
background: 'transparent',
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
},
|
||||
};
|
||||
|
||||
// Chat message interface
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const RightPanel = () => {
|
||||
const {
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
fileContents,
|
||||
graph,
|
||||
isRightPanelOpen,
|
||||
setRightPanelOpen,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
} = useAppState();
|
||||
|
||||
// Chat state
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
|
||||
// Get source code for selected node
|
||||
const sourceCode = useMemo(() => {
|
||||
if (!selectedNode) return null;
|
||||
|
||||
const filePath = selectedNode.properties.filePath;
|
||||
const content = fileContents.get(filePath);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
const startLine = selectedNode.properties.startLine ?? 0;
|
||||
const endLine = selectedNode.properties.endLine ?? startLine;
|
||||
|
||||
// Get lines around the definition with more context
|
||||
const lines = content.split('\n');
|
||||
const contextStart = Math.max(0, startLine - 3);
|
||||
const contextEnd = Math.min(lines.length - 1, endLine + 15);
|
||||
|
||||
return {
|
||||
code: lines.slice(contextStart, contextEnd + 1).join('\n'),
|
||||
startLine: contextStart,
|
||||
highlightStart: startLine - contextStart,
|
||||
highlightEnd: endLine - contextStart,
|
||||
totalLines: lines.length,
|
||||
};
|
||||
}, [selectedNode, fileContents]);
|
||||
|
||||
// Get language for syntax highlighting
|
||||
const language = useMemo(() => {
|
||||
if (!selectedNode) return 'typescript';
|
||||
const filePath = selectedNode.properties.filePath;
|
||||
if (filePath.endsWith('.py')) return 'python';
|
||||
if (filePath.endsWith('.js') || filePath.endsWith('.jsx')) return 'javascript';
|
||||
return 'typescript';
|
||||
}, [selectedNode]);
|
||||
|
||||
// Count relationships
|
||||
const relationshipCount = useMemo(() => {
|
||||
if (!selectedNode || !graph) return 0;
|
||||
return graph.relationships.filter(
|
||||
r => r.sourceId === selectedNode.id || r.targetId === selectedNode.id
|
||||
).length;
|
||||
}, [selectedNode, graph]);
|
||||
|
||||
// Chat handlers
|
||||
const handleSendMessage = () => {
|
||||
if (!chatInput.trim()) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: chatInput.trim(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setChatInput('');
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'This is a placeholder response. AI integration coming soon! I will be able to help you understand the codebase, find specific functions, and explain how different parts connect.',
|
||||
};
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const chatSuggestions = [
|
||||
'What does this project do?',
|
||||
'Show me the entry point',
|
||||
'Find all API handlers',
|
||||
];
|
||||
|
||||
if (!isRightPanelOpen) return null;
|
||||
|
||||
const nodeColor = selectedNode ? NODE_COLORS[selectedNode.label] || '#6b7280' : '#6b7280';
|
||||
|
||||
return (
|
||||
<aside className="w-[40%] min-w-[400px] max-w-[600px] flex flex-col bg-deep border-l border-border-subtle animate-slide-in relative z-30 flex-shrink-0">
|
||||
{/* Header with tabs */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-surface border-b border-border-subtle">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Code Tab */}
|
||||
<button
|
||||
onClick={() => setRightPanelTab('code')}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
${rightPanelTab === 'code'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
|
||||
{/* Chat Tab */}
|
||||
<button
|
||||
onClick={() => setRightPanelTab('chat')}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
${rightPanelTab === 'chat'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span>Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => setRightPanelOpen(false)}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
title="Close Panel"
|
||||
>
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Code Panel Content */}
|
||||
{rightPanelTab === 'code' && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{selectedNode ? (
|
||||
<>
|
||||
{/* File info header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-elevated/50 border-b border-border-subtle">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide"
|
||||
style={{ backgroundColor: nodeColor, color: '#06060a' }}
|
||||
>
|
||||
{selectedNode.label}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-medium text-text-primary truncate">
|
||||
{selectedNode.properties.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
className="ml-auto p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File path breadcrumb */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs text-text-muted border-b border-border-subtle bg-surface/50">
|
||||
<FileCode className="w-3.5 h-3.5" />
|
||||
<span className="font-mono truncate">{selectedNode.properties.filePath}</span>
|
||||
</div>
|
||||
|
||||
{/* Code content */}
|
||||
<div className="flex-1 overflow-auto scrollbar-thin">
|
||||
{sourceCode ? (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={customTheme}
|
||||
showLineNumbers
|
||||
startingLineNumber={sourceCode.startLine + 1}
|
||||
lineNumberStyle={{
|
||||
minWidth: '3em',
|
||||
paddingRight: '1em',
|
||||
color: '#5a5a70',
|
||||
textAlign: 'right',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
lineProps={(lineNumber) => {
|
||||
const isHighlighted =
|
||||
lineNumber >= sourceCode.startLine + sourceCode.highlightStart + 1 &&
|
||||
lineNumber <= sourceCode.startLine + sourceCode.highlightEnd + 1;
|
||||
return {
|
||||
style: {
|
||||
display: 'block',
|
||||
backgroundColor: isHighlighted ? 'rgba(124, 58, 237, 0.15)' : 'transparent',
|
||||
borderLeft: isHighlighted ? '3px solid #7c3aed' : '3px solid transparent',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '16px',
|
||||
},
|
||||
};
|
||||
}}
|
||||
wrapLines
|
||||
>
|
||||
{sourceCode.code}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-sm text-text-muted">
|
||||
Source code not available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata footer */}
|
||||
<div className="flex items-center gap-4 px-4 py-2.5 bg-surface border-t border-border-subtle text-xs text-text-muted">
|
||||
{selectedNode.properties.startLine !== undefined && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Hash className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
Lines {selectedNode.properties.startLine + 1}
|
||||
{selectedNode.properties.endLine !== selectedNode.properties.startLine &&
|
||||
`–${(selectedNode.properties.endLine ?? selectedNode.properties.startLine) + 1}`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
<span>{relationshipCount} connections</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center px-8">
|
||||
<div className="w-16 h-16 mb-4 flex items-center justify-center bg-elevated border border-border-subtle rounded-xl">
|
||||
<Code className="w-8 h-8 text-text-muted" />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-text-secondary mb-2">
|
||||
No code selected
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Click on a node in the graph or file tree to view its source code
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Panel Content */}
|
||||
{rightPanelTab === 'chat' && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Chat header */}
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 bg-elevated/50 border-b border-border-subtle">
|
||||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
<span className="font-medium text-sm">Nexus AI</span>
|
||||
<span className="text-xs text-text-muted">• Ask about the codebase</span>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-xl shadow-glow text-2xl">
|
||||
🧠
|
||||
</div>
|
||||
<h3 className="text-base font-medium mb-2">
|
||||
Ask me anything
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary leading-relaxed mb-5">
|
||||
I can help you understand the architecture, find functions, or explain connections.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{chatSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => setChatInput(suggestion)}
|
||||
className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-full text-xs text-text-secondary hover:border-accent hover:text-text-primary transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''} animate-fade-in`}
|
||||
>
|
||||
<div className={`
|
||||
w-7 h-7 flex-shrink-0 flex items-center justify-center rounded-md text-sm
|
||||
${message.role === 'assistant'
|
||||
? 'bg-gradient-to-br from-accent to-node-interface text-white'
|
||||
: 'bg-elevated border border-border-subtle text-text-secondary'
|
||||
}
|
||||
`}>
|
||||
{message.role === 'assistant' ? (
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<User className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className={`
|
||||
max-w-[85%] px-3.5 py-2.5 rounded-xl text-sm leading-relaxed
|
||||
${message.role === 'assistant'
|
||||
? 'bg-elevated border border-border-subtle text-text-primary'
|
||||
: 'bg-accent text-white'
|
||||
}
|
||||
`}>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 bg-surface border-t border-border-subtle">
|
||||
<div className="flex items-end gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-xl transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
|
||||
<textarea
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about the codebase..."
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted resize-none max-h-24"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!chatInput.trim()}
|
||||
className="w-7 h-7 flex items-center justify-center bg-accent rounded-md text-white transition-all hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
66
src/components/StatusBar.tsx
Normal file
66
src/components/StatusBar.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useAppState } from '../hooks/useAppState';
|
||||
|
||||
export const StatusBar = () => {
|
||||
const { graph, progress } = useAppState();
|
||||
|
||||
const nodeCount = graph?.nodes.length ?? 0;
|
||||
const edgeCount = graph?.relationships.length ?? 0;
|
||||
|
||||
// Detect primary language
|
||||
const primaryLanguage = (() => {
|
||||
if (!graph) return null;
|
||||
const languages = graph.nodes
|
||||
.map(n => n.properties.language)
|
||||
.filter(Boolean);
|
||||
if (languages.length === 0) return null;
|
||||
|
||||
const counts = languages.reduce((acc, lang) => {
|
||||
acc[lang!] = (acc[lang!] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
})();
|
||||
|
||||
return (
|
||||
<footer className="flex items-center justify-between px-5 py-2 bg-deep border-t border-dashed border-border-subtle text-[11px] text-text-muted">
|
||||
{/* Left - Status */}
|
||||
<div className="flex items-center gap-4">
|
||||
{progress && progress.phase !== 'complete' ? (
|
||||
<>
|
||||
<div className="w-28 h-1 bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{progress.message}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-node-function rounded-full" />
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right - Stats */}
|
||||
<div className="flex items-center gap-3">
|
||||
{graph && (
|
||||
<>
|
||||
<span>{nodeCount} nodes</span>
|
||||
<span className="text-border-default">•</span>
|
||||
<span>{edgeCount} edges</span>
|
||||
{primaryLanguage && (
|
||||
<>
|
||||
<span className="text-border-default">•</span>
|
||||
<span>{primaryLanguage}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
239
src/config/ignore-service.ts
Normal file
239
src/config/ignore-service.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
const DEFAULT_IGNORE_LIST = new Set([
|
||||
// Version Control
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'.bzr',
|
||||
|
||||
// IDEs & Editors
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.vs',
|
||||
'.eclipse',
|
||||
'.settings',
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
|
||||
// Dependencies
|
||||
'node_modules',
|
||||
'bower_components',
|
||||
'jspm_packages',
|
||||
'vendor', // PHP/Go
|
||||
'packages', // Sometimes used for deps
|
||||
'venv',
|
||||
'.venv',
|
||||
'env',
|
||||
'.env',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.mypy_cache',
|
||||
'site-packages',
|
||||
'.tox',
|
||||
'eggs',
|
||||
'.eggs',
|
||||
'lib64',
|
||||
'parts',
|
||||
'sdist',
|
||||
'wheels',
|
||||
|
||||
// Build Outputs
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'output',
|
||||
'bin',
|
||||
'obj',
|
||||
'target', // Java/Rust
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.output',
|
||||
'.vercel',
|
||||
'.netlify',
|
||||
'.serverless',
|
||||
'_build',
|
||||
'public/build',
|
||||
'.parcel-cache',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
|
||||
// Test & Coverage
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
'htmlcov',
|
||||
'.coverage',
|
||||
'__tests__', // Often just test files
|
||||
'__mocks__',
|
||||
'.jest',
|
||||
|
||||
// Logs & Temp
|
||||
'logs',
|
||||
'log',
|
||||
'tmp',
|
||||
'temp',
|
||||
'cache',
|
||||
'.cache',
|
||||
'.tmp',
|
||||
'.temp',
|
||||
|
||||
// Generated/Compiled
|
||||
'.generated',
|
||||
'generated',
|
||||
'auto-generated',
|
||||
'.terraform',
|
||||
'.serverless',
|
||||
|
||||
// Documentation (optional - might want to keep)
|
||||
// 'docs',
|
||||
// 'documentation',
|
||||
|
||||
// Misc
|
||||
'.husky',
|
||||
'.github', // GitHub config, not code
|
||||
'.circleci',
|
||||
'.gitlab',
|
||||
'fixtures', // Test fixtures
|
||||
'snapshots', // Jest snapshots
|
||||
'__snapshots__',
|
||||
]);
|
||||
|
||||
const IGNORED_EXTENSIONS = new Set([
|
||||
// Images
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp', '.bmp', '.tiff', '.tif',
|
||||
'.psd', '.ai', '.sketch', '.fig', '.xd',
|
||||
|
||||
// Archives
|
||||
'.zip', '.tar', '.gz', '.rar', '.7z', '.bz2', '.xz', '.tgz',
|
||||
|
||||
// Binary/Compiled
|
||||
'.exe', '.dll', '.so', '.dylib', '.a', '.lib', '.o', '.obj',
|
||||
'.class', '.jar', '.war', '.ear',
|
||||
'.pyc', '.pyo', '.pyd',
|
||||
'.beam', // Erlang
|
||||
'.wasm', // WebAssembly - important!
|
||||
'.node', // Native Node addons
|
||||
|
||||
// Documents
|
||||
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.odt', '.ods', '.odp',
|
||||
|
||||
// Media
|
||||
'.mp4', '.mp3', '.wav', '.mov', '.avi', '.mkv', '.flv', '.wmv',
|
||||
'.ogg', '.webm', '.flac', '.aac', '.m4a',
|
||||
|
||||
// Fonts
|
||||
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
||||
|
||||
// Databases
|
||||
'.db', '.sqlite', '.sqlite3', '.mdb', '.accdb',
|
||||
|
||||
// Minified/Bundled files
|
||||
'.min.js', '.min.css', '.bundle.js', '.chunk.js',
|
||||
|
||||
// Source maps (debug files, not source)
|
||||
'.map',
|
||||
|
||||
// Lock files (handled separately, but also here)
|
||||
'.lock',
|
||||
|
||||
// Certificates & Keys (security - don't index!)
|
||||
'.pem', '.key', '.crt', '.cer', '.p12', '.pfx',
|
||||
|
||||
// Data files (often large/binary)
|
||||
'.csv', '.tsv', '.parquet', '.avro', '.feather',
|
||||
'.npy', '.npz', '.pkl', '.pickle', '.h5', '.hdf5',
|
||||
|
||||
// Misc binary
|
||||
'.bin', '.dat', '.data', '.raw',
|
||||
'.iso', '.img', '.dmg',
|
||||
]);
|
||||
|
||||
// Files to ignore by exact name
|
||||
const IGNORED_FILES = new Set([
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'pnpm-lock.yaml',
|
||||
'composer.lock',
|
||||
'Gemfile.lock',
|
||||
'poetry.lock',
|
||||
'Cargo.lock',
|
||||
'go.sum',
|
||||
'.gitignore',
|
||||
'.gitattributes',
|
||||
'.npmrc',
|
||||
'.yarnrc',
|
||||
'.editorconfig',
|
||||
'.prettierrc',
|
||||
'.prettierignore',
|
||||
'.eslintignore',
|
||||
'.dockerignore',
|
||||
'Thumbs.db',
|
||||
'.DS_Store',
|
||||
'LICENSE',
|
||||
'LICENSE.md',
|
||||
'LICENSE.txt',
|
||||
'CHANGELOG.md',
|
||||
'CHANGELOG',
|
||||
'CONTRIBUTING.md',
|
||||
'CODE_OF_CONDUCT.md',
|
||||
'SECURITY.md',
|
||||
'.env',
|
||||
'.env.local',
|
||||
'.env.development',
|
||||
'.env.production',
|
||||
'.env.test',
|
||||
'.env.example',
|
||||
]);
|
||||
|
||||
|
||||
|
||||
export const shouldIgnorePath = (filePath: string): boolean => {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const parts = normalizedPath.split('/');
|
||||
const fileName = parts[parts.length - 1];
|
||||
const fileNameLower = fileName.toLowerCase();
|
||||
|
||||
// Check if any path segment is in ignore list
|
||||
for (const part of parts) {
|
||||
if (DEFAULT_IGNORE_LIST.has(part)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check exact filename matches
|
||||
if (IGNORED_FILES.has(fileName) || IGNORED_FILES.has(fileNameLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check extension
|
||||
const lastDotIndex = fileNameLower.lastIndexOf('.');
|
||||
if (lastDotIndex !== -1) {
|
||||
const ext = fileNameLower.substring(lastDotIndex);
|
||||
if (IGNORED_EXTENSIONS.has(ext)) return true;
|
||||
|
||||
// Handle compound extensions like .min.js, .bundle.js
|
||||
const secondLastDot = fileNameLower.lastIndexOf('.', lastDotIndex - 1);
|
||||
if (secondLastDot !== -1) {
|
||||
const compoundExt = fileNameLower.substring(secondLastDot);
|
||||
if (IGNORED_EXTENSIONS.has(compoundExt)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore hidden files (starting with .)
|
||||
if (fileName.startsWith('.') && fileName !== '.') {
|
||||
// But allow some important config files
|
||||
const allowedDotFiles = ['.env', '.gitignore']; // Already in IGNORED_FILES, so this is redundant
|
||||
// Actually, let's NOT ignore all dot files - many are important configs
|
||||
// Just rely on the explicit lists above
|
||||
}
|
||||
|
||||
// Ignore files that look like generated/bundled code
|
||||
if (fileNameLower.includes('.bundle.') ||
|
||||
fileNameLower.includes('.chunk.') ||
|
||||
fileNameLower.includes('.generated.') ||
|
||||
fileNameLower.endsWith('.d.ts')) { // TypeScript declaration files
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
14
src/config/supported-languages.ts
Normal file
14
src/config/supported-languages.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export enum SupportedLanguages {
|
||||
JavaScript = 'javascript',
|
||||
TypeScript = 'typescript',
|
||||
Python = 'python',
|
||||
// Java = 'java',
|
||||
// C = 'c',
|
||||
// CPlusPlus = 'cpp',
|
||||
// CSharp = 'csharp',
|
||||
// Go = 'go',
|
||||
// Rust = 'rust',
|
||||
// PHP = 'php',
|
||||
// Ruby = 'ruby',
|
||||
// Swift = 'swift',
|
||||
}
|
||||
41
src/core/graph/graph.ts
Normal file
41
src/core/graph/graph.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { GraphNode, GraphRelationship, KnowledgeGraph } from './types'
|
||||
|
||||
export const createKnowledgeGraph = (): KnowledgeGraph => {
|
||||
const nodeMap = new Map<string, GraphNode>();
|
||||
const relationshipMap = new Map<string, GraphRelationship>();
|
||||
|
||||
const addNode = (node: GraphNode) => {
|
||||
if(!nodeMap.has(node.id)) {
|
||||
nodeMap.set(node.id, node);
|
||||
}
|
||||
};
|
||||
|
||||
const addRelationship = (relationship: GraphRelationship) => {
|
||||
if (!relationshipMap.has(relationship.id)) {
|
||||
relationshipMap.set(relationship.id, relationship);
|
||||
}
|
||||
};
|
||||
|
||||
return{
|
||||
get nodes(){
|
||||
return Array.from(nodeMap.values())
|
||||
},
|
||||
|
||||
get relationships(){
|
||||
return Array.from(relationshipMap.values())
|
||||
},
|
||||
|
||||
// O(1) count getters - avoid creating arrays just for length
|
||||
get nodeCount() {
|
||||
return nodeMap.size;
|
||||
},
|
||||
|
||||
get relationshipCount() {
|
||||
return relationshipMap.size;
|
||||
},
|
||||
|
||||
addNode,
|
||||
addRelationship,
|
||||
|
||||
};
|
||||
};
|
||||
60
src/core/graph/types.ts
Normal file
60
src/core/graph/types.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
export type NodeLabel =
|
||||
| 'Project'
|
||||
| 'Package'
|
||||
| 'Module'
|
||||
| 'Folder'
|
||||
| 'File'
|
||||
| 'Class'
|
||||
| 'Function'
|
||||
| 'Method'
|
||||
| 'Variable'
|
||||
| 'Interface'
|
||||
| 'Enum'
|
||||
| 'Decorator'
|
||||
| 'Import'
|
||||
| 'Type'
|
||||
| 'CodeElement';
|
||||
|
||||
|
||||
export type NodeProperties = {
|
||||
name: string,
|
||||
filePath: string,
|
||||
startLine?: number,
|
||||
endLine?: number,
|
||||
language?: string,
|
||||
isExported?: boolean,
|
||||
}
|
||||
|
||||
export type RelationshipType =
|
||||
| 'CONTAINS'
|
||||
| 'CALLS'
|
||||
| 'INHERITS'
|
||||
| 'OVERRIDES'
|
||||
| 'IMPORTS'
|
||||
| 'USES'
|
||||
| 'DEFINES'
|
||||
| 'DECORATES'
|
||||
| 'IMPLEMENTS'
|
||||
| 'EXTENDS'
|
||||
|
||||
export interface GraphNode {
|
||||
id: string,
|
||||
label: NodeLabel,
|
||||
properties: NodeProperties,
|
||||
}
|
||||
|
||||
export interface GraphRelationship {
|
||||
id: string,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
type: RelationshipType,
|
||||
}
|
||||
|
||||
export interface KnowledgeGraph {
|
||||
nodes: GraphNode[],
|
||||
relationships: GraphRelationship[],
|
||||
nodeCount: number,
|
||||
relationshipCount: number,
|
||||
addNode: (node: GraphNode) => void,
|
||||
addRelationship: (relationship: GraphRelationship) => void,
|
||||
}
|
||||
47
src/core/ingestion/ast-cache.ts
Normal file
47
src/core/ingestion/ast-cache.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { LRUCache } from 'lru-cache';
|
||||
import Parser from 'web-tree-sitter';
|
||||
|
||||
// Define the interface for our Cache
|
||||
export interface ASTCache {
|
||||
get: (filePath: string) => Parser.Tree | undefined;
|
||||
set: (filePath: string, tree: Parser.Tree) => void;
|
||||
clear: () => void;
|
||||
stats: () => { size: number; maxSize: number };
|
||||
}
|
||||
|
||||
export const createASTCache = (maxSize: number = 50): ASTCache => {
|
||||
// Initialize the cache with a 'dispose' handler
|
||||
// This is the magic: When an item is evicted (dropped), this runs automatically.
|
||||
const cache = new LRUCache<string, Parser.Tree>({
|
||||
max: maxSize,
|
||||
dispose: (tree) => {
|
||||
try {
|
||||
// CRITICAL: Free the WASM memory when the tree leaves the cache
|
||||
tree.delete();
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete tree from WASM memory', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get: (filePath: string) => {
|
||||
const tree = cache.get(filePath);
|
||||
return tree; // Returns undefined if not found
|
||||
},
|
||||
|
||||
set: (filePath: string, tree: Parser.Tree) => {
|
||||
cache.set(filePath, tree);
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
cache.clear();
|
||||
},
|
||||
|
||||
stats: () => ({
|
||||
size: cache.size,
|
||||
maxSize: maxSize
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
171
src/core/ingestion/call-processor.ts
Normal file
171
src/core/ingestion/call-processor.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { KnowledgeGraph } from '../graph/types';
|
||||
import { ASTCache } from './ast-cache';
|
||||
import { SymbolTable } from './symbol-table';
|
||||
import { ImportMap } from './import-processor';
|
||||
import { loadParser, loadLanguage } from '../tree-sitter/parser-loader';
|
||||
import { LANGUAGE_QUERIES } from './tree-sitter-queries';
|
||||
import { generateId } from '../../lib/utils';
|
||||
import { getLanguageFromFilename } from './utils';
|
||||
|
||||
export const processCalls = async (
|
||||
graph: KnowledgeGraph,
|
||||
files: { path: string; content: string }[],
|
||||
astCache: ASTCache,
|
||||
symbolTable: SymbolTable,
|
||||
importMap: ImportMap,
|
||||
onProgress?: (current: number, total: number) => void
|
||||
) => {
|
||||
const parser = await loadParser();
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
onProgress?.(i + 1, files.length);
|
||||
|
||||
// 1. Check language support first
|
||||
const language = getLanguageFromFilename(file.path);
|
||||
if (!language) continue;
|
||||
|
||||
const queryStr = LANGUAGE_QUERIES[language];
|
||||
if (!queryStr) continue;
|
||||
|
||||
// 2. ALWAYS load the language before querying (parser is stateful)
|
||||
await loadLanguage(language, file.path);
|
||||
|
||||
// 3. Get AST (Try Cache First)
|
||||
let tree = astCache.get(file.path);
|
||||
let wasReparsed = false;
|
||||
|
||||
if (!tree) {
|
||||
// Cache Miss: Re-parse
|
||||
tree = parser.parse(file.content);
|
||||
wasReparsed = true;
|
||||
}
|
||||
|
||||
let query;
|
||||
let matches;
|
||||
try {
|
||||
query = parser.getLanguage().query(queryStr);
|
||||
matches = query.matches(tree.rootNode);
|
||||
} catch (queryError) {
|
||||
console.warn(`Query error for ${file.path}:`, queryError);
|
||||
if (wasReparsed) tree.delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Process each call match
|
||||
matches.forEach(match => {
|
||||
const captureMap: Record<string, any> = {};
|
||||
match.captures.forEach(c => captureMap[c.name] = c.node);
|
||||
|
||||
// Only process @call captures
|
||||
if (!captureMap['call']) return;
|
||||
|
||||
const nameNode = captureMap['call.name'];
|
||||
if (!nameNode) return;
|
||||
|
||||
const calledName = nameNode.text;
|
||||
|
||||
// Skip common built-ins and noise
|
||||
if (isBuiltInOrNoise(calledName)) return;
|
||||
|
||||
// 4. Resolve the target using priority strategy
|
||||
const targetNodeId = resolveCallTarget(
|
||||
calledName,
|
||||
file.path,
|
||||
symbolTable,
|
||||
importMap
|
||||
);
|
||||
|
||||
if (!targetNodeId) return;
|
||||
|
||||
// 5. Create CALLS relationship (File -> Function/Method)
|
||||
const sourceId = generateId('File', file.path);
|
||||
const relId = generateId('CALLS', `${file.path}:${calledName}->${targetNodeId}`);
|
||||
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId,
|
||||
targetId: targetNodeId,
|
||||
type: 'CALLS'
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup if we re-parsed
|
||||
if (wasReparsed) {
|
||||
tree.delete();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a function call to its target node ID using priority strategy:
|
||||
* A. Check imported files first (highest confidence)
|
||||
* B. Check local file definitions
|
||||
* C. Fuzzy global search (lowest confidence)
|
||||
*/
|
||||
const resolveCallTarget = (
|
||||
calledName: string,
|
||||
currentFile: string,
|
||||
symbolTable: SymbolTable,
|
||||
importMap: ImportMap
|
||||
): string | null => {
|
||||
// Strategy A: Check imported files
|
||||
const importedFiles = importMap.get(currentFile);
|
||||
if (importedFiles) {
|
||||
for (const importedFile of importedFiles) {
|
||||
const nodeId = symbolTable.lookupExact(importedFile, calledName);
|
||||
if (nodeId) return nodeId;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy B: Check local file (same file definition)
|
||||
const localNodeId = symbolTable.lookupExact(currentFile, calledName);
|
||||
if (localNodeId) return localNodeId;
|
||||
|
||||
// Strategy C: Fuzzy global search (pick first match)
|
||||
const fuzzyMatches = symbolTable.lookupFuzzy(calledName);
|
||||
if (fuzzyMatches.length > 0) {
|
||||
return fuzzyMatches[0].nodeId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter out common built-in functions and noise
|
||||
* that we don't want to track as calls
|
||||
*/
|
||||
const isBuiltInOrNoise = (name: string): boolean => {
|
||||
const builtIns = new Set([
|
||||
// JavaScript/TypeScript built-ins
|
||||
'console', 'log', 'warn', 'error', 'info', 'debug',
|
||||
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
|
||||
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
|
||||
'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent',
|
||||
'JSON', 'parse', 'stringify',
|
||||
'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt',
|
||||
'Map', 'Set', 'WeakMap', 'WeakSet',
|
||||
'Promise', 'resolve', 'reject', 'then', 'catch', 'finally',
|
||||
'Math', 'Date', 'RegExp', 'Error',
|
||||
'require', 'import', 'export',
|
||||
'fetch', 'Response', 'Request',
|
||||
// React hooks and common functions
|
||||
'useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext',
|
||||
'useReducer', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue',
|
||||
'createElement', 'createContext', 'createRef', 'forwardRef', 'memo', 'lazy',
|
||||
// Common array/object methods
|
||||
'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex', 'some', 'every',
|
||||
'includes', 'indexOf', 'slice', 'splice', 'concat', 'join', 'split',
|
||||
'push', 'pop', 'shift', 'unshift', 'sort', 'reverse',
|
||||
'keys', 'values', 'entries', 'assign', 'freeze', 'seal',
|
||||
'hasOwnProperty', 'toString', 'valueOf',
|
||||
// Python built-ins
|
||||
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
|
||||
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
|
||||
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
|
||||
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
|
||||
]);
|
||||
|
||||
return builtIns.has(name);
|
||||
};
|
||||
|
||||
139
src/core/ingestion/import-processor.ts
Normal file
139
src/core/ingestion/import-processor.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { KnowledgeGraph } from '../graph/types';
|
||||
import { ASTCache } from './ast-cache';
|
||||
import { loadParser, loadLanguage } from '../tree-sitter/parser-loader';
|
||||
import { LANGUAGE_QUERIES } from './tree-sitter-queries';
|
||||
import { generateId } from '../../lib/utils';
|
||||
import { getLanguageFromFilename } from './utils';
|
||||
|
||||
// Type: Map<FilePath, Set<ResolvedFilePath>>
|
||||
// Stores all files that a given file imports from
|
||||
export type ImportMap = Map<string, Set<string>>;
|
||||
|
||||
export const createImportMap = (): ImportMap => new Map();
|
||||
|
||||
// Helper: Resolve relative paths (e.g. "../utils" -> "src/lib/utils.ts")
|
||||
const resolveImportPath = (
|
||||
currentFile: string,
|
||||
importPath: string,
|
||||
allFiles: Set<string>
|
||||
): string | null => {
|
||||
// 1. Handle non-relative imports (libraries like 'react')
|
||||
if (!importPath.startsWith('.')) return null; // We skip node_modules for now
|
||||
|
||||
// 2. Resolve '..' and '.'
|
||||
const currentDir = currentFile.split('/').slice(0, -1);
|
||||
const parts = importPath.split('/');
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') {
|
||||
currentDir.pop();
|
||||
} else {
|
||||
currentDir.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
const basePath = currentDir.join('/');
|
||||
|
||||
// 3. Try extensions (prioritize .tsx for React projects)
|
||||
const extensions = ['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js'];
|
||||
|
||||
for (const ext of extensions) {
|
||||
const candidate = basePath + ext;
|
||||
if (allFiles.has(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const processImports = async (
|
||||
graph: KnowledgeGraph,
|
||||
files: { path: string; content: string }[],
|
||||
astCache: ASTCache,
|
||||
importMap: ImportMap,
|
||||
onProgress?: (current: number, total: number) => void
|
||||
) => {
|
||||
// Create a Set of all file paths for fast lookup during resolution
|
||||
const allFilePaths = new Set(files.map(f => f.path));
|
||||
const parser = await loadParser();
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
onProgress?.(i + 1, files.length);
|
||||
|
||||
// 1. Check language support first
|
||||
const language = getLanguageFromFilename(file.path);
|
||||
if (!language) continue;
|
||||
|
||||
const queryStr = LANGUAGE_QUERIES[language];
|
||||
if (!queryStr) continue;
|
||||
|
||||
// 2. ALWAYS load the language before querying (parser is stateful)
|
||||
await loadLanguage(language, file.path);
|
||||
|
||||
// 3. Get AST (Try Cache First)
|
||||
let tree = astCache.get(file.path);
|
||||
let wasReparsed = false;
|
||||
|
||||
if (!tree) {
|
||||
// Cache Miss: Re-parse (slower, but necessary if evicted)
|
||||
tree = parser.parse(file.content);
|
||||
wasReparsed = true;
|
||||
}
|
||||
|
||||
let query;
|
||||
let matches;
|
||||
try {
|
||||
query = parser.getLanguage().query(queryStr);
|
||||
matches = query.matches(tree.rootNode);
|
||||
} catch (queryError) {
|
||||
console.warn(`Query error for ${file.path}:`, queryError);
|
||||
if (wasReparsed) tree.delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.forEach(match => {
|
||||
const captureMap: Record<string, any> = {};
|
||||
match.captures.forEach(c => captureMap[c.name] = c.node);
|
||||
|
||||
if (captureMap['import']) {
|
||||
const sourceNode = captureMap['import.source'];
|
||||
if (!sourceNode) return;
|
||||
|
||||
// Clean path (remove quotes)
|
||||
const rawImportPath = sourceNode.text.replace(/['"]/g, '');
|
||||
|
||||
// Resolve to actual file in our system
|
||||
const resolvedPath = resolveImportPath(file.path, rawImportPath, allFilePaths);
|
||||
|
||||
if (resolvedPath) {
|
||||
// A. Update Graph (File -> IMPORTS -> File)
|
||||
const sourceId = generateId('File', file.path);
|
||||
const targetId = generateId('File', resolvedPath);
|
||||
const relId = generateId('IMPORTS', `${file.path}->${resolvedPath}`);
|
||||
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId,
|
||||
targetId,
|
||||
type: 'IMPORTS'
|
||||
});
|
||||
|
||||
// B. Update Import Map (For Pass 4)
|
||||
// Store all resolved import paths for this file
|
||||
if (!importMap.has(file.path)) {
|
||||
importMap.set(file.path, new Set());
|
||||
}
|
||||
importMap.get(file.path)!.add(resolvedPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If we re-parsed just for this, delete the tree to save memory
|
||||
if (wasReparsed) {
|
||||
tree.delete();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
124
src/core/ingestion/parsing-processor.ts
Normal file
124
src/core/ingestion/parsing-processor.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types';
|
||||
import { loadParser, loadLanguage } from '../tree-sitter/parser-loader';
|
||||
import { LANGUAGE_QUERIES } from './tree-sitter-queries';
|
||||
import { generateId } from '../../lib/utils';
|
||||
import { SymbolTable } from './symbol-table';
|
||||
import { ASTCache } from './ast-cache';
|
||||
import { getLanguageFromFilename } from './utils';
|
||||
|
||||
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
|
||||
|
||||
export const processParsing = async (
|
||||
graph: KnowledgeGraph,
|
||||
files: { path: string; content: string }[],
|
||||
symbolTable: SymbolTable,
|
||||
astCache: ASTCache,
|
||||
onFileProgress?: FileProgressCallback
|
||||
) => {
|
||||
|
||||
const parser = await loadParser();
|
||||
const total = files.length;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Report progress for each file
|
||||
onFileProgress?.(i + 1, total, file.path);
|
||||
|
||||
const language = getLanguageFromFilename(file.path);
|
||||
|
||||
if (!language) continue;
|
||||
|
||||
await loadLanguage(language, file.path);
|
||||
|
||||
// 3. Parse the text content into an AST
|
||||
const tree = parser.parse(file.content);
|
||||
|
||||
// Store in cache immediately (this might evict an old one)
|
||||
astCache.set(file.path, tree);
|
||||
|
||||
// 4. Get the specific query string for this language
|
||||
const queryString = LANGUAGE_QUERIES[language];
|
||||
if (!queryString) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Run the query against the AST root node
|
||||
// This looks for patterns like (function_declaration)
|
||||
let query;
|
||||
let matches;
|
||||
try {
|
||||
query = parser.getLanguage().query(queryString);
|
||||
matches = query.matches(tree.rootNode);
|
||||
} catch (queryError) {
|
||||
console.warn(`Query error for ${file.path}:`, queryError);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 6. Process every match found
|
||||
matches.forEach(match => {
|
||||
const captureMap: Record<string, any> = {};
|
||||
|
||||
match.captures.forEach(c => {
|
||||
captureMap[c.name] = c.node;
|
||||
});
|
||||
|
||||
// Skip imports here - they are handled by import-processor.ts
|
||||
// which creates proper File -> IMPORTS -> File relationships
|
||||
if (captureMap['import']) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip call expressions - they are handled by call-processor.ts
|
||||
if (captureMap['call']) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameNode = captureMap['name'];
|
||||
if (!nameNode) return;
|
||||
|
||||
const nodeName = nameNode.text;
|
||||
|
||||
let nodeLabel = 'CodeElement';
|
||||
|
||||
if (captureMap['definition.function']) nodeLabel = 'Function';
|
||||
else if (captureMap['definition.class']) nodeLabel = 'Class';
|
||||
else if (captureMap['definition.interface']) nodeLabel = 'Interface';
|
||||
else if (captureMap['definition.method']) nodeLabel = 'Method';
|
||||
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
|
||||
|
||||
const node: GraphNode = {
|
||||
id: nodeId,
|
||||
label: nodeLabel as any,
|
||||
properties: {
|
||||
name: nodeName,
|
||||
filePath: file.path,
|
||||
startLine: nameNode.startPosition.row,
|
||||
endLine: nameNode.endPosition.row,
|
||||
language: language
|
||||
}
|
||||
};
|
||||
|
||||
graph.addNode(node);
|
||||
|
||||
// Register in Symbol Table (only definitions, not imports)
|
||||
symbolTable.add(file.path, nodeName, nodeId, nodeLabel);
|
||||
|
||||
const fileId = generateId('File', file.path);
|
||||
|
||||
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
|
||||
|
||||
const relationship: GraphRelationship = {
|
||||
id: relId,
|
||||
sourceId: fileId,
|
||||
targetId: nodeId,
|
||||
type: 'DEFINES'
|
||||
};
|
||||
|
||||
graph.addRelationship(relationship);
|
||||
});
|
||||
|
||||
// Don't delete tree here - LRU cache handles cleanup when evicted
|
||||
}
|
||||
};
|
||||
164
src/core/ingestion/pipeline.ts
Normal file
164
src/core/ingestion/pipeline.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { createKnowledgeGraph } from '../graph/graph';
|
||||
import { extractZip } from '../../services/zip';
|
||||
import { processStructure } from './structure-processor';
|
||||
import { processParsing } from './parsing-processor';
|
||||
import { processImports, createImportMap } from './import-processor';
|
||||
import { processCalls } from './call-processor';
|
||||
import { createSymbolTable } from './symbol-table';
|
||||
import { createASTCache } from './ast-cache';
|
||||
import { PipelineProgress, PipelineResult } from '../../types/pipeline';
|
||||
|
||||
export const runIngestionPipeline = async ( file: File, onProgress: (progress: PipelineProgress) => void): Promise<PipelineResult> => {
|
||||
|
||||
const graph = createKnowledgeGraph();
|
||||
const fileContents = new Map<string, string>();
|
||||
const symbolTable = createSymbolTable();
|
||||
const astCache = createASTCache(50); // Keep last 50 files hot
|
||||
const importMap = createImportMap();
|
||||
|
||||
// Cleanup function for error handling
|
||||
const cleanup = () => {
|
||||
astCache.clear();
|
||||
symbolTable.clear();
|
||||
};
|
||||
|
||||
try {
|
||||
// Phase 1: Extracting (0-15%)
|
||||
onProgress({
|
||||
phase: 'extracting',
|
||||
percent: 0,
|
||||
message: 'Extracting ZIP file...',
|
||||
});
|
||||
|
||||
// Fake progress for extraction (JSZip doesn't expose progress)
|
||||
const fakeExtractionProgress = setInterval(() => {
|
||||
onProgress({
|
||||
phase: 'extracting',
|
||||
percent: Math.min(14, Math.random() * 10 + 5),
|
||||
message: 'Extracting ZIP file...',
|
||||
});
|
||||
}, 200);
|
||||
|
||||
const files = await extractZip(file);
|
||||
clearInterval(fakeExtractionProgress);
|
||||
|
||||
// Store file contents for code panel
|
||||
files.forEach(f => fileContents.set(f.path, f.content));
|
||||
|
||||
onProgress({
|
||||
phase: 'extracting',
|
||||
percent: 15,
|
||||
message: 'ZIP extracted successfully',
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: 0 },
|
||||
});
|
||||
|
||||
// Phase 2: Structure (15-30%)
|
||||
onProgress({
|
||||
phase: 'structure',
|
||||
percent: 15,
|
||||
message: 'Analyzing project structure...',
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: 0 },
|
||||
});
|
||||
|
||||
const filePaths = files.map(f => f.path);
|
||||
processStructure(graph, filePaths);
|
||||
|
||||
onProgress({
|
||||
phase: 'structure',
|
||||
percent: 30,
|
||||
message: 'Project structure analyzed',
|
||||
stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
// Phase 3: Parsing (30-70%)
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 30,
|
||||
message: 'Parsing code definitions...',
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
await processParsing(graph, files, symbolTable, astCache, (current, total, filePath) => {
|
||||
const parsingProgress = 30 + ((current / total) * 40);
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(parsingProgress),
|
||||
message: 'Parsing code definitions...',
|
||||
detail: filePath,
|
||||
stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
});
|
||||
|
||||
// Debug: Check if symbol table was populated (dev only)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Symbol Table Stats:', symbolTable.getStats());
|
||||
}
|
||||
|
||||
// Phase 4: Imports (70-82%)
|
||||
onProgress({
|
||||
phase: 'imports',
|
||||
percent: 70,
|
||||
message: 'Resolving imports...',
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
await processImports(graph, files, astCache, importMap, (current, total) => {
|
||||
const importProgress = 70 + ((current / total) * 12);
|
||||
onProgress({
|
||||
phase: 'imports',
|
||||
percent: Math.round(importProgress),
|
||||
message: 'Resolving imports...',
|
||||
stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
});
|
||||
|
||||
// Debug: Check import map (dev only)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Import Map Size:', importMap.size);
|
||||
}
|
||||
|
||||
// Phase 5: Calls (82-98%)
|
||||
onProgress({
|
||||
phase: 'calls',
|
||||
percent: 82,
|
||||
message: 'Tracing function calls...',
|
||||
stats: { filesProcessed: 0, totalFiles: files.length, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
|
||||
await processCalls(graph, files, astCache, symbolTable, importMap, (current, total) => {
|
||||
const callProgress = 82 + ((current / total) * 16);
|
||||
onProgress({
|
||||
phase: 'calls',
|
||||
percent: Math.round(callProgress),
|
||||
message: 'Tracing function calls...',
|
||||
stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
});
|
||||
|
||||
// Debug: Check relationship count (dev only)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Total Relationships:', graph.relationshipCount);
|
||||
}
|
||||
|
||||
// Phase 6: Complete (100%)
|
||||
onProgress({
|
||||
phase: 'complete',
|
||||
percent: 100,
|
||||
message: 'Graph generation complete!',
|
||||
stats: {
|
||||
filesProcessed: files.length,
|
||||
totalFiles: files.length,
|
||||
nodesCreated: graph.nodeCount
|
||||
},
|
||||
});
|
||||
|
||||
// Cleanup WASM memory before returning
|
||||
astCache.clear();
|
||||
|
||||
return { graph, fileContents };
|
||||
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
46
src/core/ingestion/structure-processor.ts
Normal file
46
src/core/ingestion/structure-processor.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { generateId } from "@/lib/utils";
|
||||
import { KnowledgeGraph, GraphNode, GraphRelationship } from "../graph/types";
|
||||
|
||||
export const processStructure = ( graph: KnowledgeGraph, paths: string[])=>{
|
||||
paths.forEach( path => {
|
||||
const parts = path.split('/')
|
||||
let currentPath = ''
|
||||
let parentId = ''
|
||||
|
||||
parts.forEach( (part, index ) => {
|
||||
const isFile = index === parts.length - 1
|
||||
const label = isFile ? 'File' : 'Folder'
|
||||
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part
|
||||
|
||||
const nodeId=generateId(label, currentPath)
|
||||
|
||||
const node: GraphNode = {
|
||||
id: nodeId,
|
||||
label: label,
|
||||
properties: {
|
||||
name: part,
|
||||
filePath: currentPath
|
||||
}
|
||||
}
|
||||
graph.addNode(node)
|
||||
|
||||
if(parentId){
|
||||
const relId = generateId('CONTAINS', `${parentId}->${nodeId}`)
|
||||
|
||||
const relationship: GraphRelationship={
|
||||
id: relId,
|
||||
type: 'CONTAINS',
|
||||
sourceId: parentId,
|
||||
targetId: nodeId
|
||||
}
|
||||
|
||||
graph.addRelationship(relationship)
|
||||
}
|
||||
|
||||
parentId = nodeId
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
80
src/core/ingestion/symbol-table.ts
Normal file
80
src/core/ingestion/symbol-table.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
export interface SymbolDefinition {
|
||||
nodeId: string;
|
||||
filePath: string;
|
||||
type: string; // 'Function', 'Class', etc.
|
||||
}
|
||||
|
||||
export interface SymbolTable {
|
||||
/**
|
||||
* Register a new symbol definition
|
||||
*/
|
||||
add: (filePath: string, name: string, nodeId: string, type: string) => void;
|
||||
|
||||
/**
|
||||
* High Confidence: Look for a symbol specifically inside a file
|
||||
* Returns the Node ID if found
|
||||
*/
|
||||
lookupExact: (filePath: string, name: string) => string | undefined;
|
||||
|
||||
/**
|
||||
* Low Confidence: Look for a symbol anywhere in the project
|
||||
* Used when imports are missing or for framework magic
|
||||
*/
|
||||
lookupFuzzy: (name: string) => SymbolDefinition[];
|
||||
|
||||
/**
|
||||
* Debugging: See how many symbols we have tracked
|
||||
*/
|
||||
getStats: () => { fileCount: number; globalSymbolCount: number };
|
||||
|
||||
/**
|
||||
* Cleanup memory
|
||||
*/
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const createSymbolTable = (): SymbolTable => {
|
||||
// 1. File-Specific Index (The "Good" one)
|
||||
// Structure: FilePath -> (SymbolName -> NodeID)
|
||||
const fileIndex = new Map<string, Map<string, string>>();
|
||||
|
||||
// 2. Global Reverse Index (The "Backup")
|
||||
// Structure: SymbolName -> [List of Definitions]
|
||||
const globalIndex = new Map<string, SymbolDefinition[]>();
|
||||
|
||||
const add = (filePath: string, name: string, nodeId: string, type: string) => {
|
||||
// A. Add to File Index
|
||||
if (!fileIndex.has(filePath)) {
|
||||
fileIndex.set(filePath, new Map());
|
||||
}
|
||||
fileIndex.get(filePath)!.set(name, nodeId);
|
||||
|
||||
// B. Add to Global Index
|
||||
if (!globalIndex.has(name)) {
|
||||
globalIndex.set(name, []);
|
||||
}
|
||||
globalIndex.get(name)!.push({ nodeId, filePath, type });
|
||||
};
|
||||
|
||||
const lookupExact = (filePath: string, name: string): string | undefined => {
|
||||
const fileSymbols = fileIndex.get(filePath);
|
||||
if (!fileSymbols) return undefined;
|
||||
return fileSymbols.get(name);
|
||||
};
|
||||
|
||||
const lookupFuzzy = (name: string): SymbolDefinition[] => {
|
||||
return globalIndex.get(name) || [];
|
||||
};
|
||||
|
||||
const getStats = () => ({
|
||||
fileCount: fileIndex.size,
|
||||
globalSymbolCount: globalIndex.size
|
||||
});
|
||||
|
||||
const clear = () => {
|
||||
fileIndex.clear();
|
||||
globalIndex.clear();
|
||||
};
|
||||
|
||||
return { add, lookupExact, lookupFuzzy, getStats, clear };
|
||||
};
|
||||
85
src/core/ingestion/tree-sitter-queries.ts
Normal file
85
src/core/ingestion/tree-sitter-queries.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { SupportedLanguages } from '../../config/supported-languages';
|
||||
|
||||
/*
|
||||
* Tree-sitter queries for extracting code definitions.
|
||||
*
|
||||
* Note: Different grammars (typescript vs tsx vs javascript) may have
|
||||
* slightly different node types. These queries are designed to be
|
||||
* compatible with the standard tree-sitter grammars.
|
||||
*/
|
||||
|
||||
// TypeScript queries - works with tree-sitter-typescript
|
||||
export const TYPESCRIPT_QUERIES = `
|
||||
(class_declaration
|
||||
name: (type_identifier) @name) @definition.class
|
||||
|
||||
(interface_declaration
|
||||
name: (type_identifier) @name) @definition.interface
|
||||
|
||||
(function_declaration
|
||||
name: (identifier) @name) @definition.function
|
||||
|
||||
(method_definition
|
||||
name: (property_identifier) @name) @definition.method
|
||||
|
||||
(import_statement
|
||||
source: (string) @import.source) @import
|
||||
|
||||
(call_expression
|
||||
function: (identifier) @call.name) @call
|
||||
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @call.name)) @call
|
||||
`;
|
||||
|
||||
// JavaScript queries - works with tree-sitter-javascript
|
||||
export const JAVASCRIPT_QUERIES = `
|
||||
(class_declaration
|
||||
name: (identifier) @name) @definition.class
|
||||
|
||||
(function_declaration
|
||||
name: (identifier) @name) @definition.function
|
||||
|
||||
(method_definition
|
||||
name: (property_identifier) @name) @definition.method
|
||||
|
||||
(import_statement
|
||||
source: (string) @import.source) @import
|
||||
|
||||
(call_expression
|
||||
function: (identifier) @call.name) @call
|
||||
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @call.name)) @call
|
||||
`;
|
||||
|
||||
// Python queries - works with tree-sitter-python
|
||||
export const PYTHON_QUERIES = `
|
||||
(class_definition
|
||||
name: (identifier) @name) @definition.class
|
||||
|
||||
(function_definition
|
||||
name: (identifier) @name) @definition.function
|
||||
|
||||
(import_statement
|
||||
name: (dotted_name) @import.source) @import
|
||||
|
||||
(import_from_statement
|
||||
module_name: (dotted_name) @import.source) @import
|
||||
|
||||
(call
|
||||
function: (identifier) @call.name) @call
|
||||
|
||||
(call
|
||||
function: (attribute
|
||||
attribute: (identifier) @call.name)) @call
|
||||
`;
|
||||
|
||||
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
|
||||
[SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES,
|
||||
[SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES,
|
||||
[SupportedLanguages.Python]: PYTHON_QUERIES,
|
||||
};
|
||||
|
||||
14
src/core/ingestion/utils.ts
Normal file
14
src/core/ingestion/utils.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { SupportedLanguages } from '../../config/supported-languages';
|
||||
|
||||
/**
|
||||
* Map file extension to SupportedLanguage enum
|
||||
*/
|
||||
export const getLanguageFromFilename = (filename: string): SupportedLanguages | null => {
|
||||
if (filename.endsWith('.tsx')) return SupportedLanguages.TypeScript;
|
||||
if (filename.endsWith('.ts')) return SupportedLanguages.TypeScript;
|
||||
if (filename.endsWith('.jsx')) return SupportedLanguages.JavaScript;
|
||||
if (filename.endsWith('.js')) return SupportedLanguages.JavaScript;
|
||||
if (filename.endsWith('.py')) return SupportedLanguages.Python;
|
||||
return null;
|
||||
};
|
||||
|
||||
200
src/core/kuzu/csv-generator.ts
Normal file
200
src/core/kuzu/csv-generator.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* CSV Generator for KuzuDB
|
||||
*
|
||||
* Converts our in-memory KnowledgeGraph into CSV format
|
||||
* for bulk loading into KuzuDB.
|
||||
*
|
||||
* RFC 4180 Compliant:
|
||||
* - Fields containing commas, double quotes, or newlines are enclosed in double quotes
|
||||
* - Double quotes within fields are escaped by doubling them ("")
|
||||
* - All fields are consistently quoted for safety with code content
|
||||
*/
|
||||
|
||||
import { KnowledgeGraph, GraphNode } from '../graph/types';
|
||||
|
||||
/**
|
||||
* Sanitize string to ensure valid UTF-8
|
||||
* Removes or replaces invalid characters that would break CSV parsing
|
||||
*/
|
||||
const sanitizeUTF8 = (str: string): string => {
|
||||
// Remove null bytes and other control characters (except newline, tab, carriage return)
|
||||
// Also remove surrogate pairs and other problematic Unicode
|
||||
return str
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control chars except \t \n \r
|
||||
.replace(/[\uD800-\uDFFF]/g, '') // Remove surrogate pairs (invalid standalone)
|
||||
.replace(/[\uFFFE\uFFFF]/g, ''); // Remove BOM and special chars
|
||||
};
|
||||
|
||||
/**
|
||||
* RFC 4180 compliant CSV field escaping
|
||||
* ALWAYS wraps in double quotes for safety with code content
|
||||
* Escapes internal double quotes by doubling them
|
||||
* Sanitizes to valid UTF-8
|
||||
*/
|
||||
const escapeCSVField = (value: string | number | undefined | null): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return '""'; // Empty quoted string
|
||||
}
|
||||
|
||||
let str = String(value);
|
||||
|
||||
// Sanitize to valid UTF-8
|
||||
str = sanitizeUTF8(str);
|
||||
|
||||
// Always quote and escape double quotes by doubling them
|
||||
// This is the safest approach for code content which may contain anything
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape a numeric value (no quotes needed for numbers)
|
||||
*/
|
||||
const escapeCSVNumber = (value: number | undefined | null, defaultValue: number = -1): string => {
|
||||
if (value === undefined || value === null) {
|
||||
return String(defaultValue);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if content looks like binary data
|
||||
* Binary files have high ratio of non-printable characters
|
||||
*/
|
||||
const isBinaryContent = (content: string): boolean => {
|
||||
if (!content || content.length === 0) return false;
|
||||
|
||||
// Check first 1000 chars for binary indicators
|
||||
const sample = content.slice(0, 1000);
|
||||
|
||||
// Count non-printable characters (excluding common whitespace)
|
||||
let nonPrintable = 0;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const code = sample.charCodeAt(i);
|
||||
// Non-printable: 0-8, 14-31, 127, or high bytes that aren't valid UTF-8 sequences
|
||||
if ((code < 9) || (code > 13 && code < 32) || code === 127) {
|
||||
nonPrintable++;
|
||||
}
|
||||
}
|
||||
|
||||
// If more than 10% non-printable, likely binary
|
||||
return (nonPrintable / sample.length) > 0.1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract code content for a node
|
||||
* - For File nodes: return entire file content (limited to avoid huge CSVs)
|
||||
* - For Function/Class/Method nodes: extract lines from startLine to endLine
|
||||
* - For Folder nodes: empty string
|
||||
* - For binary files: return placeholder
|
||||
*/
|
||||
const extractContent = (
|
||||
node: GraphNode,
|
||||
fileContents: Map<string, string>
|
||||
): string => {
|
||||
const filePath = node.properties.filePath;
|
||||
const content = fileContents.get(filePath);
|
||||
|
||||
if (!content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// For Folder nodes, no content
|
||||
if (node.label === 'Folder') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Check for binary content
|
||||
if (isBinaryContent(content)) {
|
||||
return '[Binary file - content not stored]';
|
||||
}
|
||||
|
||||
// For File nodes, return content (limited to prevent huge CSVs)
|
||||
if (node.label === 'File') {
|
||||
// Limit file content to 10KB to avoid memory issues
|
||||
const MAX_FILE_CONTENT = 10000;
|
||||
if (content.length > MAX_FILE_CONTENT) {
|
||||
return content.slice(0, MAX_FILE_CONTENT) + '\n... [truncated]';
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// For code elements (Function, Class, Method, etc.), extract the relevant lines
|
||||
const startLine = node.properties.startLine;
|
||||
const endLine = node.properties.endLine;
|
||||
|
||||
if (startLine === undefined || endLine === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Extract with some context
|
||||
const contextLines = 2;
|
||||
const start = Math.max(0, startLine - contextLines);
|
||||
const end = Math.min(lines.length - 1, endLine + contextLines);
|
||||
|
||||
const snippet = lines.slice(start, end + 1).join('\n');
|
||||
|
||||
// Limit snippet size
|
||||
const MAX_SNIPPET = 5000;
|
||||
if (snippet.length > MAX_SNIPPET) {
|
||||
return snippet.slice(0, MAX_SNIPPET) + '\n... [truncated]';
|
||||
}
|
||||
|
||||
return snippet;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate CSV for nodes
|
||||
* Headers: id,label,name,filePath,startLine,endLine,content
|
||||
*
|
||||
* All string fields are quoted for RFC 4180 compliance
|
||||
*/
|
||||
export const generateNodeCSV = (
|
||||
graph: KnowledgeGraph,
|
||||
fileContents: Map<string, string>
|
||||
): string => {
|
||||
const headers = ['id', 'label', 'name', 'filePath', 'startLine', 'endLine', 'content'];
|
||||
const rows: string[] = [headers.join(',')];
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const content = extractContent(node, fileContents);
|
||||
|
||||
const row = [
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.label),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
escapeCSVField(node.properties.filePath || ''),
|
||||
escapeCSVNumber(node.properties.startLine, -1),
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
escapeCSVField(content),
|
||||
];
|
||||
|
||||
rows.push(row.join(','));
|
||||
}
|
||||
|
||||
return rows.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate CSV for edges/relationships
|
||||
* Headers: from,to,type
|
||||
*
|
||||
* Note: Kuzu expects 'from' and 'to' columns for relationship tables
|
||||
*/
|
||||
export const generateEdgeCSV = (graph: KnowledgeGraph): string => {
|
||||
const headers = ['from', 'to', 'type'];
|
||||
const rows: string[] = [headers.join(',')];
|
||||
|
||||
for (const rel of graph.relationships) {
|
||||
const row = [
|
||||
escapeCSVField(rel.sourceId),
|
||||
escapeCSVField(rel.targetId),
|
||||
escapeCSVField(rel.type),
|
||||
];
|
||||
|
||||
rows.push(row.join(','));
|
||||
}
|
||||
|
||||
return rows.join('\n');
|
||||
};
|
||||
200
src/core/kuzu/kuzu-adapter.ts
Normal file
200
src/core/kuzu/kuzu-adapter.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* KuzuDB Adapter
|
||||
*
|
||||
* Manages the KuzuDB WASM instance for client-side graph database operations.
|
||||
* Uses the "Snapshot / Bulk Load" pattern with COPY FROM for performance.
|
||||
*
|
||||
* Based on V1 implementation with dynamic import to handle Vite bundling.
|
||||
*/
|
||||
|
||||
import { KnowledgeGraph, GraphNode } from '../graph/types';
|
||||
import { NODE_SCHEMA, EDGE_SCHEMA, NODE_TABLE_NAME, EDGE_TABLE_NAME } from './schema';
|
||||
import { generateNodeCSV, generateEdgeCSV } from './csv-generator';
|
||||
|
||||
// Holds the reference to the dynamically loaded module
|
||||
let kuzu: any = null;
|
||||
let db: any = null;
|
||||
let conn: any = null;
|
||||
|
||||
/**
|
||||
* Initialize KuzuDB WASM module and create in-memory database
|
||||
*/
|
||||
export const initKuzu = async () => {
|
||||
if (conn) return { db, conn, kuzu };
|
||||
|
||||
try {
|
||||
console.log('🚀 Initializing KuzuDB (Dynamic Import)...');
|
||||
|
||||
// 1. Dynamic Import (Fixes the "not a function" bundler issue)
|
||||
const kuzuModule = await import('kuzu-wasm');
|
||||
|
||||
// 2. Handle Vite/Webpack "default" wrapping
|
||||
// Sometimes the library is at kuzuModule, sometimes at kuzuModule.default
|
||||
kuzu = kuzuModule.default || kuzuModule;
|
||||
|
||||
// 3. Initialize WASM
|
||||
await kuzu.init();
|
||||
|
||||
// 4. Create Database
|
||||
db = new kuzu.Database(':memory:');
|
||||
conn = new kuzu.Connection(db);
|
||||
|
||||
console.log('✅ KuzuDB WASM Initialized');
|
||||
|
||||
// 5. Initialize Schema
|
||||
// We wrap these in try-catch in case they already exist (re-run scenario)
|
||||
try {
|
||||
await conn.query(NODE_SCHEMA);
|
||||
await conn.query(EDGE_SCHEMA);
|
||||
console.log('✅ KuzuDB Schema Created');
|
||||
} catch (e) {
|
||||
console.log('Schema might already exist, skipping creation.');
|
||||
}
|
||||
|
||||
return { db, conn, kuzu };
|
||||
} catch (error) {
|
||||
console.error('❌ KuzuDB Initialization Failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load a KnowledgeGraph into KuzuDB using COPY FROM (bulk load)
|
||||
*/
|
||||
export const loadGraphToKuzu = async (
|
||||
graph: KnowledgeGraph,
|
||||
fileContents: Map<string, string>
|
||||
) => {
|
||||
const { conn, kuzu } = await initKuzu();
|
||||
|
||||
try {
|
||||
console.log(`KuzuDB: Serializing ${graph.nodeCount} nodes...`);
|
||||
const nodesCSV = generateNodeCSV(graph, fileContents);
|
||||
const edgesCSV = generateEdgeCSV(graph);
|
||||
|
||||
const fs = kuzu.FS;
|
||||
const nodesPath = '/nodes.csv';
|
||||
const edgesPath = '/edges.csv';
|
||||
|
||||
// Cleanup old files if they exist
|
||||
try { await fs.unlink(nodesPath); } catch {}
|
||||
try { await fs.unlink(edgesPath); } catch {}
|
||||
|
||||
// Write CSV files to virtual filesystem
|
||||
await fs.writeFile(nodesPath, nodesCSV);
|
||||
await fs.writeFile(edgesPath, edgesCSV);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const nodeLines = nodesCSV.split('\n');
|
||||
console.log('Node CSV preview:', nodeLines.slice(0, 3));
|
||||
console.log('Node CSV total lines:', nodeLines.length);
|
||||
}
|
||||
|
||||
console.log('KuzuDB: Executing COPY FROM...');
|
||||
|
||||
// Use HEADER=true because our CSV generator adds headers
|
||||
// Use PARALLEL=false because content field has quoted newlines
|
||||
await conn.query(`COPY ${NODE_TABLE_NAME} FROM "${nodesPath}" (HEADER=true, PARALLEL=false)`);
|
||||
await conn.query(`COPY ${EDGE_TABLE_NAME} FROM "${edgesPath}" (HEADER=true, PARALLEL=false)`);
|
||||
|
||||
// Verify results
|
||||
const countRes = await conn.query(`MATCH (n:${NODE_TABLE_NAME}) RETURN count(n) AS cnt`);
|
||||
const countRow = await countRes.getNext();
|
||||
const nodeCount = countRow ? countRow.cnt || countRow[0] || 0 : 0;
|
||||
|
||||
console.log(`✅ KuzuDB Bulk Load Complete. Nodes in DB: ${nodeCount}`);
|
||||
|
||||
// Cleanup
|
||||
try { await fs.unlink(nodesPath); } catch {}
|
||||
try { await fs.unlink(edgesPath); } catch {}
|
||||
|
||||
return { success: true, count: Number(nodeCount) };
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ KuzuDB Bulk Load Failed:', error);
|
||||
// Don't throw - let the app continue without KuzuDB
|
||||
return { success: false, count: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a Cypher query against the database
|
||||
*/
|
||||
export const executeQuery = async (cypher: string): Promise<any[]> => {
|
||||
if (!conn) {
|
||||
console.warn("DB not initialized, initializing now...");
|
||||
await initKuzu();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await conn.query(cypher);
|
||||
|
||||
// Collect all rows
|
||||
const rows: any[] = [];
|
||||
while (await result.hasNext()) {
|
||||
const row = await result.getNext();
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('Query execution failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
*/
|
||||
export const getKuzuStats = async (): Promise<{ nodes: number; edges: number }> => {
|
||||
if (!conn) {
|
||||
return { nodes: 0, edges: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const nodeResult = await conn.query(`MATCH (n:${NODE_TABLE_NAME}) RETURN count(n) AS cnt`);
|
||||
const edgeResult = await conn.query(`MATCH ()-[r:${EDGE_TABLE_NAME}]->() RETURN count(r) AS cnt`);
|
||||
|
||||
const nodeRow = await nodeResult.getNext();
|
||||
const edgeRow = await edgeResult.getNext();
|
||||
|
||||
const nodeCount = nodeRow ? (nodeRow.cnt ?? nodeRow[0] ?? 0) : 0;
|
||||
const edgeCount = edgeRow ? (edgeRow.cnt ?? edgeRow[0] ?? 0) : 0;
|
||||
|
||||
return {
|
||||
nodes: Number(nodeCount),
|
||||
edges: Number(edgeCount)
|
||||
};
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Failed to get Kuzu stats:', error);
|
||||
}
|
||||
return { nodes: 0, edges: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if KuzuDB is initialized and has data
|
||||
*/
|
||||
export const isKuzuReady = (): boolean => {
|
||||
return conn !== null && db !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Close the database connection (cleanup)
|
||||
*/
|
||||
export const closeKuzu = async (): Promise<void> => {
|
||||
if (conn) {
|
||||
try {
|
||||
await conn.close();
|
||||
} catch {}
|
||||
conn = null;
|
||||
}
|
||||
if (db) {
|
||||
try {
|
||||
await db.close();
|
||||
} catch {}
|
||||
db = null;
|
||||
}
|
||||
kuzu = null;
|
||||
};
|
||||
44
src/core/kuzu/schema.ts
Normal file
44
src/core/kuzu/schema.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* KuzuDB Schema Definitions
|
||||
*
|
||||
* Using Polymorphic Schema (Single Table Inheritance):
|
||||
* - All nodes go into ONE table (CodeNode) with a label column
|
||||
* - All edges go into ONE table (CodeRelation) with a type column
|
||||
*
|
||||
* This simplifies querying for the AI agent.
|
||||
*/
|
||||
|
||||
export const NODE_TABLE_NAME = 'CodeNode';
|
||||
export const EDGE_TABLE_NAME = 'CodeRelation';
|
||||
|
||||
/**
|
||||
* Node table schema
|
||||
* Stores all code elements: Files, Functions, Classes, etc.
|
||||
*/
|
||||
export const NODE_SCHEMA = `
|
||||
CREATE NODE TABLE ${NODE_TABLE_NAME} (
|
||||
id STRING,
|
||||
label STRING,
|
||||
name STRING,
|
||||
filePath STRING,
|
||||
startLine INT64,
|
||||
endLine INT64,
|
||||
content STRING,
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
/**
|
||||
* Edge table schema
|
||||
* Stores all relationships: CALLS, IMPORTS, CONTAINS, DEFINES
|
||||
*/
|
||||
export const EDGE_SCHEMA = `
|
||||
CREATE REL TABLE ${EDGE_TABLE_NAME} (
|
||||
FROM ${NODE_TABLE_NAME} TO ${NODE_TABLE_NAME},
|
||||
type STRING
|
||||
)`;
|
||||
|
||||
/**
|
||||
* All schema creation queries in order
|
||||
*/
|
||||
export const SCHEMA_QUERIES = [NODE_SCHEMA, EDGE_SCHEMA];
|
||||
|
||||
57
src/core/tree-sitter/parser-loader.ts
Normal file
57
src/core/tree-sitter/parser-loader.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import Parser from 'web-tree-sitter';
|
||||
import { SupportedLanguages } from '../../config/supported-languages';
|
||||
|
||||
let parser: Parser | null = null;
|
||||
|
||||
// Cache the compiled Language objects so we never fetch/compile twice
|
||||
const languageCache = new Map<string, Parser.Language>();
|
||||
|
||||
export const loadParser = async (): Promise<Parser> => {
|
||||
if (parser) return parser;
|
||||
|
||||
await Parser.init({
|
||||
locateFile: (scriptName: string) => {
|
||||
return `/wasm/${scriptName}`;
|
||||
}
|
||||
})
|
||||
|
||||
parser = new Parser();
|
||||
return parser;
|
||||
}
|
||||
|
||||
// Get the appropriate WASM file based on language and file extension
|
||||
const getWasmPath = (language: SupportedLanguages, filePath?: string): string => {
|
||||
// For TypeScript, check if it's a TSX file
|
||||
if (language === SupportedLanguages.TypeScript) {
|
||||
if (filePath?.endsWith('.tsx')) {
|
||||
return '/wasm/typescript/tree-sitter-tsx.wasm';
|
||||
}
|
||||
return '/wasm/typescript/tree-sitter-typescript.wasm';
|
||||
}
|
||||
|
||||
const languageFileMap: Record<SupportedLanguages, string> = {
|
||||
[SupportedLanguages.JavaScript]: '/wasm/javascript/tree-sitter-javascript.wasm',
|
||||
[SupportedLanguages.TypeScript]: '/wasm/typescript/tree-sitter-typescript.wasm',
|
||||
[SupportedLanguages.Python]: '/wasm/python/tree-sitter-python.wasm',
|
||||
};
|
||||
|
||||
return languageFileMap[language];
|
||||
};
|
||||
|
||||
export const loadLanguage = async (language: SupportedLanguages, filePath?: string): Promise<void> => {
|
||||
if (!parser) await loadParser();
|
||||
|
||||
const wasmPath = getWasmPath(language, filePath);
|
||||
|
||||
// Use wasmPath as cache key to differentiate ts vs tsx
|
||||
if (languageCache.has(wasmPath)) {
|
||||
parser!.setLanguage(languageCache.get(wasmPath)!);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wasmPath) throw new Error(`Unsupported language: ${language}`);
|
||||
|
||||
const loadedLanguage = await Parser.Language.load(wasmPath);
|
||||
languageCache.set(wasmPath, loadedLanguage);
|
||||
parser!.setLanguage(loadedLanguage);
|
||||
}
|
||||
137
src/hooks/useAppState.tsx
Normal file
137
src/hooks/useAppState.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { KnowledgeGraph, GraphNode, NodeLabel } from '../core/graph/types';
|
||||
import { PipelineProgress } from '../types/pipeline';
|
||||
import { DEFAULT_VISIBLE_LABELS } from '../lib/constants';
|
||||
|
||||
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
|
||||
export type RightPanelTab = 'code' | 'chat';
|
||||
|
||||
interface AppState {
|
||||
// View state
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
|
||||
// Graph data
|
||||
graph: KnowledgeGraph | null;
|
||||
setGraph: (graph: KnowledgeGraph | null) => void;
|
||||
fileContents: Map<string, string>;
|
||||
setFileContents: (contents: Map<string, string>) => void;
|
||||
|
||||
// Selection
|
||||
selectedNode: GraphNode | null;
|
||||
setSelectedNode: (node: GraphNode | null) => void;
|
||||
|
||||
// Right Panel (unified Code + Chat)
|
||||
isRightPanelOpen: boolean;
|
||||
setRightPanelOpen: (open: boolean) => void;
|
||||
rightPanelTab: RightPanelTab;
|
||||
setRightPanelTab: (tab: RightPanelTab) => void;
|
||||
openCodePanel: () => void; // Opens panel and switches to code tab
|
||||
openChatPanel: () => void; // Opens panel and switches to chat tab
|
||||
|
||||
// Filters
|
||||
visibleLabels: NodeLabel[];
|
||||
toggleLabelVisibility: (label: NodeLabel) => void;
|
||||
|
||||
// Depth filter (N hops from selection)
|
||||
depthFilter: number | null; // null = show all, 1 = neighbors only, 2 = 2 hops, etc.
|
||||
setDepthFilter: (depth: number | null) => void;
|
||||
|
||||
// Progress
|
||||
progress: PipelineProgress | null;
|
||||
setProgress: (progress: PipelineProgress | null) => void;
|
||||
|
||||
// Project info
|
||||
projectName: string;
|
||||
setProjectName: (name: string) => void;
|
||||
}
|
||||
|
||||
const AppStateContext = createContext<AppState | null>(null);
|
||||
|
||||
export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
// View state
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('onboarding');
|
||||
|
||||
// Graph data
|
||||
const [graph, setGraph] = useState<KnowledgeGraph | null>(null);
|
||||
const [fileContents, setFileContents] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Selection
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
|
||||
// Right Panel
|
||||
const [isRightPanelOpen, setRightPanelOpen] = useState(false);
|
||||
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>('code');
|
||||
|
||||
const openCodePanel = useCallback(() => {
|
||||
setRightPanelOpen(true);
|
||||
setRightPanelTab('code');
|
||||
}, []);
|
||||
|
||||
const openChatPanel = useCallback(() => {
|
||||
setRightPanelOpen(true);
|
||||
setRightPanelTab('chat');
|
||||
}, []);
|
||||
|
||||
// Filters
|
||||
const [visibleLabels, setVisibleLabels] = useState<NodeLabel[]>(DEFAULT_VISIBLE_LABELS);
|
||||
|
||||
// Depth filter
|
||||
const [depthFilter, setDepthFilter] = useState<number | null>(null);
|
||||
|
||||
// Progress
|
||||
const [progress, setProgress] = useState<PipelineProgress | null>(null);
|
||||
|
||||
// Project info
|
||||
const [projectName, setProjectName] = useState<string>('');
|
||||
|
||||
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
|
||||
setVisibleLabels(prev => {
|
||||
if (prev.includes(label)) {
|
||||
return prev.filter(l => l !== label);
|
||||
} else {
|
||||
return [...prev, label];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value: AppState = {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
graph,
|
||||
setGraph,
|
||||
fileContents,
|
||||
setFileContents,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
isRightPanelOpen,
|
||||
setRightPanelOpen,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
openCodePanel,
|
||||
openChatPanel,
|
||||
visibleLabels,
|
||||
toggleLabelVisibility,
|
||||
depthFilter,
|
||||
setDepthFilter,
|
||||
progress,
|
||||
setProgress,
|
||||
projectName,
|
||||
setProjectName,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppStateContext.Provider value={value}>
|
||||
{children}
|
||||
</AppStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAppState = (): AppState => {
|
||||
const context = useContext(AppStateContext);
|
||||
if (!context) {
|
||||
throw new Error('useAppState must be used within AppStateProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
135
src/hooks/useIngestionWorker.ts
Normal file
135
src/hooks/useIngestionWorker.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import * as Comlink from 'comlink';
|
||||
import type { IngestionWorkerApi } from '../workers/ingestion.worker';
|
||||
import { PipelineProgress, PipelineResult, deserializePipelineResult } from '../types/pipeline';
|
||||
import { createKnowledgeGraph } from '../core/graph/graph';
|
||||
|
||||
/**
|
||||
* Hook to run the ingestion pipeline in a Web Worker
|
||||
*
|
||||
* This prevents UI freezing during large repo processing by offloading
|
||||
* the heavy computation to a separate thread.
|
||||
*/
|
||||
export const useIngestionWorker = () => {
|
||||
// Keep worker instance in a ref so it persists across renders
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const apiRef = useRef<Comlink.Remote<IngestionWorkerApi> | null>(null);
|
||||
|
||||
// Initialize worker on mount
|
||||
useEffect(() => {
|
||||
// Create the worker with module type for ES modules support
|
||||
const worker = new Worker(
|
||||
new URL('../workers/ingestion.worker.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
|
||||
// Wrap with Comlink for RPC-style communication
|
||||
const api = Comlink.wrap<IngestionWorkerApi>(worker);
|
||||
|
||||
workerRef.current = worker;
|
||||
apiRef.current = api;
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
worker.terminate();
|
||||
workerRef.current = null;
|
||||
apiRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Run the ingestion pipeline in the background worker
|
||||
*
|
||||
* @param file - The ZIP file to process
|
||||
* @param onProgress - Callback for progress updates (will be proxied to worker)
|
||||
* @returns Promise resolving to the pipeline result
|
||||
*/
|
||||
const runPipelineInWorker = useCallback(async (
|
||||
file: File,
|
||||
onProgress: (progress: PipelineProgress) => void
|
||||
): Promise<PipelineResult> => {
|
||||
const api = apiRef.current;
|
||||
|
||||
if (!api) {
|
||||
throw new Error('Worker not initialized');
|
||||
}
|
||||
|
||||
// CRITICAL: Wrap the callback with Comlink.proxy()
|
||||
// This allows the worker to call our callback function
|
||||
// The callback executes on the main thread, updating React state
|
||||
const proxiedOnProgress = Comlink.proxy(onProgress);
|
||||
|
||||
// Run pipeline in worker (non-blocking for main thread!)
|
||||
const serializedResult = await api.runPipeline(file, proxiedOnProgress);
|
||||
|
||||
// Deserialize the result back to full objects
|
||||
// (reconstruct KnowledgeGraph with methods, Map from object)
|
||||
return deserializePipelineResult(serializedResult, createKnowledgeGraph);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Terminate the worker (useful for cancellation)
|
||||
*/
|
||||
const terminateWorker = useCallback(() => {
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
workerRef.current = null;
|
||||
apiRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Execute a Cypher query against the KuzuDB database in the worker
|
||||
*
|
||||
* @param cypher - The Cypher query string
|
||||
* @returns Promise resolving to query results
|
||||
*/
|
||||
const runQuery = useCallback(async (cypher: string): Promise<any[]> => {
|
||||
const api = apiRef.current;
|
||||
|
||||
if (!api) {
|
||||
throw new Error('Worker not initialized');
|
||||
}
|
||||
|
||||
return api.runQuery(cypher);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check if the database is ready for queries
|
||||
*/
|
||||
const isDatabaseReady = useCallback(async (): Promise<boolean> => {
|
||||
const api = apiRef.current;
|
||||
|
||||
if (!api) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await api.isReady();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
*/
|
||||
const getDatabaseStats = useCallback(async (): Promise<{ nodes: number; edges: number }> => {
|
||||
const api = apiRef.current;
|
||||
|
||||
if (!api) {
|
||||
return { nodes: 0, edges: 0 };
|
||||
}
|
||||
|
||||
return api.getStats();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runPipelineInWorker,
|
||||
terminateWorker,
|
||||
runQuery,
|
||||
isDatabaseReady,
|
||||
getDatabaseStats,
|
||||
};
|
||||
};
|
||||
|
||||
470
src/hooks/useSigma.ts
Normal file
470
src/hooks/useSigma.ts
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import Sigma from 'sigma';
|
||||
import Graph from 'graphology';
|
||||
import FA2Layout from 'graphology-layout-forceatlas2/worker';
|
||||
import forceAtlas2 from 'graphology-layout-forceatlas2';
|
||||
import noverlap from 'graphology-layout-noverlap';
|
||||
import EdgeCurveProgram from '@sigma/edge-curve';
|
||||
import { SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter';
|
||||
|
||||
// Helper: Parse hex color to RGB
|
||||
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 100, g: 100, b: 100 };
|
||||
};
|
||||
|
||||
// Helper: RGB to hex
|
||||
const rgbToHex = (r: number, g: number, b: number): string => {
|
||||
return '#' + [r, g, b].map(x => {
|
||||
const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// Dim a color by mixing with dark background (keeps color hint)
|
||||
const dimColor = (hex: string, amount: number): string => {
|
||||
const rgb = hexToRgb(hex);
|
||||
const darkBg = { r: 18, g: 18, b: 28 }; // #12121c - dark background
|
||||
return rgbToHex(
|
||||
darkBg.r + (rgb.r - darkBg.r) * amount,
|
||||
darkBg.g + (rgb.g - darkBg.g) * amount,
|
||||
darkBg.b + (rgb.b - darkBg.b) * amount
|
||||
);
|
||||
};
|
||||
|
||||
// Brighten a color (increase luminosity)
|
||||
const brightenColor = (hex: string, factor: number): string => {
|
||||
const rgb = hexToRgb(hex);
|
||||
return rgbToHex(
|
||||
rgb.r + (255 - rgb.r) * (factor - 1) / factor,
|
||||
rgb.g + (255 - rgb.g) * (factor - 1) / factor,
|
||||
rgb.b + (255 - rgb.b) * (factor - 1) / factor
|
||||
);
|
||||
};
|
||||
|
||||
interface UseSigmaOptions {
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string | null) => void;
|
||||
onStageClick?: () => void;
|
||||
}
|
||||
|
||||
interface UseSigmaReturn {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
sigmaRef: React.RefObject<Sigma | null>;
|
||||
setGraph: (graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => void;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
resetZoom: () => void;
|
||||
focusNode: (nodeId: string) => void;
|
||||
isLayoutRunning: boolean;
|
||||
startLayout: () => void;
|
||||
stopLayout: () => void;
|
||||
selectedNode: string | null;
|
||||
setSelectedNode: (nodeId: string | null) => void;
|
||||
}
|
||||
|
||||
// Noverlap for final cleanup - minimal since we start with good positions
|
||||
const NOVERLAP_SETTINGS = {
|
||||
maxIterations: 20, // Reduced - less cleanup needed
|
||||
ratio: 1.1,
|
||||
margin: 10,
|
||||
expansion: 1.05,
|
||||
};
|
||||
|
||||
// ForceAtlas2 settings - FAST convergence since nodes start near their parents
|
||||
const getFA2Settings = (nodeCount: number) => {
|
||||
const isSmall = nodeCount < 500;
|
||||
const isMedium = nodeCount >= 500 && nodeCount < 2000;
|
||||
const isLarge = nodeCount >= 2000 && nodeCount < 10000;
|
||||
|
||||
return {
|
||||
// Lower gravity allows folders to stay spread out
|
||||
gravity: isSmall ? 0.8 : isMedium ? 0.5 : isLarge ? 0.3 : 0.15,
|
||||
|
||||
// Higher scaling ratio = more spread out overall
|
||||
scalingRatio: isSmall ? 15 : isMedium ? 30 : isLarge ? 60 : 100,
|
||||
|
||||
// LOW slowDown = FASTER movement (converges quicker)
|
||||
slowDown: isSmall ? 1 : isMedium ? 2 : isLarge ? 3 : 5,
|
||||
|
||||
// Barnes-Hut for performance - use it even on smaller graphs
|
||||
barnesHutOptimize: nodeCount > 200,
|
||||
barnesHutTheta: isLarge ? 0.8 : 0.6, // Higher = faster but less accurate
|
||||
|
||||
// These help with clustering while keeping spread
|
||||
strongGravityMode: false,
|
||||
outboundAttractionDistribution: true,
|
||||
linLogMode: false,
|
||||
adjustSizes: true,
|
||||
edgeWeightInfluence: 1,
|
||||
};
|
||||
};
|
||||
|
||||
// Layout duration - let it run longer for better results
|
||||
// Web Worker + WebGL means minimal system impact
|
||||
const getLayoutDuration = (nodeCount: number): number => {
|
||||
if (nodeCount > 10000) return 45000; // 45s for huge graphs
|
||||
if (nodeCount > 5000) return 35000; // 35s
|
||||
if (nodeCount > 2000) return 30000; // 30s
|
||||
if (nodeCount > 1000) return 30000; // 30s
|
||||
if (nodeCount > 500) return 25000; // 25s
|
||||
return 20000; // 20s for small graphs
|
||||
};
|
||||
|
||||
export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sigmaRef = useRef<Sigma | null>(null);
|
||||
const graphRef = useRef<Graph<SigmaNodeAttributes, SigmaEdgeAttributes> | null>(null);
|
||||
const layoutRef = useRef<FA2Layout | null>(null);
|
||||
const selectedNodeRef = useRef<string | null>(null);
|
||||
const layoutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||
const [selectedNode, setSelectedNodeState] = useState<string | null>(null);
|
||||
|
||||
const setSelectedNode = useCallback((nodeId: string | null) => {
|
||||
selectedNodeRef.current = nodeId;
|
||||
setSelectedNodeState(nodeId);
|
||||
|
||||
const sigma = sigmaRef.current;
|
||||
if (!sigma) return;
|
||||
|
||||
// Tiny camera nudge to force edge refresh (workaround for Sigma edge caching)
|
||||
const camera = sigma.getCamera();
|
||||
const currentRatio = camera.ratio;
|
||||
// Imperceptible zoom change that triggers re-render
|
||||
camera.animate(
|
||||
{ ratio: currentRatio * 1.0001 },
|
||||
{ duration: 50 }
|
||||
);
|
||||
|
||||
sigma.refresh();
|
||||
}, []);
|
||||
|
||||
// Initialize Sigma ONCE
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const graph = new Graph<SigmaNodeAttributes, SigmaEdgeAttributes>();
|
||||
graphRef.current = graph;
|
||||
|
||||
const sigma = new Sigma(graph, containerRef.current, {
|
||||
renderLabels: true,
|
||||
labelFont: 'JetBrains Mono, monospace',
|
||||
labelSize: 11,
|
||||
labelWeight: '500',
|
||||
labelColor: { color: '#e4e4ed' },
|
||||
labelRenderedSizeThreshold: 8,
|
||||
labelDensity: 0.1,
|
||||
labelGridCellSize: 70,
|
||||
|
||||
defaultNodeColor: '#6b7280',
|
||||
defaultEdgeColor: '#2a2a3a',
|
||||
|
||||
defaultEdgeType: 'curved',
|
||||
edgeProgramClasses: {
|
||||
curved: EdgeCurveProgram,
|
||||
},
|
||||
|
||||
// Custom hover renderer - dark background instead of white
|
||||
defaultDrawNodeHover: (context, data, settings) => {
|
||||
const label = data.label;
|
||||
if (!label) return;
|
||||
|
||||
const size = settings.labelSize || 11;
|
||||
const font = settings.labelFont || 'JetBrains Mono, monospace';
|
||||
const weight = settings.labelWeight || '500';
|
||||
|
||||
context.font = `${weight} ${size}px ${font}`;
|
||||
const textWidth = context.measureText(label).width;
|
||||
|
||||
const nodeSize = data.size || 8;
|
||||
const x = data.x;
|
||||
const y = data.y - nodeSize - 10;
|
||||
const paddingX = 8;
|
||||
const paddingY = 5;
|
||||
const height = size + paddingY * 2;
|
||||
const width = textWidth + paddingX * 2;
|
||||
const radius = 4;
|
||||
|
||||
// Dark background pill
|
||||
context.fillStyle = '#12121c';
|
||||
context.beginPath();
|
||||
context.roundRect(x - width / 2, y - height / 2, width, height, radius);
|
||||
context.fill();
|
||||
|
||||
// Border matching node color
|
||||
context.strokeStyle = data.color || '#6366f1';
|
||||
context.lineWidth = 2;
|
||||
context.stroke();
|
||||
|
||||
// Label text - light color
|
||||
context.fillStyle = '#f5f5f7';
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText(label, x, y);
|
||||
|
||||
// Also draw a subtle glow ring around the node
|
||||
context.beginPath();
|
||||
context.arc(data.x, data.y, nodeSize + 4, 0, Math.PI * 2);
|
||||
context.strokeStyle = data.color || '#6366f1';
|
||||
context.lineWidth = 2;
|
||||
context.globalAlpha = 0.5;
|
||||
context.stroke();
|
||||
context.globalAlpha = 1;
|
||||
},
|
||||
|
||||
minCameraRatio: 0.002,
|
||||
maxCameraRatio: 50,
|
||||
hideEdgesOnMove: true,
|
||||
zIndex: true,
|
||||
|
||||
nodeReducer: (node, data) => {
|
||||
const res = { ...data };
|
||||
|
||||
if (data.hidden) {
|
||||
res.hidden = true;
|
||||
return res;
|
||||
}
|
||||
|
||||
const currentSelected = selectedNodeRef.current;
|
||||
if (currentSelected) {
|
||||
const graph = graphRef.current;
|
||||
if (graph) {
|
||||
const isSelected = node === currentSelected;
|
||||
const isNeighbor = graph.hasEdge(node, currentSelected) || graph.hasEdge(currentSelected, node);
|
||||
|
||||
if (isSelected) {
|
||||
// Selected node: full color, bigger, glowing effect via size
|
||||
res.color = data.color;
|
||||
res.size = (data.size || 8) * 1.8;
|
||||
res.zIndex = 2;
|
||||
res.highlighted = true;
|
||||
} else if (isNeighbor) {
|
||||
// Connected nodes: keep their color, slightly bigger
|
||||
res.color = data.color;
|
||||
res.size = (data.size || 8) * 1.3;
|
||||
res.zIndex = 1;
|
||||
} else {
|
||||
// Non-connected: DIM but keep color hint (not fully gray)
|
||||
// Mix original color with dark background for "faded" look
|
||||
res.color = dimColor(data.color, 0.25); // 25% of original color
|
||||
res.size = (data.size || 8) * 0.6;
|
||||
res.zIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
},
|
||||
|
||||
edgeReducer: (edge, data) => {
|
||||
const res = { ...data };
|
||||
|
||||
const currentSelected = selectedNodeRef.current;
|
||||
if (currentSelected) {
|
||||
const graph = graphRef.current;
|
||||
if (graph) {
|
||||
const [source, target] = graph.extremities(edge);
|
||||
const isConnected = source === currentSelected || target === currentSelected;
|
||||
|
||||
if (isConnected) {
|
||||
// Connected edges: BRIGHTEN original color, make THICK for visibility
|
||||
res.color = brightenColor(data.color, 1.5);
|
||||
res.size = Math.max(3, (data.size || 1) * 4); // Minimum 3px, visible at any zoom
|
||||
res.zIndex = 2;
|
||||
} else {
|
||||
// Non-connected: very faint
|
||||
res.color = dimColor(data.color, 0.1);
|
||||
res.size = 0.3;
|
||||
res.zIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
},
|
||||
});
|
||||
|
||||
sigmaRef.current = sigma;
|
||||
|
||||
sigma.on('clickNode', ({ node }) => {
|
||||
setSelectedNode(node);
|
||||
options.onNodeClick?.(node);
|
||||
});
|
||||
|
||||
sigma.on('clickStage', () => {
|
||||
setSelectedNode(null);
|
||||
options.onStageClick?.();
|
||||
});
|
||||
|
||||
sigma.on('enterNode', ({ node }) => {
|
||||
options.onNodeHover?.(node);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.cursor = 'pointer';
|
||||
}
|
||||
});
|
||||
|
||||
sigma.on('leaveNode', () => {
|
||||
options.onNodeHover?.(null);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.cursor = 'grab';
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (layoutTimeoutRef.current) {
|
||||
clearTimeout(layoutTimeoutRef.current);
|
||||
}
|
||||
layoutRef.current?.kill();
|
||||
sigma.kill();
|
||||
sigmaRef.current = null;
|
||||
graphRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Run ForceAtlas2 layout
|
||||
const runLayout = useCallback((graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => {
|
||||
const nodeCount = graph.order;
|
||||
if (nodeCount === 0) return;
|
||||
|
||||
// Kill existing
|
||||
if (layoutRef.current) {
|
||||
layoutRef.current.kill();
|
||||
layoutRef.current = null;
|
||||
}
|
||||
if (layoutTimeoutRef.current) {
|
||||
clearTimeout(layoutTimeoutRef.current);
|
||||
layoutTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Get settings
|
||||
const inferredSettings = forceAtlas2.inferSettings(graph);
|
||||
const customSettings = getFA2Settings(nodeCount);
|
||||
const settings = { ...inferredSettings, ...customSettings };
|
||||
|
||||
const layout = new FA2Layout(graph, { settings });
|
||||
|
||||
layoutRef.current = layout;
|
||||
layout.start();
|
||||
setIsLayoutRunning(true);
|
||||
|
||||
const duration = getLayoutDuration(nodeCount);
|
||||
|
||||
layoutTimeoutRef.current = setTimeout(() => {
|
||||
if (layoutRef.current) {
|
||||
layoutRef.current.stop();
|
||||
layoutRef.current = null;
|
||||
|
||||
// Light noverlap cleanup
|
||||
noverlap.assign(graph, NOVERLAP_SETTINGS);
|
||||
sigmaRef.current?.refresh();
|
||||
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, duration);
|
||||
}, []);
|
||||
|
||||
const setGraph = useCallback((newGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => {
|
||||
const sigma = sigmaRef.current;
|
||||
if (!sigma) return;
|
||||
|
||||
if (layoutRef.current) {
|
||||
layoutRef.current.kill();
|
||||
layoutRef.current = null;
|
||||
}
|
||||
if (layoutTimeoutRef.current) {
|
||||
clearTimeout(layoutTimeoutRef.current);
|
||||
layoutTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
graphRef.current = newGraph;
|
||||
sigma.setGraph(newGraph);
|
||||
setSelectedNode(null);
|
||||
|
||||
runLayout(newGraph);
|
||||
sigma.getCamera().animatedReset({ duration: 500 });
|
||||
}, [runLayout, setSelectedNode]);
|
||||
|
||||
const focusNode = useCallback((nodeId: string) => {
|
||||
const sigma = sigmaRef.current;
|
||||
const graph = graphRef.current;
|
||||
if (!sigma || !graph || !graph.hasNode(nodeId)) return;
|
||||
|
||||
// Skip if already focused on this node (prevents double-click issues)
|
||||
const alreadySelected = selectedNodeRef.current === nodeId;
|
||||
|
||||
// Set selection state directly (without the camera nudge from setSelectedNode)
|
||||
selectedNodeRef.current = nodeId;
|
||||
setSelectedNodeState(nodeId);
|
||||
|
||||
// Only animate camera if selecting a new node
|
||||
if (!alreadySelected) {
|
||||
const nodeAttrs = graph.getNodeAttributes(nodeId);
|
||||
sigma.getCamera().animate(
|
||||
{ x: nodeAttrs.x, y: nodeAttrs.y, ratio: 0.15 },
|
||||
{ duration: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
sigma.refresh();
|
||||
}, []);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
sigmaRef.current?.getCamera().animatedZoom({ duration: 200 });
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
sigmaRef.current?.getCamera().animatedUnzoom({ duration: 200 });
|
||||
}, []);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
sigmaRef.current?.getCamera().animatedReset({ duration: 300 });
|
||||
setSelectedNode(null);
|
||||
}, [setSelectedNode]);
|
||||
|
||||
const startLayout = useCallback(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || graph.order === 0) return;
|
||||
runLayout(graph);
|
||||
}, [runLayout]);
|
||||
|
||||
const stopLayout = useCallback(() => {
|
||||
if (layoutTimeoutRef.current) {
|
||||
clearTimeout(layoutTimeoutRef.current);
|
||||
layoutTimeoutRef.current = null;
|
||||
}
|
||||
if (layoutRef.current) {
|
||||
layoutRef.current.stop();
|
||||
layoutRef.current = null;
|
||||
|
||||
const graph = graphRef.current;
|
||||
if (graph) {
|
||||
noverlap.assign(graph, NOVERLAP_SETTINGS);
|
||||
sigmaRef.current?.refresh();
|
||||
}
|
||||
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
sigmaRef,
|
||||
setGraph,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
focusNode,
|
||||
isLayoutRunning,
|
||||
startLayout,
|
||||
stopLayout,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
};
|
||||
};
|
||||
145
src/index.css
Normal file
145
src/index.css
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TAILWIND V4 THEME CONFIGURATION
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@theme {
|
||||
/* Backgrounds */
|
||||
--color-void: #06060a;
|
||||
--color-deep: #0a0a10;
|
||||
--color-surface: #101018;
|
||||
--color-elevated: #16161f;
|
||||
--color-hover: #1c1c28;
|
||||
|
||||
/* Borders */
|
||||
--color-border-subtle: #1e1e2a;
|
||||
--color-border-default: #2a2a3a;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #e4e4ed;
|
||||
--color-text-secondary: #8888a0;
|
||||
--color-text-muted: #5a5a70;
|
||||
|
||||
/* Accent */
|
||||
--color-accent: #7c3aed;
|
||||
--color-accent-dim: #5b21b6;
|
||||
|
||||
/* Node colors */
|
||||
--color-node-file: #3b82f6;
|
||||
--color-node-folder: #6366f1;
|
||||
--color-node-class: #f59e0b;
|
||||
--color-node-function: #10b981;
|
||||
--color-node-interface: #ec4899;
|
||||
--color-node-import: #6b7280;
|
||||
--color-node-method: #14b8a6;
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: 'Outfit', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
|
||||
/* Animations */
|
||||
--animate-breathe: breathe 3s ease-in-out infinite;
|
||||
--animate-pulse-glow: pulse-glow 2s ease-in-out infinite;
|
||||
--animate-slide-in: slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--animate-slide-up: slide-up 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--animate-fade-in: fade-in 0.3s ease-out;
|
||||
|
||||
/* Box shadows */
|
||||
--shadow-glow: 0 0 20px rgba(124, 58, 237, 0.4);
|
||||
--shadow-glow-soft: 0 0 40px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes breathe {
|
||||
0%, 100% {
|
||||
border-color: #2a2a3a;
|
||||
box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
50% {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 40px 10px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 40px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 80px rgba(124, 58, 237, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
BASE STYLES
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-void);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
CUSTOM SCROLLBAR
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #2a2a3a #0a0a10;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: var(--color-deep);
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SIGMA.JS CONTAINER
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.sigma-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sigma-container canvas {
|
||||
outline: none;
|
||||
}
|
||||
67
src/lib/constants.ts
Normal file
67
src/lib/constants.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { NodeLabel } from '../core/graph/types';
|
||||
|
||||
// Node colors by type - slightly muted for less visual noise
|
||||
export const NODE_COLORS: Record<NodeLabel, string> = {
|
||||
Project: '#a855f7', // Purple - prominent
|
||||
Package: '#8b5cf6', // Violet
|
||||
Module: '#7c3aed', // Violet darker
|
||||
Folder: '#6366f1', // Indigo
|
||||
File: '#3b82f6', // Blue
|
||||
Class: '#f59e0b', // Amber - stands out
|
||||
Function: '#10b981', // Emerald
|
||||
Method: '#14b8a6', // Teal
|
||||
Variable: '#64748b', // Slate - muted (less important)
|
||||
Interface: '#ec4899', // Pink
|
||||
Enum: '#f97316', // Orange
|
||||
Decorator: '#eab308', // Yellow
|
||||
Import: '#475569', // Slate darker - very muted
|
||||
Type: '#a78bfa', // Violet light
|
||||
CodeElement: '#64748b', // Slate - muted
|
||||
};
|
||||
|
||||
// Node sizes by type - clear visual hierarchy with dramatic size differences
|
||||
// Structural nodes are MUCH larger to make hierarchy obvious
|
||||
export const NODE_SIZES: Record<NodeLabel, number> = {
|
||||
Project: 20, // Largest - root of everything
|
||||
Package: 16, // Major structural element
|
||||
Module: 13, // Important container
|
||||
Folder: 10, // Structural - clearly bigger than files
|
||||
File: 6, // Common element - smaller than folders
|
||||
Class: 8, // Important code structure
|
||||
Function: 4, // Common code element - small
|
||||
Method: 3, // Smaller than function
|
||||
Variable: 2, // Tiny - leaf node
|
||||
Interface: 7, // Important type definition
|
||||
Enum: 5, // Type definition
|
||||
Decorator: 2, // Tiny modifier
|
||||
Import: 1.5, // Very small - usually hidden anyway
|
||||
Type: 3, // Type alias - small
|
||||
CodeElement: 2, // Generic small
|
||||
};
|
||||
|
||||
// Labels to show by default (hide imports and variables by default as they clutter)
|
||||
export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [
|
||||
'Project',
|
||||
'Package',
|
||||
'Module',
|
||||
'Folder',
|
||||
'File',
|
||||
'Class',
|
||||
'Function',
|
||||
'Method',
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Type',
|
||||
];
|
||||
|
||||
// All filterable labels
|
||||
export const FILTERABLE_LABELS: NodeLabel[] = [
|
||||
'Folder',
|
||||
'File',
|
||||
'Class',
|
||||
'Function',
|
||||
'Method',
|
||||
'Variable',
|
||||
'Interface',
|
||||
'Import',
|
||||
];
|
||||
340
src/lib/graph-adapter.ts
Normal file
340
src/lib/graph-adapter.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import Graph from 'graphology';
|
||||
import { KnowledgeGraph, NodeLabel } from '../core/graph/types';
|
||||
import { NODE_COLORS, NODE_SIZES } from './constants';
|
||||
|
||||
export interface SigmaNodeAttributes {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
color: string;
|
||||
label: string;
|
||||
nodeType: NodeLabel;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
hidden?: boolean;
|
||||
zIndex?: number;
|
||||
highlighted?: boolean;
|
||||
mass?: number; // ForceAtlas2 mass - higher = more repulsion
|
||||
}
|
||||
|
||||
export interface SigmaEdgeAttributes {
|
||||
size: number;
|
||||
color: string;
|
||||
relationType: string;
|
||||
type?: string;
|
||||
curvature?: number;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node size scaled for graph density
|
||||
* Uses lower minimums to maintain hierarchy visibility even in huge graphs
|
||||
*/
|
||||
const getScaledNodeSize = (baseSize: number, nodeCount: number): number => {
|
||||
// Scale factor decreases as graph gets larger
|
||||
// But we use a minimum that preserves relative differences
|
||||
if (nodeCount > 50000) return Math.max(1, baseSize * 0.4);
|
||||
if (nodeCount > 20000) return Math.max(1.5, baseSize * 0.5);
|
||||
if (nodeCount > 5000) return Math.max(2, baseSize * 0.65);
|
||||
if (nodeCount > 1000) return Math.max(2.5, baseSize * 0.8);
|
||||
return baseSize;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get mass for node type - higher mass = more repulsion in ForceAtlas2
|
||||
* Folders get MUCH higher mass so they spread out and pull their files with them
|
||||
*/
|
||||
const getNodeMass = (nodeType: NodeLabel, nodeCount: number): number => {
|
||||
// Scale mass based on graph size
|
||||
const baseMassMultiplier = nodeCount > 5000 ? 2 : nodeCount > 1000 ? 1.5 : 1;
|
||||
|
||||
switch (nodeType) {
|
||||
case 'Project':
|
||||
return 50 * baseMassMultiplier; // Heaviest - anchors everything
|
||||
case 'Package':
|
||||
return 30 * baseMassMultiplier; // Very heavy
|
||||
case 'Module':
|
||||
return 20 * baseMassMultiplier; // Heavy
|
||||
case 'Folder':
|
||||
return 15 * baseMassMultiplier; // Heavy - blasts folders apart!
|
||||
case 'File':
|
||||
return 3 * baseMassMultiplier; // Medium - follows folders
|
||||
case 'Class':
|
||||
case 'Interface':
|
||||
return 5 * baseMassMultiplier; // Medium-heavy
|
||||
case 'Function':
|
||||
case 'Method':
|
||||
return 2 * baseMassMultiplier; // Light
|
||||
default:
|
||||
return 1; // Default mass
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts our KnowledgeGraph to a graphology Graph for Sigma.js
|
||||
* Folders are positioned in a wide spread, children positioned NEAR their parents
|
||||
*/
|
||||
export const knowledgeGraphToGraphology = (
|
||||
knowledgeGraph: KnowledgeGraph
|
||||
): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => {
|
||||
const graph = new Graph<SigmaNodeAttributes, SigmaEdgeAttributes>();
|
||||
const nodeCount = knowledgeGraph.nodes.length;
|
||||
|
||||
// Build parent-child map from hierarchy relationships
|
||||
// CONTAINS: Folder -> File
|
||||
// DEFINES: File -> Function/Class/Interface/Method
|
||||
// IMPORTS: File -> Import
|
||||
// parent -> children
|
||||
const parentToChildren = new Map<string, string[]>();
|
||||
// child -> parent
|
||||
const childToParent = new Map<string, string>();
|
||||
|
||||
const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
|
||||
|
||||
knowledgeGraph.relationships.forEach(rel => {
|
||||
// These relationships represent parent-child hierarchy for positioning
|
||||
if (hierarchyRelations.has(rel.type)) {
|
||||
// source CONTAINS/DEFINES/IMPORTS target, so source is parent
|
||||
if (!parentToChildren.has(rel.sourceId)) {
|
||||
parentToChildren.set(rel.sourceId, []);
|
||||
}
|
||||
parentToChildren.get(rel.sourceId)!.push(rel.targetId);
|
||||
childToParent.set(rel.targetId, rel.sourceId);
|
||||
}
|
||||
});
|
||||
|
||||
// Create node lookup
|
||||
const nodeMap = new Map(knowledgeGraph.nodes.map(n => [n.id, n]));
|
||||
|
||||
// Separate structural nodes (folders, packages) from content nodes
|
||||
const structuralTypes = new Set(['Project', 'Package', 'Module', 'Folder']);
|
||||
const structuralNodes = knowledgeGraph.nodes.filter(n => structuralTypes.has(n.label));
|
||||
|
||||
// Much wider spread for structural nodes - this is the key!
|
||||
const structuralSpread = Math.sqrt(nodeCount) * 40;
|
||||
// Small jitter for children around their parent
|
||||
const childJitter = Math.sqrt(nodeCount) * 3;
|
||||
|
||||
// Store positions for parent lookup
|
||||
const nodePositions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
// Position structural nodes (folders, etc.) in a wide radial pattern FIRST
|
||||
structuralNodes.forEach((node, index) => {
|
||||
// Use golden angle for even distribution
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
const angle = index * goldenAngle;
|
||||
const radius = structuralSpread * Math.sqrt((index + 1) / Math.max(structuralNodes.length, 1));
|
||||
|
||||
// Add some randomness to prevent perfect patterns
|
||||
const jitter = structuralSpread * 0.15;
|
||||
const x = radius * Math.cos(angle) + (Math.random() - 0.5) * jitter;
|
||||
const y = radius * Math.sin(angle) + (Math.random() - 0.5) * jitter;
|
||||
|
||||
nodePositions.set(node.id, { x, y });
|
||||
|
||||
const baseSize = NODE_SIZES[node.label] || 8;
|
||||
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
|
||||
|
||||
graph.addNode(node.id, {
|
||||
x,
|
||||
y,
|
||||
size: scaledSize,
|
||||
color: NODE_COLORS[node.label] || '#9ca3af',
|
||||
label: node.properties.name,
|
||||
nodeType: node.label,
|
||||
filePath: node.properties.filePath,
|
||||
startLine: node.properties.startLine,
|
||||
endLine: node.properties.endLine,
|
||||
hidden: false,
|
||||
mass: getNodeMass(node.label, nodeCount),
|
||||
});
|
||||
});
|
||||
|
||||
// Process remaining nodes in HIERARCHY ORDER (parents before children)
|
||||
// Use BFS starting from structural nodes to ensure parents are positioned first
|
||||
const addNodeWithPosition = (nodeId: string) => {
|
||||
if (graph.hasNode(nodeId)) return;
|
||||
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
let x: number, y: number;
|
||||
|
||||
// Find parent position (parent should already be positioned!)
|
||||
const parentId = childToParent.get(nodeId);
|
||||
const parentPos = parentId ? nodePositions.get(parentId) : null;
|
||||
|
||||
if (parentPos) {
|
||||
// Position near parent with small random offset
|
||||
x = parentPos.x + (Math.random() - 0.5) * childJitter;
|
||||
y = parentPos.y + (Math.random() - 0.5) * childJitter;
|
||||
} else {
|
||||
// No parent found - position randomly but still spread out
|
||||
x = (Math.random() - 0.5) * structuralSpread * 0.5;
|
||||
y = (Math.random() - 0.5) * structuralSpread * 0.5;
|
||||
}
|
||||
|
||||
nodePositions.set(nodeId, { x, y });
|
||||
|
||||
const baseSize = NODE_SIZES[node.label] || 8;
|
||||
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
|
||||
|
||||
graph.addNode(nodeId, {
|
||||
x,
|
||||
y,
|
||||
size: scaledSize,
|
||||
color: NODE_COLORS[node.label] || '#9ca3af',
|
||||
label: node.properties.name,
|
||||
nodeType: node.label,
|
||||
filePath: node.properties.filePath,
|
||||
startLine: node.properties.startLine,
|
||||
endLine: node.properties.endLine,
|
||||
hidden: false,
|
||||
mass: getNodeMass(node.label, nodeCount),
|
||||
});
|
||||
};
|
||||
|
||||
// BFS from structural nodes - this ensures parent is ALWAYS positioned before child
|
||||
const queue: string[] = [...structuralNodes.map(n => n.id)];
|
||||
const visited = new Set<string>(queue);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()!;
|
||||
|
||||
// Get children of current node and add them
|
||||
const children = parentToChildren.get(currentId) || [];
|
||||
for (const childId of children) {
|
||||
if (!visited.has(childId)) {
|
||||
visited.add(childId);
|
||||
addNodeWithPosition(childId);
|
||||
queue.push(childId); // Add to queue so we process ITS children too
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add any orphan nodes that weren't reached (no parent relationship)
|
||||
knowledgeGraph.nodes.forEach((node) => {
|
||||
if (!graph.hasNode(node.id)) {
|
||||
addNodeWithPosition(node.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Add edges with distinct colors per relationship type
|
||||
const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0;
|
||||
|
||||
// Edge styles - each relationship type has a DISTINCT color for clarity
|
||||
// Using varied hues so relationships are easily distinguishable
|
||||
const EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
|
||||
// STRUCTURAL - Greens (folder/file hierarchy)
|
||||
CONTAINS: { color: '#2d5a3d', sizeMultiplier: 0.4 }, // Forest green - folder contains
|
||||
|
||||
// DEFINITIONS - Cyan/Teal (code definitions)
|
||||
DEFINES: { color: '#0e7490', sizeMultiplier: 0.5 }, // Cyan - file defines function/class
|
||||
|
||||
// DEPENDENCIES - Blue (imports between files)
|
||||
IMPORTS: { color: '#1d4ed8', sizeMultiplier: 0.6 }, // Blue - file imports file
|
||||
|
||||
// FUNCTION FLOW - Purple (call graph)
|
||||
CALLS: { color: '#7c3aed', sizeMultiplier: 0.8 }, // Violet - function calls
|
||||
|
||||
// TYPE RELATIONSHIPS - Warm colors (OOP)
|
||||
INHERITS: { color: '#b45309', sizeMultiplier: 1.0 }, // Amber - class inheritance
|
||||
EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension
|
||||
IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation
|
||||
|
||||
// OTHER
|
||||
USES: { color: '#4338ca', sizeMultiplier: 0.7 }, // Indigo - general usage
|
||||
OVERRIDES: { color: '#b91c1c', sizeMultiplier: 0.8 }, // Red - method override
|
||||
DECORATES: { color: '#a16207', sizeMultiplier: 0.6 }, // Yellow/gold - decorators
|
||||
};
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) {
|
||||
if (!graph.hasEdge(rel.sourceId, rel.targetId)) {
|
||||
const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
const curvature = 0.12 + (Math.random() * 0.08);
|
||||
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature: curvature,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter nodes by visibility - sets hidden attribute
|
||||
*/
|
||||
export const filterGraphByLabels = (
|
||||
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
|
||||
visibleLabels: NodeLabel[]
|
||||
): void => {
|
||||
graph.forEachNode((nodeId, attributes) => {
|
||||
const isVisible = visibleLabels.includes(attributes.nodeType);
|
||||
graph.setNodeAttribute(nodeId, 'hidden', !isVisible);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all nodes within N hops of a starting node
|
||||
*/
|
||||
export const getNodesWithinHops = (
|
||||
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
|
||||
startNodeId: string,
|
||||
maxHops: number
|
||||
): Set<string> => {
|
||||
const visited = new Set<string>();
|
||||
const queue: { nodeId: string; depth: number }[] = [{ nodeId: startNodeId, depth: 0 }];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { nodeId, depth } = queue.shift()!;
|
||||
|
||||
if (visited.has(nodeId)) continue;
|
||||
visited.add(nodeId);
|
||||
|
||||
if (depth < maxHops) {
|
||||
graph.forEachNeighbor(nodeId, (neighborId) => {
|
||||
if (!visited.has(neighborId)) {
|
||||
queue.push({ nodeId: neighborId, depth: depth + 1 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter nodes by depth from selected node
|
||||
*/
|
||||
export const filterGraphByDepth = (
|
||||
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
|
||||
selectedNodeId: string | null,
|
||||
maxHops: number | null,
|
||||
visibleLabels: NodeLabel[]
|
||||
): void => {
|
||||
if (maxHops === null) {
|
||||
filterGraphByLabels(graph, visibleLabels);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNodeId === null || !graph.hasNode(selectedNodeId)) {
|
||||
filterGraphByLabels(graph, visibleLabels);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodesInRange = getNodesWithinHops(graph, selectedNodeId, maxHops);
|
||||
|
||||
graph.forEachNode((nodeId, attributes) => {
|
||||
const isLabelVisible = visibleLabels.includes(attributes.nodeType);
|
||||
const isInRange = nodesInRange.has(nodeId);
|
||||
graph.setNodeAttribute(nodeId, 'hidden', !isLabelVisible || !isInRange);
|
||||
});
|
||||
};
|
||||
3
src/lib/utils.ts
Normal file
3
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const generateId = (label: string, name: string): string => {
|
||||
return `${label}:${name}`
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
32
src/services/zip.ts
Normal file
32
src/services/zip.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import JSZip from 'jszip';
|
||||
import { shouldIgnorePath } from '../config/ignore-service';
|
||||
|
||||
export interface FileEntry {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const extractZip = async (file: File): Promise<FileEntry[]> => {
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
const files: FileEntry[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
const processEntry = async (relativePath: string, entry: JSZip.JSZipObject) => {
|
||||
if (entry.dir) return;
|
||||
if (shouldIgnorePath(relativePath)) return;
|
||||
|
||||
const content = await entry.async('string');
|
||||
|
||||
files.push({
|
||||
path: relativePath,
|
||||
content: content
|
||||
});
|
||||
};
|
||||
|
||||
zip.forEach((relativePath, entry) => {
|
||||
promises.push(processEntry(relativePath, entry));
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
return files;
|
||||
};
|
||||
52
src/types/pipeline.ts
Normal file
52
src/types/pipeline.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { GraphNode, GraphRelationship, KnowledgeGraph } from '../core/graph/types';
|
||||
|
||||
export type PipelinePhase = 'idle' | 'extracting' | 'structure' | 'parsing' | 'imports' | 'calls' | 'complete' | 'error';
|
||||
|
||||
export interface PipelineProgress {
|
||||
phase: PipelinePhase;
|
||||
percent: number;
|
||||
message: string;
|
||||
detail?: string;
|
||||
stats?: {
|
||||
filesProcessed: number;
|
||||
totalFiles: number;
|
||||
nodesCreated: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Original result type (used internally in pipeline)
|
||||
export interface PipelineResult {
|
||||
graph: KnowledgeGraph;
|
||||
fileContents: Map<string, string>;
|
||||
}
|
||||
|
||||
// Serializable version for Web Worker communication
|
||||
// Maps and functions cannot be transferred via postMessage
|
||||
export interface SerializablePipelineResult {
|
||||
nodes: GraphNode[];
|
||||
relationships: GraphRelationship[];
|
||||
fileContents: Record<string, string>; // Object instead of Map
|
||||
}
|
||||
|
||||
// Helper to convert PipelineResult to serializable format
|
||||
export const serializePipelineResult = (result: PipelineResult): SerializablePipelineResult => ({
|
||||
nodes: result.graph.nodes,
|
||||
relationships: result.graph.relationships,
|
||||
fileContents: Object.fromEntries(result.fileContents),
|
||||
});
|
||||
|
||||
// Helper to reconstruct from serializable format (used in main thread)
|
||||
export const deserializePipelineResult = (
|
||||
serialized: SerializablePipelineResult,
|
||||
createGraph: () => KnowledgeGraph
|
||||
): PipelineResult => {
|
||||
const graph = createGraph();
|
||||
serialized.nodes.forEach(node => graph.addNode(node));
|
||||
serialized.relationships.forEach(rel => graph.addRelationship(rel));
|
||||
|
||||
return {
|
||||
graph,
|
||||
fileContents: new Map(Object.entries(serialized.fileContents)),
|
||||
};
|
||||
};
|
||||
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
104
src/workers/ingestion.worker.ts
Normal file
104
src/workers/ingestion.worker.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import * as Comlink from 'comlink';
|
||||
import { runIngestionPipeline } from '../core/ingestion/pipeline';
|
||||
import { PipelineProgress, SerializablePipelineResult, serializePipelineResult } from '../types/pipeline';
|
||||
|
||||
// Lazy import for Kuzu to avoid breaking worker if SharedArrayBuffer unavailable
|
||||
let kuzuAdapter: typeof import('../core/kuzu/kuzu-adapter') | null = null;
|
||||
const getKuzuAdapter = async () => {
|
||||
if (!kuzuAdapter) {
|
||||
kuzuAdapter = await import('../core/kuzu/kuzu-adapter');
|
||||
}
|
||||
return kuzuAdapter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Worker API exposed via Comlink
|
||||
*
|
||||
* Note: The onProgress callback is passed as a Comlink.proxy() from the main thread,
|
||||
* allowing us to call it from the worker and have it execute on the main thread.
|
||||
*/
|
||||
const workerApi = {
|
||||
/**
|
||||
* Run the ingestion pipeline in the worker thread
|
||||
* @param file - The ZIP file to process
|
||||
* @param onProgress - Proxied callback for progress updates (runs on main thread)
|
||||
* @returns Serializable result (nodes, relationships, fileContents as object)
|
||||
*/
|
||||
async runPipeline(
|
||||
file: File,
|
||||
onProgress: (progress: PipelineProgress) => void
|
||||
): Promise<SerializablePipelineResult> {
|
||||
// Run the actual pipeline
|
||||
const result = await runIngestionPipeline(file, onProgress);
|
||||
|
||||
// Load graph into KuzuDB for querying (optional - gracefully degrades)
|
||||
try {
|
||||
onProgress({
|
||||
phase: 'complete',
|
||||
percent: 98,
|
||||
message: 'Loading into KuzuDB...',
|
||||
stats: {
|
||||
filesProcessed: result.graph.nodeCount,
|
||||
totalFiles: result.graph.nodeCount,
|
||||
nodesCreated: result.graph.nodeCount,
|
||||
},
|
||||
});
|
||||
|
||||
const kuzu = await getKuzuAdapter();
|
||||
await kuzu.loadGraphToKuzu(result.graph, result.fileContents);
|
||||
|
||||
const stats = await kuzu.getKuzuStats();
|
||||
console.log('KuzuDB loaded:', stats);
|
||||
} catch {
|
||||
// KuzuDB is optional - silently continue without it
|
||||
// The graph visualization still works without the database
|
||||
}
|
||||
|
||||
// Convert to serializable format for transfer back to main thread
|
||||
return serializePipelineResult(result);
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute a Cypher query against the KuzuDB database
|
||||
* @param cypher - The Cypher query string
|
||||
* @returns Query results as an array of objects
|
||||
*/
|
||||
async runQuery(cypher: string): Promise<any[]> {
|
||||
const kuzu = await getKuzuAdapter();
|
||||
if (!kuzu.isKuzuReady()) {
|
||||
throw new Error('Database not ready. Please load a repository first.');
|
||||
}
|
||||
return kuzu.executeQuery(cypher);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the database is ready for queries
|
||||
*/
|
||||
async isReady(): Promise<boolean> {
|
||||
try {
|
||||
const kuzu = await getKuzuAdapter();
|
||||
return kuzu.isKuzuReady();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
*/
|
||||
async getStats(): Promise<{ nodes: number; edges: number }> {
|
||||
try {
|
||||
const kuzu = await getKuzuAdapter();
|
||||
return kuzu.getKuzuStats();
|
||||
} catch {
|
||||
return { nodes: 0, edges: 0 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Expose the worker API to the main thread
|
||||
Comlink.expose(workerApi);
|
||||
|
||||
// TypeScript type for the exposed API (used by the hook)
|
||||
export type IngestionWorkerApi = typeof workerApi;
|
||||
|
||||
23
tsconfig.app.json
Normal file
23
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
13
tsconfig.node.json
Normal file
13
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
47
vite.config.ts
Normal file
47
vite.config.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
import topLevelAwait from 'vite-plugin-top-level-await';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
// Optimize deps - exclude kuzu-wasm from pre-bundling (it has WASM files)
|
||||
optimizeDeps: {
|
||||
exclude: ['kuzu-wasm'],
|
||||
},
|
||||
// Required for KuzuDB WASM (SharedArrayBuffer needs Cross-Origin Isolation)
|
||||
server: {
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
// Allow serving files from node_modules
|
||||
fs: {
|
||||
allow: ['..'],
|
||||
},
|
||||
},
|
||||
// Also set for preview/production builds
|
||||
preview: {
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
},
|
||||
// Worker configuration
|
||||
worker: {
|
||||
format: 'es',
|
||||
plugins: () => [wasm(), topLevelAwait()],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue