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,296 @@
|
||||
/**
|
||||
* File Index Module
|
||||
*
|
||||
* 使用 ripgrep 索引项目文件,支持模糊搜索
|
||||
*/
|
||||
|
||||
import fuzzysort from 'fuzzysort';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export interface FileIndexEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export interface FileSearchOptions {
|
||||
query: string;
|
||||
limit?: number;
|
||||
type?: 'file' | 'directory' | 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件索引类
|
||||
*/
|
||||
class FileIndex {
|
||||
private files: FileIndexEntry[] = [];
|
||||
private dirs: Set<string> = new Set();
|
||||
private cwd: string;
|
||||
private initialized = false;
|
||||
private initializing: Promise<void> | null = null;
|
||||
|
||||
constructor(cwd: string) {
|
||||
this.cwd = cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化索引(使用 ripgrep 或 fallback 到 find)
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// 防止并发初始化
|
||||
if (this.initializing) {
|
||||
return this.initializing;
|
||||
}
|
||||
|
||||
this.initializing = this._doInitialize();
|
||||
await this.initializing;
|
||||
this.initializing = null;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
// 尝试使用 ripgrep
|
||||
const paths = await this.listFilesWithRipgrep();
|
||||
this.processFilePaths(paths);
|
||||
this.initialized = true;
|
||||
} catch {
|
||||
// ripgrep 不可用,使用 find 命令 (macOS/Linux) 或 fallback
|
||||
try {
|
||||
const paths = await this.listFilesWithFind();
|
||||
this.processFilePaths(paths);
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize file index:', error);
|
||||
this.files = [];
|
||||
this.dirs = new Set();
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private processFilePaths(paths: string[]): void {
|
||||
this.files = [];
|
||||
this.dirs = new Set();
|
||||
|
||||
for (const path of paths) {
|
||||
if (!path) continue;
|
||||
|
||||
const name = path.split('/').pop() || path;
|
||||
const ext = name.includes('.') ? name.split('.').pop() : undefined;
|
||||
|
||||
this.files.push({
|
||||
path,
|
||||
name,
|
||||
type: 'file',
|
||||
extension: ext,
|
||||
});
|
||||
|
||||
// 收集目录
|
||||
const parts = path.split('/');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const dir = parts.slice(0, i).join('/');
|
||||
if (dir) this.dirs.add(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private listFilesWithRipgrep(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'--files',
|
||||
'--follow',
|
||||
'--hidden',
|
||||
'--glob=!.git/*',
|
||||
'--glob=!node_modules/*',
|
||||
'--glob=!.next/*',
|
||||
'--glob=!dist/*',
|
||||
'--glob=!build/*',
|
||||
'--glob=!coverage/*',
|
||||
'--glob=!.turbo/*',
|
||||
'--glob=!*.lock',
|
||||
'--glob=!package-lock.json',
|
||||
];
|
||||
|
||||
const proc = spawn('rg', args, {
|
||||
cwd: this.cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 || code === 1) {
|
||||
// ripgrep returns 1 if no matches, which is fine
|
||||
const paths = output.trim().split('\n').filter(Boolean);
|
||||
resolve(paths);
|
||||
} else {
|
||||
reject(new Error(`ripgrep failed: ${error}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private listFilesWithFind(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'.',
|
||||
'-type', 'f',
|
||||
'-not', '-path', '*/.git/*',
|
||||
'-not', '-path', '*/node_modules/*',
|
||||
'-not', '-path', '*/.next/*',
|
||||
'-not', '-path', '*/dist/*',
|
||||
'-not', '-path', '*/build/*',
|
||||
'-not', '-path', '*/coverage/*',
|
||||
'-not', '-name', '*.lock',
|
||||
'-not', '-name', 'package-lock.json',
|
||||
];
|
||||
|
||||
const proc = spawn('find', args, {
|
||||
cwd: this.cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
const paths = output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((p) => p.replace(/^\.\//, '')); // 移除开头的 ./
|
||||
resolve(paths);
|
||||
} else {
|
||||
reject(new Error(`find failed: ${error}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 模糊搜索文件
|
||||
*/
|
||||
async search(options: FileSearchOptions): Promise<FileIndexEntry[]> {
|
||||
await this.initialize();
|
||||
|
||||
const { query, limit = 10, type = 'file' } = options;
|
||||
|
||||
// 构建搜索列表
|
||||
let items: FileIndexEntry[];
|
||||
|
||||
if (type === 'directory') {
|
||||
items = Array.from(this.dirs).map((d) => ({
|
||||
path: d,
|
||||
name: d.split('/').pop()!,
|
||||
type: 'directory' as const,
|
||||
}));
|
||||
} else if (type === 'all') {
|
||||
const dirItems = Array.from(this.dirs).map((d) => ({
|
||||
path: d,
|
||||
name: d.split('/').pop()!,
|
||||
type: 'directory' as const,
|
||||
}));
|
||||
items = [...this.files, ...dirItems];
|
||||
} else {
|
||||
items = this.files;
|
||||
}
|
||||
|
||||
// 如果没有查询,返回前 N 个
|
||||
if (!query) {
|
||||
return items.slice(0, limit);
|
||||
}
|
||||
|
||||
// 模糊搜索
|
||||
const results = fuzzysort.go(query, items, {
|
||||
key: 'path',
|
||||
limit,
|
||||
threshold: -10000,
|
||||
});
|
||||
|
||||
return results.map((r) => r.obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新索引
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
this.initialized = false;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取索引统计
|
||||
*/
|
||||
getStats(): { files: number; directories: number } {
|
||||
return {
|
||||
files: this.files.length,
|
||||
directories: this.dirs.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 索引实例缓存
|
||||
const indexCache = new Map<string, FileIndex>();
|
||||
|
||||
/**
|
||||
* 获取文件索引实例
|
||||
*/
|
||||
export function getFileIndex(cwd: string): FileIndex {
|
||||
let index = indexCache.get(cwd);
|
||||
if (!index) {
|
||||
index = new FileIndex(cwd);
|
||||
indexCache.set(cwd, index);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索文件(便捷函数)
|
||||
*/
|
||||
export async function searchFiles(options: FileSearchOptions & { cwd: string }): Promise<FileIndexEntry[]> {
|
||||
const { cwd, ...searchOptions } = options;
|
||||
const index = getFileIndex(cwd);
|
||||
return index.search(searchOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新文件索引
|
||||
*/
|
||||
export async function refreshFileIndex(cwd: string): Promise<void> {
|
||||
const index = getFileIndex(cwd);
|
||||
await index.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取索引统计
|
||||
*/
|
||||
export async function getFileIndexStats(cwd: string): Promise<{ files: number; directories: number }> {
|
||||
const index = getFileIndex(cwd);
|
||||
await index.initialize();
|
||||
return index.getStats();
|
||||
}
|
||||
@@ -226,3 +226,16 @@ export type {
|
||||
ProviderListItem,
|
||||
ProviderDetail,
|
||||
} from './provider/index.js';
|
||||
|
||||
// File Index
|
||||
export {
|
||||
getFileIndex,
|
||||
searchFiles,
|
||||
refreshFileIndex,
|
||||
getFileIndexStats,
|
||||
} from './file-index/index.js';
|
||||
|
||||
export type {
|
||||
FileIndexEntry,
|
||||
FileSearchOptions,
|
||||
} from './file-index/index.js';
|
||||
|
||||
Reference in New Issue
Block a user