Files
ai-terminal-assistant/packages/ui/src/components/FileMenu.tsx
T
kurihada 865e0906b9 feat(ui): 实现 @ 文件提及自动补全功能
- 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
2025-12-15 16:32:59 +08:00

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>
);
}