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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user