Skip to content

Commit c4b5850

Browse files
author
huzijie.sea
committed
feat(文件处理): 支持 glob 模式匹配和目录树展示
添加对 @提及 中 glob 模式(*, ?, [])的支持,并优化目录展示为树形结构 重构文件处理逻辑,分离 glob 处理和目录树渲染 改进文件内容展示,增加行数统计和截断提示
1 parent f07aa25 commit c4b5850

4 files changed

Lines changed: 188 additions & 25 deletions

File tree

src/prompts/processors/AtMentionParser.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export class AtMentionParser {
2525
*/
2626
private static readonly LINE_RANGE_PATTERN = /#L(\d+)(?:-(\d+))?$/;
2727

28+
/**
29+
* Glob 通配符模式:检测 *, ?, [ 等字符
30+
*/
31+
private static readonly GLOB_PATTERN = /[*?[\]]/;
32+
2833
/**
2934
* 从用户输入中提取所有 @ 提及
3035
*
@@ -56,12 +61,16 @@ export class AtMentionParser {
5661
path = path.replace(this.LINE_RANGE_PATTERN, '');
5762
}
5863

64+
// 检测是否为 glob 模式
65+
const isGlob = this.GLOB_PATTERN.test(path);
66+
5967
mentions.push({
6068
raw,
6169
path: path.trim(),
6270
lineRange,
6371
startIndex: match.index,
6472
endIndex: match.index + raw.length,
73+
isGlob,
6574
});
6675
}
6776

src/prompts/processors/AttachmentCollector.ts

Lines changed: 176 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ export class AttachmentCollector {
9393
* 处理单个 @ 提及
9494
*/
9595
private async processOne(mention: AtMention): Promise<Attachment> {
96+
// Glob 模式处理
97+
if (mention.isGlob) {
98+
logger.debug(`Processing glob pattern: ${mention.path}`);
99+
return await this.processGlob(mention.path);
100+
}
101+
96102
// 安全验证
97103
const absolutePath = await PathSecurity.validatePath(
98104
mention.path,
@@ -104,10 +110,10 @@ export class AttachmentCollector {
104110

105111
const stats = await fs.stat(realPath);
106112

107-
// 目录处理
113+
// 目录处理 - 展示树结构
108114
if (stats.isDirectory()) {
109115
logger.debug(`Processing directory: ${mention.path}`);
110-
return await this.readDirectory(realPath, mention.path);
116+
return await this.renderDirectoryTree(realPath, mention.path);
111117
}
112118

113119
// 文件处理
@@ -224,9 +230,9 @@ export class AttachmentCollector {
224230
}
225231

226232
/**
227-
* 读取目录内容
233+
* 渲染目录树结构(不读取文件内容,仅展示结构)
228234
*/
229-
private async readDirectory(
235+
private async renderDirectoryTree(
230236
absolutePath: string,
231237
relativePath: string
232238
): Promise<Attachment> {
@@ -258,44 +264,191 @@ export class AttachmentCollector {
258264

259265
logger.debug(`Found ${files.length} files in directory: ${relativePath}`);
260266

261-
// 限制文件数量
262-
const maxFiles = 50;
267+
// 构建树形结构
268+
const tree = this.buildFileTree(files);
269+
const treeContent = this.printTree(tree, relativePath);
270+
271+
// 限制显示的文件数
272+
const maxFiles = 500;
273+
const suffix =
274+
files.length > maxFiles
275+
? `\n\n[... and ${files.length - maxFiles} more files]`
276+
: '';
277+
278+
return {
279+
type: 'directory',
280+
path: relativePath,
281+
content: treeContent + suffix,
282+
metadata: {
283+
lines: files.length,
284+
truncated: files.length > maxFiles,
285+
},
286+
};
287+
}
288+
289+
/**
290+
* 构建文件树结构
291+
*/
292+
private buildFileTree(files: string[]): Map<string, any> {
293+
const tree = new Map<string, any>();
294+
295+
for (const file of files) {
296+
const parts = file.split('/');
297+
let current = tree;
298+
299+
for (let i = 0; i < parts.length; i++) {
300+
const part = parts[i];
301+
const isFile = i === parts.length - 1;
302+
303+
if (!current.has(part)) {
304+
current.set(part, isFile ? null : new Map<string, any>());
305+
}
306+
307+
if (!isFile) {
308+
current = current.get(part);
309+
}
310+
}
311+
}
312+
313+
return tree;
314+
}
315+
316+
/**
317+
* 打印树形结构为 ASCII 格式
318+
*/
319+
private printTree(
320+
tree: Map<string, any>,
321+
rootPath: string,
322+
prefix: string = '',
323+
isLast: boolean = true
324+
): string {
325+
const lines: string[] = [];
326+
327+
// 根目录
328+
if (prefix === '') {
329+
lines.push(`${rootPath}/`);
330+
}
331+
332+
// 按名称排序,目录优先
333+
const entries = Array.from(tree.entries()).sort((a, b) => {
334+
const aIsDir = a[1] instanceof Map;
335+
const bIsDir = b[1] instanceof Map;
336+
if (aIsDir !== bIsDir) return bIsDir ? 1 : -1;
337+
return a[0].localeCompare(b[0]);
338+
});
339+
340+
entries.forEach(([name, value], index) => {
341+
const isLastEntry = index === entries.length - 1;
342+
const connector = isLastEntry ? '└── ' : '├── ';
343+
const isDir = value instanceof Map;
344+
345+
lines.push(`${prefix}${connector}${name}${isDir ? '/' : ''}`);
346+
347+
if (isDir && value.size > 0) {
348+
const newPrefix = prefix + (isLastEntry ? ' ' : '│ ');
349+
lines.push(this.printTree(value, '', newPrefix, isLastEntry));
350+
}
351+
});
352+
353+
return lines.filter((l) => l).join('\n');
354+
}
355+
356+
/**
357+
* 处理 Glob 模式
358+
*/
359+
private async processGlob(pattern: string): Promise<Attachment> {
360+
// 使用 fast-glob 展开模式
361+
const files = (await fg(pattern, {
362+
cwd: this.options.cwd,
363+
dot: false,
364+
followSymbolicLinks: false,
365+
onlyFiles: true,
366+
unique: true,
367+
ignore: [
368+
'node_modules/**',
369+
'.git/**',
370+
'dist/**',
371+
'build/**',
372+
'.next/**',
373+
'.cache/**',
374+
'coverage/**',
375+
],
376+
})) as string[];
377+
378+
if (files.length === 0) {
379+
return {
380+
type: 'error',
381+
path: pattern,
382+
content: '',
383+
error: `No files matched pattern: ${pattern}`,
384+
};
385+
}
386+
387+
logger.debug(`Glob pattern "${pattern}" matched ${files.length} files`);
388+
389+
// 限制最大文件数
390+
const maxFiles = 30;
263391
const limitedFiles = files.slice(0, maxFiles);
264392

265-
// 读取所有文件(并行)
393+
// 读取所有匹配的文件
266394
const fileContents = await Promise.allSettled(
267395
limitedFiles.map(async (file) => {
268-
const filePath = path.join(absolutePath, file);
396+
const absolutePath = path.join(this.options.cwd, file);
269397
try {
270-
const content = await fs.readFile(filePath, 'utf-8');
271-
// 限制每个文件的长度
272-
const truncated =
273-
content.length > 10000 ? content.slice(0, 10000) + '\n...' : content;
274-
return `--- ${file} ---\n${truncated}\n`;
398+
const content = await fs.readFile(absolutePath, 'utf-8');
399+
const lines = content.split('\n');
400+
401+
// 限制每个文件的行数
402+
const maxLinesPerFile = 200;
403+
let truncatedContent = content;
404+
let truncated = false;
405+
406+
if (lines.length > maxLinesPerFile) {
407+
truncatedContent = lines.slice(0, maxLinesPerFile).join('\n');
408+
truncatedContent += `\n\n[... truncated ${lines.length - maxLinesPerFile} lines ...]`;
409+
truncated = true;
410+
}
411+
412+
return {
413+
path: file,
414+
content: truncatedContent,
415+
lines: lines.length,
416+
truncated,
417+
};
275418
} catch (error) {
276-
return `--- ${file} ---\n[Error reading file: ${error instanceof Error ? error.message : 'unknown error'}]\n`;
419+
return {
420+
path: file,
421+
content: `[Error: ${error instanceof Error ? error.message : 'unknown error'}]`,
422+
lines: 0,
423+
truncated: false,
424+
};
277425
}
278426
})
279427
);
280428

281-
// 合并所有文件内容
282-
const contentParts = fileContents.map((result) =>
283-
result.status === 'fulfilled' ? result.value : '[Error]'
429+
// 组装结果
430+
const results = fileContents
431+
.map((result) => (result.status === 'fulfilled' ? result.value : null))
432+
.filter((r): r is NonNullable<typeof r> => r !== null);
433+
434+
// 格式化为多文件附件
435+
const contentParts = results.map(
436+
(r) => `--- ${r.path} (${r.lines} lines${r.truncated ? ', truncated' : ''}) ---\n${r.content}`
284437
);
285438

286-
const content = contentParts.join('\n');
439+
const content = contentParts.join('\n\n');
287440
const suffix =
288441
files.length > maxFiles
289-
? `\n\n[... ${files.length - maxFiles} more files omitted ...]`
442+
? `\n\n[... and ${files.length - maxFiles} more files matched]`
290443
: '';
291444

292445
return {
293-
type: 'directory',
294-
path: relativePath,
446+
type: 'file',
447+
path: pattern,
295448
content: content + suffix,
296449
metadata: {
297-
lines: files.length,
298-
truncated: files.length > maxFiles,
450+
lines: results.reduce((sum, r) => sum + r.lines, 0),
451+
truncated: files.length > maxFiles || results.some((r) => r.truncated),
299452
},
300453
};
301454
}

src/prompts/processors/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface AtMention {
2424
startIndex: number;
2525
/** 在输入中的结束位置 */
2626
endIndex: number;
27+
/** 是否为 glob 模式(包含 *, ?, [ 等通配符) */
28+
isGlob?: boolean;
2729
}
2830

2931
/**

src/ui/hooks/useAtCompletion.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,9 +258,8 @@ export function useAtCompletion(
258258
if (fuzzyMatch) {
259259
const fuse = new Fuse(files, {
260260
threshold: 0.4,
261-
distance: 100,
261+
ignoreLocation: true, // 支持路径任意位置匹配(长路径中的文件名也能被搜到)
262262
minMatchCharLength: 1,
263-
keys: ['$'] as any, // 搜索整个字符串
264263
});
265264

266265
const results = fuse.search(query);

0 commit comments

Comments
 (0)