GitNexus V2 - Complete refactor

This commit is contained in:
abhigyanpatwari 2026-01-03 23:00:55 +05:30
commit 21719396fa
51 changed files with 10003 additions and 0 deletions

34
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

48
package.json Normal file
View 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"
}
}

Binary file not shown.

BIN
public/wasm/kuzu-wasm.wasm Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

109
src/App.tsx Normal file
View 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
View 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>
);
};

View 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>
);
};

View 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
View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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;
}

View 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
View 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
View 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,
}

View 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
})
};
};

View 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);
};

View 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();
}
}
};

View 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
}
};

View 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;
}
};

View 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
})
})
}

View 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 };
};

View 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,
};

View 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;
};

View 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');
};

View 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
View 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];

View 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
View 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;
};

View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
export const generateId = (label: string, name: string): string => {
return `${label}:${name}`
}

10
src/main.tsx Normal file
View 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
View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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
View 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
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

13
tsconfig.node.json Normal file
View 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
View 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()],
},
});