865e0906b9
- Core: 添加 file-index 模块,使用 ripgrep 索引文件,fuzzysort 模糊搜索 - Server: 添加 /api/files/search 端点,支持文件模糊搜索 - Server: WebSocket 消息处理中将 @filepath 转换为 ./filepath 格式 - UI: 新增 FileMenu 组件,显示文件搜索结果列表 - UI: 新增 FileMentionTag 组件,高亮显示文件提及 - UI: 新增 useFileMention hook,管理文件提及状态 - UI: ChatInput 集成 @ 触发的文件自动补全 - UI: ChatMessage 用户消息中高亮显示 @filepath
213 lines
6.8 KiB
TypeScript
213 lines
6.8 KiB
TypeScript
/**
|
|
* File Menu Component
|
|
*
|
|
* 文件自动补全菜单,支持键盘导航
|
|
*/
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { File, Folder } from 'lucide-react';
|
|
import { cn } from '../utils/cn.js';
|
|
|
|
export interface FileMenuItem {
|
|
path: string;
|
|
name: string;
|
|
type: 'file' | 'directory';
|
|
extension?: string;
|
|
}
|
|
|
|
interface FileMenuProps {
|
|
/** 文件列表 */
|
|
files: FileMenuItem[];
|
|
/** 是否显示 */
|
|
isOpen: boolean;
|
|
/** 当前选中索引 */
|
|
selectedIndex: number;
|
|
/** 选择文件回调 */
|
|
onSelect: (file: FileMenuItem) => void;
|
|
/** 关闭菜单回调 */
|
|
onClose: () => void;
|
|
/** 选中索引变化回调 */
|
|
onSelectedIndexChange: (index: number) => void;
|
|
/** 是否正在加载 */
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
// 文件图标颜色
|
|
function getFileIconColor(extension?: string): string {
|
|
const colors: Record<string, string> = {
|
|
ts: 'text-blue-400',
|
|
tsx: 'text-blue-400',
|
|
js: 'text-yellow-400',
|
|
jsx: 'text-yellow-400',
|
|
json: 'text-yellow-500',
|
|
md: 'text-fg-muted',
|
|
css: 'text-pink-400',
|
|
scss: 'text-pink-400',
|
|
html: 'text-orange-400',
|
|
py: 'text-green-400',
|
|
go: 'text-cyan-400',
|
|
rs: 'text-orange-500',
|
|
vue: 'text-emerald-400',
|
|
svelte: 'text-orange-500',
|
|
};
|
|
return colors[extension || ''] || 'text-fg-muted';
|
|
}
|
|
|
|
export function FileMenu({
|
|
files,
|
|
isOpen,
|
|
selectedIndex,
|
|
onSelect,
|
|
onClose,
|
|
onSelectedIndexChange,
|
|
isLoading = false,
|
|
}: FileMenuProps) {
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const selectedRef = useRef<HTMLButtonElement>(null);
|
|
|
|
// 滚动选中项到可见区域
|
|
useEffect(() => {
|
|
if (isOpen && selectedRef.current) {
|
|
selectedRef.current.scrollIntoView({
|
|
block: 'nearest',
|
|
behavior: 'smooth',
|
|
});
|
|
}
|
|
}, [selectedIndex, isOpen]);
|
|
|
|
// 键盘导航
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
onSelectedIndexChange(selectedIndex < files.length - 1 ? selectedIndex + 1 : 0);
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
onSelectedIndexChange(selectedIndex > 0 ? selectedIndex - 1 : files.length - 1);
|
|
break;
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
if (files[selectedIndex]) {
|
|
onSelect(files[selectedIndex]);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
onClose();
|
|
break;
|
|
case 'Tab':
|
|
e.preventDefault();
|
|
if (files[selectedIndex]) {
|
|
onSelect(files[selectedIndex]);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, files, selectedIndex, onSelect, onClose, onSelectedIndexChange]);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
ref={menuRef}
|
|
initial={{ opacity: 0, y: 8, scale: 0.96 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 8, scale: 0.96 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="absolute bottom-full left-0 right-0 mb-2 mx-4 md:mx-0 z-50"
|
|
>
|
|
<div className="bg-surface-subtle border border-line rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="px-3 py-2 border-b border-line bg-surface-subtle/80 sticky top-0">
|
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
<File size={12} />
|
|
<span>Files</span>
|
|
{files.length > 0 && <span className="text-fg-subtle">({files.length})</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Loading */}
|
|
{isLoading && files.length === 0 && (
|
|
<div className="px-3 py-4 text-center text-fg-muted text-sm">Searching files...</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{!isLoading && files.length === 0 && (
|
|
<div className="px-3 py-4 text-center text-fg-muted text-sm">No files found</div>
|
|
)}
|
|
|
|
{/* File list */}
|
|
{files.length > 0 && (
|
|
<div className="py-1">
|
|
{files.map((file, index) => (
|
|
<button
|
|
key={file.path}
|
|
ref={index === selectedIndex ? selectedRef : null}
|
|
onClick={() => onSelect(file)}
|
|
onMouseEnter={() => onSelectedIndexChange(index)}
|
|
className={cn(
|
|
'w-full px-3 py-2 flex items-center gap-3 text-left transition-colors',
|
|
index === selectedIndex ? 'bg-primary-600/20' : 'hover:bg-surface-muted/50'
|
|
)}
|
|
>
|
|
{/* Icon */}
|
|
{file.type === 'directory' ? (
|
|
<Folder size={14} className="text-yellow-400 flex-shrink-0" />
|
|
) : (
|
|
<File
|
|
size={14}
|
|
className={cn(getFileIconColor(file.extension), 'flex-shrink-0')}
|
|
/>
|
|
)}
|
|
|
|
{/* Path */}
|
|
<span
|
|
className={cn(
|
|
'font-mono text-sm truncate',
|
|
index === selectedIndex ? 'text-primary-300' : 'text-fg-secondary'
|
|
)}
|
|
>
|
|
{file.path}
|
|
</span>
|
|
|
|
{/* Keyboard hint */}
|
|
{index === selectedIndex && (
|
|
<div className="ml-auto flex items-center gap-1 text-[10px] text-fg-subtle flex-shrink-0">
|
|
<kbd className="px-1 py-0.5 bg-surface-muted rounded">Enter</kbd>
|
|
</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer hint */}
|
|
<div className="px-3 py-1.5 border-t border-line bg-surface-subtle/80 sticky bottom-0">
|
|
<div className="flex items-center gap-3 text-[10px] text-fg-subtle">
|
|
<span>
|
|
<kbd className="px-1 py-0.5 bg-surface-muted rounded mr-1">↑</kbd>
|
|
<kbd className="px-1 py-0.5 bg-surface-muted rounded">↓</kbd> navigate
|
|
</span>
|
|
<span>
|
|
<kbd className="px-1 py-0.5 bg-surface-muted rounded">Tab</kbd> select
|
|
</span>
|
|
<span>
|
|
<kbd className="px-1 py-0.5 bg-surface-muted rounded">Esc</kbd> close
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|