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
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user