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:
2025-12-15 16:32:59 +08:00
parent 5b7b0ff1e4
commit 865e0906b9
15 changed files with 1137 additions and 53 deletions
+23
View File
@@ -48,6 +48,8 @@ import type {
// Context types
ContextUsageInfo,
CompressionResult,
// File search types
FileSearchResponse,
} from './types.js';
// Re-export types
@@ -124,6 +126,9 @@ export type {
CompressionResult,
// WebSocket error types
ConfigErrorPayload,
// File search types
FileSearchResult,
FileSearchResponse,
} from './types.js';
// API Configuration
@@ -974,3 +979,21 @@ export async function compressContext(
}> {
return request('POST', `/sessions/${encodeURIComponent(sessionId)}/compress`, options || {});
}
// ============ File Search API ============
/**
* 模糊搜索项目文件
*/
export async function searchFiles(
query: string = '',
limit: number = 10,
type: 'file' | 'directory' | 'all' = 'file'
): Promise<FileSearchResponse> {
const params = new URLSearchParams({
query,
limit: String(limit),
type,
});
return request('GET', `/files/search?${params}`);
}
+23
View File
@@ -854,3 +854,26 @@ export interface ConfigErrorPayload {
action: 'open_providers_panel' | 'open_settings';
}
// ============ 文件搜索相关 ============
/** 文件搜索结果 */
export interface FileSearchResult {
/** 相对路径 */
path: string;
/** 文件名 */
name: string;
/** 文件类型 */
type: 'file' | 'directory';
/** 扩展名 */
extension?: string;
}
/** 文件搜索响应 */
export interface FileSearchResponse {
success: boolean;
data: {
files: FileSearchResult[];
total: number;
};
}
+171 -48
View File
@@ -3,13 +3,17 @@
*
* 支持响应式:responsive=true 时适配移动端键盘和触摸操作
* 支持斜杠命令:输入 / 时显示命令菜单
* 支持文件提及:输入 @ 时显示文件搜索菜单
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Send, Square } from 'lucide-react';
import clsx from 'clsx';
import { CommandMenu, type CommandMenuItem } from './CommandMenu.js';
import { FileMenu, type FileMenuItem } from './FileMenu.js';
import { FileMentionTag } from './FileMentionTag.js';
import { useCommands } from '../hooks/useCommands.js';
import { useFileMention } from '../hooks/useFileMention.js';
interface ChatInputProps {
onSend: (content: string) => void;
@@ -20,6 +24,8 @@ interface ChatInputProps {
responsive?: boolean;
/** 是否启用斜杠命令 */
enableCommands?: boolean;
/** 是否启用文件提及 (@) */
enableFileMention?: boolean;
}
export function ChatInput({
@@ -29,6 +35,7 @@ export function ChatInput({
disabled,
responsive = false,
enableCommands = true,
enableFileMention = true,
}: ChatInputProps) {
const [input, setInput] = useState('');
const [showCommandMenu, setShowCommandMenu] = useState(false);
@@ -42,6 +49,41 @@ export function ChatInput({
filterCommands,
} = useCommands({ autoLoad: enableCommands });
// 文件提及系统
const {
isOpen: showFileMenu,
files: filteredFiles,
isLoading: filesLoading,
selectedIndex: fileSelectedIndex,
setSelectedIndex: setFileSelectedIndex,
checkTrigger: checkFileTrigger,
getReplacementText,
close: closeFileMenu,
mentionStart,
} = useFileMention({ enabled: enableFileMention });
// 解析输入中已存在的文件提及 (匹配 @path/to/file 格式)
const mentionedFiles = useMemo(() => {
const regex = /@([\w./-]+)/g;
const files: string[] = [];
let match;
while ((match = regex.exec(input)) !== null) {
files.push(match[1]);
}
return files;
}, [input]);
// 移除指定的文件提及
const handleRemoveFile = useCallback(
(filePath: string) => {
// 移除 @filepath 和后面的空格
const regex = new RegExp(`@${filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g');
setInput((prev) => prev.replace(regex, '').trim());
textareaRef.current?.focus();
},
[]
);
// 自动调整高度
useEffect(() => {
const textarea = textareaRef.current;
@@ -77,21 +119,55 @@ export function ChatInput({
// 处理输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursorPos = e.target.selectionStart;
setInput(value);
// 检查命令触发
checkCommandTrigger(value);
// 检查文件提及触发(只在非命令输入模式下)
if (enableFileMention && !value.startsWith('/')) {
checkFileTrigger(value, cursorPos);
} else {
closeFileMenu();
}
};
// 选择命令
const handleCommandSelect = useCallback(
(command: CommandMenuItem) => {
// 替换输入内容为 /command + 空格,准备输入参数
setInput(`/${command.name} `);
setShowCommandMenu(false);
const handleCommandSelect = useCallback((command: CommandMenuItem) => {
// 替换输入内容为 /command + 空格,准备输入参数
setInput(`/${command.name} `);
setShowCommandMenu(false);
// 聚焦输入框
textareaRef.current?.focus();
// 聚焦输入框
textareaRef.current?.focus();
}, []);
// 选择文件
const handleFileSelect = useCallback(
(file: FileMenuItem) => {
if (mentionStart === null) return;
// 获取当前光标位置
const cursorPos = textareaRef.current?.selectionStart || input.length;
// 替换 @ 到光标之间的内容
const before = input.slice(0, mentionStart);
const after = input.slice(cursorPos);
const replacement = getReplacementText(file);
const newInput = before + replacement + after;
setInput(newInput);
closeFileMenu();
// 聚焦输入框并设置光标位置
setTimeout(() => {
textareaRef.current?.focus();
const newPos = before.length + replacement.length;
textareaRef.current?.setSelectionRange(newPos, newPos);
}, 0);
},
[]
[input, mentionStart, getReplacementText, closeFileMenu]
);
// 关闭命令菜单
@@ -103,8 +179,9 @@ export function ChatInput({
const trimmed = input.trim();
if (!trimmed || isLoading || disabled) return;
// 关闭命令菜单
// 关闭菜单
setShowCommandMenu(false);
closeFileMenu();
onSend(trimmed);
setInput('');
@@ -116,7 +193,22 @@ export function ChatInput({
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// 如果命令菜单打开,让菜单处理键盘事件
// 文件菜单优先处理(因为可以在任意位置触发)
if (showFileMenu && filteredFiles.length > 0) {
if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) {
// 这些键由 FileMenu 处理
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (filteredFiles[fileSelectedIndex]) {
handleFileSelect(filteredFiles[fileSelectedIndex]);
}
return;
}
}
// 命令菜单处理
if (showCommandMenu && filteredCommands.length > 0) {
if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) {
// 这些键由 CommandMenu 处理,阻止默认行为
@@ -159,51 +251,82 @@ export function ChatInput({
/>
)}
<div className="max-w-4xl mx-auto flex gap-2">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
responsive
? 'Type a message or /command...'
: 'Type a message or /command... (Shift+Enter for new line)'
}
disabled={disabled}
rows={1}
{/* File Menu */}
{enableFileMention && (
<FileMenu
files={filteredFiles}
isOpen={showFileMenu}
selectedIndex={fileSelectedIndex}
onSelect={handleFileSelect}
onClose={closeFileMenu}
onSelectedIndexChange={setFileSelectedIndex}
isLoading={filesLoading}
/>
)}
<div className="max-w-4xl mx-auto">
{/* 已选文件标签 */}
{mentionedFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{mentionedFiles.map((file, index) => (
<FileMentionTag
key={`${file}-${index}`}
path={file}
size="sm"
removable
onRemove={() => handleRemoveFile(file)}
/>
))}
</div>
)}
{/* 输入区域 */}
<div className="flex gap-2">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
responsive
? 'Type a message, /command, or @file...'
: 'Type a message, /command, or @file... (Shift+Enter for new line)'
}
disabled={disabled}
rows={1}
className={clsx(
'w-full resize-none rounded-lg border border-line bg-surface-subtle',
responsive ? 'px-3 py-2.5 md:px-4 md:py-3' : 'px-4 py-3',
responsive ? 'text-base md:text-sm' : 'text-sm', // 移动端使用 16px 防止缩放
'text-fg placeholder-fg-subtle',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
/>
</div>
<button
onClick={isLoading ? onCancel : handleSubmit}
disabled={!isLoading && (!input.trim() || disabled)}
className={clsx(
'w-full resize-none rounded-lg border border-line bg-surface-subtle',
responsive ? 'px-3 py-2.5 md:px-4 md:py-3' : 'px-4 py-3',
responsive ? 'text-base md:text-sm' : 'text-sm', // 移动端使用 16px 防止缩放
'text-fg placeholder-fg-subtle',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'rounded-lg flex items-center justify-center transition-colors self-end',
responsive
? 'px-3 py-2.5 md:px-4 md:py-3 min-w-[44px] min-h-[44px]' // 最小触摸目标 44x44
: 'px-4 py-3',
isLoading
? 'bg-red-600 hover:bg-red-700 active:bg-red-800 text-white'
: 'bg-primary-600 hover:bg-primary-700 active:bg-primary-800 text-white',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
/>
>
{isLoading ? <Square size={20} /> : <Send size={20} />}
</button>
</div>
<button
onClick={isLoading ? onCancel : handleSubmit}
disabled={!isLoading && (!input.trim() || disabled)}
className={clsx(
'rounded-lg flex items-center justify-center transition-colors',
responsive
? 'px-3 py-2.5 md:px-4 md:py-3 min-w-[44px] min-h-[44px]' // 最小触摸目标 44x44
: 'px-4 py-3',
isLoading
? 'bg-red-600 hover:bg-red-700 active:bg-red-800 text-white'
: 'bg-primary-600 hover:bg-primary-700 active:bg-primary-800 text-white',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isLoading ? <Square size={20} /> : <Send size={20} />}
</button>
</div>
{/* 响应式模式下桌面端显示提示文字 */}
{responsive && (
<p className="hidden md:block text-xs text-fg-subtle text-center mt-2">
Press Enter to send, Shift+Enter for new line, / for commands
Press Enter to send, Shift+Enter for new line, / for commands, @ for files
</p>
)}
</div>
+6 -3
View File
@@ -20,6 +20,7 @@ import { useState, forwardRef } from 'react';
import { cn } from '../utils/cn';
import { fadeInUp, smoothTransition } from '../utils/animations';
import { Markdown } from './Markdown';
import { FileMentionText } from './FileMentionTag';
import type { Message, ToolCallInfo, ToolCallStatus, ToolMessagePart } from '../api/types.js';
interface ChatMessageProps {
@@ -48,8 +49,8 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
case 'text':
if (!part.text) return null;
return isUser ? (
<div key={part.id} className="whitespace-pre-wrap break-words">
{part.text}
<div key={part.id}>
<FileMentionText text={part.text} />
</div>
) : (
<Markdown key={part.id} content={part.text} />
@@ -78,7 +79,9 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
)}
<div className="message-content text-fg-secondary">
{isUser ? (
<div className="whitespace-pre-wrap break-words">{message.content ?? ''}</div>
<div>
<FileMentionText text={message.content ?? ''} />
</div>
) : (
<Markdown content={message.content ?? ''} />
)}
@@ -0,0 +1,181 @@
/**
* File Mention Tag Component
*
* 显示文件提及的标签样式组件
*/
import { File, Folder, X } from 'lucide-react';
import { cn } from '../utils/cn.js';
export interface FileMentionTagProps {
/** 文件路径 */
path: string;
/** 是否可删除 */
removable?: boolean;
/** 删除回调 */
onRemove?: () => void;
/** 点击回调 */
onClick?: () => void;
/** 尺寸 */
size?: 'sm' | 'md';
/** 额外的 class */
className?: string;
}
// 根据扩展名获取图标颜色
function getFileColor(path: string): string {
const ext = path.split('.').pop()?.toLowerCase();
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[ext || ''] || 'text-fg-muted';
}
// 判断是否是目录
function isDirectory(path: string): boolean {
return path.endsWith('/');
}
// 获取文件名
function getFileName(path: string): string {
const parts = path.replace(/\/$/, '').split('/');
return parts[parts.length - 1] || path;
}
export function FileMentionTag({
path,
removable = false,
onRemove,
onClick,
size = 'md',
className,
}: FileMentionTagProps) {
const isDir = isDirectory(path);
const fileName = getFileName(path);
const iconColor = isDir ? 'text-yellow-400' : getFileColor(path);
const sizeClasses = {
sm: 'text-xs px-1.5 py-0.5 gap-1',
md: 'text-sm px-2 py-1 gap-1.5',
};
const iconSize = size === 'sm' ? 10 : 12;
return (
<span
className={cn(
'inline-flex items-center rounded-md',
'bg-primary-600/20 text-primary-300 border border-primary-600/30',
'font-mono',
sizeClasses[size],
onClick && 'cursor-pointer hover:bg-primary-600/30 transition-colors',
className
)}
onClick={onClick}
>
{isDir ? (
<Folder size={iconSize} className={iconColor} />
) : (
<File size={iconSize} className={iconColor} />
)}
<span className="truncate max-w-[200px]" title={path}>
{fileName}
</span>
{removable && onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-0.5 p-0.5 rounded hover:bg-primary-600/40 transition-colors"
>
<X size={iconSize} />
</button>
)}
</span>
);
}
/**
* 解析文本中的文件提及
* 返回分段后的内容数组
*/
export interface ParsedSegment {
type: 'text' | 'file';
content: string;
}
export function parseFileMentions(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
// 匹配 @filepath 格式(路径可以包含 / 和 . 但不能有空格)
const regex = /@([\w./-]+)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
// 添加前面的普通文本
if (match.index > lastIndex) {
segments.push({
type: 'text',
content: text.slice(lastIndex, match.index),
});
}
// 添加文件提及 (不包含 @ 符号的路径)
segments.push({
type: 'file',
content: match[1],
});
lastIndex = regex.lastIndex;
}
// 添加剩余的文本
if (lastIndex < text.length) {
segments.push({
type: 'text',
content: text.slice(lastIndex),
});
}
return segments;
}
/**
* 渲染带有文件提及高亮的文本
*/
interface FileMentionTextProps {
text: string;
className?: string;
}
export function FileMentionText({ text, className }: FileMentionTextProps) {
const segments = parseFileMentions(text);
if (segments.length === 0) {
return <span className={className}>{text}</span>;
}
return (
<span className={cn('whitespace-pre-wrap break-words', className)}>
{segments.map((segment, index) =>
segment.type === 'file' ? (
<FileMentionTag key={index} path={segment.content} size="sm" className="mx-0.5" />
) : (
<span key={index}>{segment.content}</span>
)
)}
</span>
);
}
+212
View File
@@ -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>
);
}
+150
View File
@@ -0,0 +1,150 @@
/**
* useFileMention Hook
*
* 管理文件提及的状态和逻辑
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { searchFiles } from '../api/client.js';
import type { FileSearchResult } from '../api/types.js';
interface UseFileMentionOptions {
/** 是否启用 */
enabled?: boolean;
/** 搜索结果限制 */
limit?: number;
/** 防抖延迟 (ms) */
debounceMs?: number;
}
interface UseFileMentionReturn {
/** 是否显示文件菜单 */
isOpen: boolean;
/** 搜索结果 */
files: FileSearchResult[];
/** 是否正在加载 */
isLoading: boolean;
/** 当前选中索引 */
selectedIndex: number;
/** 设置选中索引 */
setSelectedIndex: (index: number) => void;
/** 检查并处理 @ 触发 */
checkTrigger: (value: string, cursorPos: number) => void;
/** 选择文件后获取替换文本 */
getReplacementText: (file: FileSearchResult) => string;
/** 关闭菜单 */
close: () => void;
/** @ 符号在输入中的位置 */
mentionStart: number | null;
}
export function useFileMention(options: UseFileMentionOptions = {}): UseFileMentionReturn {
const { enabled = true, limit = 10, debounceMs = 150 } = options;
const [isOpen, setIsOpen] = useState(false);
const [files, setFiles] = useState<FileSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [mentionStart, setMentionStart] = useState<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 搜索文件
const search = useCallback(
async (query: string) => {
setIsLoading(true);
try {
const result = await searchFiles(query, limit, 'all');
if (result.success) {
setFiles(result.data.files);
setSelectedIndex(0);
}
} catch (error) {
console.error('File search error:', error);
setFiles([]);
} finally {
setIsLoading(false);
}
},
[limit]
);
// 检查 @ 触发
const checkTrigger = useCallback(
(value: string, cursorPos: number) => {
if (!enabled) return;
// 找到光标前最近的 @ 符号
const beforeCursor = value.slice(0, cursorPos);
const lastAtIndex = beforeCursor.lastIndexOf('@');
if (lastAtIndex === -1) {
setIsOpen(false);
setMentionStart(null);
return;
}
// 检查 @ 前面是否为空白或行首
const charBefore = lastAtIndex > 0 ? beforeCursor[lastAtIndex - 1] : '';
if (charBefore && !/\s/.test(charBefore)) {
setIsOpen(false);
setMentionStart(null);
return;
}
// 检查 @ 后面到光标之间是否有空格(如果有,说明已经完成输入)
const afterAt = beforeCursor.slice(lastAtIndex + 1);
if (afterAt.includes(' ')) {
setIsOpen(false);
setMentionStart(null);
return;
}
// 触发文件搜索
setIsOpen(true);
setMentionStart(lastAtIndex);
// 防抖搜索
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
search(afterAt);
}, debounceMs);
},
[enabled, search, debounceMs]
);
// 获取替换文本
const getReplacementText = useCallback((file: FileSearchResult): string => {
// 返回 @filepath 格式(用户友好的显示格式)
return `@${file.path} `;
}, []);
// 关闭菜单
const close = useCallback(() => {
setIsOpen(false);
setMentionStart(null);
}, []);
// 清理
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
return {
isOpen,
files,
isLoading,
selectedIndex,
setSelectedIndex,
checkTrigger,
getReplacementText,
close,
mentionStart,
};
}
+14
View File
@@ -93,6 +93,8 @@ export {
// Context API
getContextUsage,
compressContext,
// File Search API
searchFiles,
} from './api/client.js';
// Types
@@ -169,6 +171,9 @@ export type {
ContextUsageInfo,
CompressionStatus,
CompressionResult,
// File Search types
FileSearchResult,
FileSearchResponse,
} from './api/client.js';
// Primitives (shadcn/ui style)
@@ -182,6 +187,14 @@ export * from './utils/animations.js';
export { ChatMessage, StreamingMessage, TypingIndicator } from './components/ChatMessage.js';
export { ChatInput } from './components/ChatInput.js';
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
export { FileMenu, type FileMenuItem } from './components/FileMenu.js';
export {
FileMentionTag,
FileMentionText,
parseFileMentions,
type FileMentionTagProps,
type ParsedSegment,
} from './components/FileMentionTag.js';
export { CommandPanel } from './components/CommandPanel.js';
export { CommandEditor } from './components/CommandEditor.js';
export { MCPPanel } from './components/MCPPanel.js';
@@ -212,6 +225,7 @@ export { toast } from 'sonner';
// Hooks
export { useChat } from './hooks/useChat.js';
export { useCommands } from './hooks/useCommands.js';
export { useFileMention } from './hooks/useFileMention.js';
export { useTheme, ThemeProvider, themeInitScript, type Theme, type ResolvedTheme } from './hooks/useTheme.js';
// Theme Components