- 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.
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
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>
|
||
);
|
||
});
|