feat: add code graph feature with interactive visualization

- Implemented CodeGraphView component for visualizing project file structure using Sigma.js.
- Added NodeSearchBox for searching nodes within the graph.
- Created API module to fetch file data from backend.
- Developed graph building logic to construct a tree structure from file data.
- Integrated new routes and updated route tree to include the code graph page.
- Updated Vite configuration to load environment variables and changed server port.
- Added example data for testing the code graph functionality.
This commit is contained in:
xiongxiao
2026-03-13 22:01:14 +08:00
committed by cnb
parent 3e54f994fd
commit fa11796aef
15 changed files with 1457 additions and 17 deletions

View File

@@ -0,0 +1,133 @@
import { useState, useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon, FolderIcon, FileIcon, DatabaseIcon } from 'lucide-react';
import { NodeSearchEntry, NodeKind } from '../modules/graph';
export interface NodeSearchBoxHandle {
focus: () => void;
}
interface NodeSearchBoxProps {
searchIndex: NodeSearchEntry[];
onSelect: (entry: NodeSearchEntry) => void;
}
const KIND_LABEL: Record<NodeKind, string> = { root: '项目根', dir: '目录', file: '文件' };
const KIND_COLOR: Record<NodeKind, string> = {
root: 'text-amber-400',
dir: 'text-blue-400',
file: 'text-indigo-400',
};
function KindIcon({ kind }: { kind: NodeKind }) {
if (kind === 'root') return <DatabaseIcon className='size-3 shrink-0 text-amber-400' />;
if (kind === 'dir') return <FolderIcon className='size-3 shrink-0 text-blue-400' />;
return <FileIcon className='size-3 shrink-0 text-indigo-400' />;
}
export const NodeSearchBox = forwardRef<NodeSearchBoxHandle, NodeSearchBoxProps>(function NodeSearchBox({ searchIndex, onSelect }, ref) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({
focus() {
inputRef.current?.focus();
inputRef.current?.select();
},
}));
// 点击外部关闭
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filtered = query.trim()
? searchIndex
.filter((e) => e.label.toLowerCase().includes(query.toLowerCase()) || e.fullPath.toLowerCase().includes(query.toLowerCase()))
.slice(0, 30)
: [];
const handleSelect = (entry: NodeSearchEntry) => {
onSelect(entry);
setQuery('');
setOpen(false);
inputRef.current?.blur();
};
return (
<div ref={containerRef} className='relative w-64'>
<CommandPrimitive
// cmdk 的 shouldFilter=false由我们自己过滤
shouldFilter={false}
onKeyDown={(e) => {
// Esc 关闭
if (e.key === 'Escape') {
setOpen(false);
inputRef.current?.blur();
}
}}>
{/* 输入框 */}
<div className='flex items-center gap-1.5 rounded-md border border-white/10 bg-slate-800 px-2 py-1 focus-within:ring-1 focus-within:ring-indigo-500'>
<SearchIcon className='size-3 shrink-0 text-slate-500' />
<CommandPrimitive.Input
ref={inputRef}
value={query}
onValueChange={(v) => {
setQuery(v);
setOpen(v.trim().length > 0);
}}
onFocus={() => query.trim().length > 0 && setOpen(true)}
placeholder='搜索节点…'
className='flex-1 min-w-0 bg-transparent text-xs text-slate-100 outline-none placeholder:text-slate-500'
/>
{query && (
<button
onMouseDown={(e) => {
e.preventDefault();
setQuery('');
setOpen(false);
}}
className='text-slate-500 hover:text-slate-300 text-xs leading-none'></button>
)}
</div>
{/* 下拉列表,使用绝对定位浮在画布上方 */}
{open && filtered.length > 0 && (
<div className='absolute top-full mt-1 left-0 right-0 z-50 rounded-md border border-white/10 bg-slate-800 shadow-2xl overflow-hidden'>
<CommandPrimitive.List className='max-h-64 overflow-y-auto py-1 scrollbar'>
<CommandPrimitive.Empty className='py-4 text-center text-xs text-slate-500'>
</CommandPrimitive.Empty>
{filtered.map((entry) => (
<CommandPrimitive.Item
key={entry.nodeKey}
value={entry.nodeKey}
onSelect={() => handleSelect(entry)}
className='flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer
aria-selected:bg-slate-700 data-[selected=true]:bg-slate-700
hover:bg-slate-700 outline-none'>
<KindIcon kind={entry.kind} />
<span className={`shrink-0 text-[10px] font-medium ${KIND_COLOR[entry.kind]}`}>
{KIND_LABEL[entry.kind]}
</span>
<span className='text-slate-200 truncate'>{entry.label}</span>
<span className='text-slate-500 text-[10px] ml-auto shrink-0 max-w-[100px] truncate'>
{entry.fullPath.split('/').slice(-2).join('/')}
</span>
</CommandPrimitive.Item>
))}
</CommandPrimitive.List>
</div>
)}
</CommandPrimitive>
</div>
);
});