Files
code-graph/src/pages/code-graph/components/NodeSearchBox.tsx
xiongxiao fa11796aef 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.
2026-03-13 22:01:14 +08:00

134 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
});