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:
133
src/pages/code-graph/components/NodeSearchBox.tsx
Normal file
133
src/pages/code-graph/components/NodeSearchBox.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user