Skip to content

Commit e5cae2f

Browse files
authored
feat(at): ignore directory @mentions (#701)
* feat(at): ignore directory @mentions
1 parent 592a6a3 commit e5cae2f

File tree

3 files changed

+306
-0
lines changed

3 files changed

+306
-0
lines changed
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# 忽略目录 @ 引用
2+
3+
**Date:** 2026-01-21
4+
5+
## Context
6+
7+
在当前的 `src/at.ts` 实现中,当用户使用 `@目录` 语法时,系统会递归遍历该目录下的所有文件,并将所有文件的内容注入到 prompt 中。这种行为会导致:
8+
9+
1. **性能问题**: 大目录会导致大量文件被读取,消耗时间和资源
10+
2. **token 浪费**: 用户可能并不需要目录下的所有文件内容
11+
3. **用户体验差**: 用户无法预期会有多少内容被注入
12+
13+
用户希望当使用 `@目录` 时,系统完全不处理该路径,保持静默(不添加任何内容,也不显示警告)。
14+
15+
## Discussion
16+
17+
### 关键问题与决策
18+
19+
**Q1: 当用户使用 `@目录` 时,希望如何处理?**
20+
-**选择**: 保持现状不做任何处理(完全忽略)
21+
- ❌ 只显示目录结构树,不包含任何文件内容
22+
- ❌ 显示目录结构树,但允许用户手动选择要读取的文件
23+
- ❌ 只读取目录下的第一层文件,不递归子目录
24+
- ❌ 根据文件类型或规则智能过滤
25+
26+
**Q2: 当识别到 @ 后面是目录时,具体应该怎么做?**
27+
-**选择**: 完全忽略 `@目录`,不添加任何内容
28+
- ❌ 显示警告提示,告知用户目录不支持
29+
- ❌ 显示目录结构树,但不读取文件内容
30+
31+
### 探索的方案
32+
33+
#### 方案 1: 早期过滤 (✅ 最终选择)
34+
`extractAtPaths()` 阶段就检测并过滤掉目录路径,只保留文件路径。
35+
36+
**优点:**
37+
- 性能最优,避免后续不必要的目录遍历和文件系统操作
38+
- 代码改动最小,逻辑最清晰
39+
- 对用户完全透明,不会有任何输出
40+
41+
**缺点:**
42+
- 用户可能不知道为什么 `@目录` 没有效果(静默失败)
43+
44+
**复杂度:**
45+
46+
#### 方案 2: 中期过滤
47+
`getContent()` 方法中分类文件和目录时,直接丢弃目录分支,不调用 `getAllFilesInDirectory()`
48+
49+
**优点:**
50+
- 逻辑清晰,在分类阶段就明确处理策略
51+
- 仍然保持较好的性能
52+
53+
**缺点:**
54+
- 相比方案1有额外的文件系统检查(`statSync`)
55+
- 代码改动稍多一些
56+
57+
#### 方案 3: 添加验证提示
58+
在识别到目录时,返回一个友好的提示信息,告知用户目录不被支持。
59+
60+
**优点:**
61+
- 用户体验更好,明确知道为什么没有内容
62+
- 可以提供使用建议
63+
64+
**缺点:**
65+
- 会在 prompt 中添加内容(虽然是提示信息)
66+
- 与"完全不处理"的需求不完全一致
67+
68+
## Approach
69+
70+
采用**方案 1: 早期过滤**策略。
71+
72+
**核心思路:**
73+
`extractAtPaths()` 方法中,当解析完 `@路径` 后,立即进行文件系统检查。如果检测到路径指向目录,则直接跳过该路径,不将其加入返回的 `AtPath[]` 数组。
74+
75+
**数据流变化:**
76+
77+
```
78+
当前流程:
79+
用户输入 @目录
80+
→ extractAtPaths 提取所有路径
81+
→ getContent 分类文件/目录
82+
→ 目录被遍历,所有文件被注入
83+
84+
新流程:
85+
用户输入 @目录
86+
→ extractAtPaths 提取路径时检测到是目录
87+
→ 直接跳过,不加入返回数组
88+
→ getContent 收到空数组或只有文件的数组
89+
→ 目录完全不被处理
90+
```
91+
92+
**预期效果:**
93+
- `@文件` 继续正常工作
94+
- `@目录` 被完全忽略,不产生任何输出
95+
- 混合使用时(如 `@file1 @dir1 @file2`),只处理文件部分
96+
97+
## Architecture
98+
99+
### 修改点
100+
101+
**核心修改位置:** `src/at.ts` 中的 `extractAtPaths()` 方法
102+
103+
### 实现细节
104+
105+
1. **在解析路径后立即添加目录检查:**
106+
- 将相对路径转换为绝对路径: `path.resolve(this.cwd, filePath)`
107+
- 检查路径是否存在: `fs.existsSync(absolutePath)`
108+
- 如果存在,检查是否为目录: `fs.statSync(absolutePath).isDirectory()`
109+
- 如果是目录,执行 `continue` 跳过本次循环
110+
111+
2. **代码插入位置:**
112+
-`extractAtPaths()` 方法的 while 循环体内
113+
- 在解析完 `filePath``lineRange` 之后
114+
- 在将路径添加到 `pathsMap` 之前
115+
116+
3. **伪代码示例:**
117+
```typescript
118+
// 在 extractAtPaths() 方法中
119+
while (match !== null) {
120+
// ... 解析 filePath 和 lineRange ...
121+
122+
// 🆕 添加目录检查
123+
try {
124+
const absolutePath = path.resolve(this.cwd, filePath);
125+
if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isDirectory()) {
126+
match = regex.exec(prompt);
127+
continue; // 跳过目录
128+
}
129+
} catch {
130+
// 权限问题等异常,保持原逻辑,让后续处理
131+
}
132+
133+
// 创建 key 并添加到 pathsMap
134+
const key = `${filePath}:${lineRange ? ... : 'all'}`;
135+
pathsMap.set(key, { path: filePath, lineRange });
136+
137+
match = regex.exec(prompt);
138+
}
139+
```
140+
141+
### 错误处理
142+
143+
1. **文件系统访问异常:**
144+
-`fs.existsSync()``fs.statSync()` 因权限问题抛出异常时
145+
- 采用保守策略: 捕获异常后,仍然将路径加入 `pathsMap`
146+
- 理由: 让后续的 `getContent()` 方法处理,保持错误处理的一致性
147+
148+
2. **符号链接处理:**
149+
- `fs.statSync()` 会跟随符号链接,返回目标的状态
150+
- 如果符号链接指向目录,会被正确过滤
151+
- 如果符号链接损坏,会抛出异常,按上述策略处理
152+
153+
### 边界场景
154+
155+
| 场景 | 当前行为 | 新行为 | 说明 |
156+
|------|---------|--------|------|
157+
| `@src/` | 遍历所有文件并注入 | 完全忽略 | ✅ 符合需求 |
158+
| `@src/index.ts` | 读取文件内容 | 读取文件内容 | ✅ 保持不变 |
159+
| `@不存在的路径` | 后续报错 | 后续报错 | ✅ 保持不变 |
160+
| `@src/:10-20` | 目录+行号(无意义) | 完全忽略 | ✅ 合理忽略 |
161+
| `@file1 @dir1 @file2` | 3个都处理 | 只处理 file1 和 file2 | ✅ 符合需求 |
162+
163+
### 性能影响
164+
165+
- **增加的开销**: 每个 `@路径` 会增加 1-2 次文件系统调用
166+
- **减少的开销**: 避免了目录遍历,尤其是大目录时性能提升显著
167+
- **净影响**: 对于纯文件场景,性能影响可忽略不计;对于包含目录的场景,性能显著提升
168+
169+
### 测试策略
170+
171+
1. **基础功能测试:**
172+
-`@文件路径` 仍然正常工作
173+
-`@目录路径` 被完全忽略
174+
-`@文件:10-20` 带行号的文件正常工作
175+
-`@目录:10` 带行号的目录被忽略
176+
177+
2. **混合场景测试:**
178+
```
179+
输入: "@src/index.ts @src/ @README.md"
180+
预期: 只注入 index.ts 和 README.md 的内容,src/ 被忽略
181+
```
182+
183+
3. **边界测试:**
184+
- 空目录的处理
185+
- 符号链接指向目录的处理
186+
- 没有权限访问的路径处理
187+
- 不存在的路径(保持原有错误提示)
188+
189+
4. **回归测试:**
190+
- 确保原有的文件处理逻辑完全不受影响
191+
- 确保 `renderFilesToXml()` 方法仍然正常工作
192+
- 确保行号范围功能不受影响
193+
194+
### 影响范围
195+
196+
- **修改文件**: 仅 `src/at.ts`
197+
- **修改方法**: 仅 `extractAtPaths()` 方法
198+
- **代码行数**: 约 5-10 行
199+
- **向后兼容**: 完全兼容,所有现有文件处理功能不受影响
200+
- **不需要修改的部分**:
201+
- `getContent()` 方法
202+
- `getAllFilesInDirectory()` 方法(将不会被调用)
203+
- `renderDirectoriesToTree()` 方法(将不会被调用)
204+
- `renderFilesToXml()` 方法

src/at.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import fs from 'fs';
2+
import os from 'os';
3+
import path from 'pathe';
4+
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
5+
import { At } from './at';
6+
7+
describe('At class - directory filtering', () => {
8+
let testDir: string;
9+
let testFile: string;
10+
let testSubDir: string;
11+
12+
beforeAll(() => {
13+
// Create a temporary test directory structure
14+
testDir = path.join(os.tmpdir(), `at-test-${Date.now()}`);
15+
fs.mkdirSync(testDir, { recursive: true });
16+
17+
// Create a test file
18+
testFile = path.join(testDir, 'test.txt');
19+
fs.writeFileSync(testFile, 'Test file content');
20+
21+
// Create a subdirectory
22+
testSubDir = path.join(testDir, 'subdir');
23+
fs.mkdirSync(testSubDir);
24+
25+
// Create a file in subdirectory
26+
const subFile = path.join(testSubDir, 'sub.txt');
27+
fs.writeFileSync(subFile, 'Subdirectory file');
28+
});
29+
30+
afterAll(() => {
31+
// Clean up
32+
if (fs.existsSync(testDir)) {
33+
fs.rmSync(testDir, { recursive: true, force: true });
34+
}
35+
});
36+
37+
test('should process file @mention normally', () => {
38+
const at = new At({
39+
userPrompt: `@${path.relative(testDir, testFile)}`,
40+
cwd: testDir,
41+
});
42+
43+
const result = at.getContent();
44+
45+
expect(result).toBeTruthy();
46+
expect(result).toContain('test.txt');
47+
expect(result).toContain('Test file content');
48+
});
49+
50+
test('should ignore directory @mention completely', () => {
51+
const at = new At({
52+
userPrompt: `@${path.relative(testDir, testSubDir)}`,
53+
cwd: testDir,
54+
});
55+
56+
const result = at.getContent();
57+
58+
expect(result).toBeNull();
59+
});
60+
61+
test('should process mixed file and directory mentions', () => {
62+
const at = new At({
63+
userPrompt: `@${path.relative(testDir, testFile)} @${path.relative(testDir, testSubDir)}`,
64+
cwd: testDir,
65+
});
66+
67+
const result = at.getContent();
68+
69+
// Should only contain the file content
70+
expect(result).toBeTruthy();
71+
expect(result).toContain('test.txt');
72+
expect(result).toContain('Test file content');
73+
// Should NOT contain subdirectory files
74+
expect(result).not.toContain('Subdirectory file');
75+
});
76+
77+
test('should ignore directory with line range syntax', () => {
78+
const at = new At({
79+
userPrompt: `@${path.relative(testDir, testSubDir)}:1-10`,
80+
cwd: testDir,
81+
});
82+
83+
const result = at.getContent();
84+
85+
expect(result).toBeNull();
86+
});
87+
});

src/at.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,21 @@ export class At {
8484
filePath = filePath.replace(/\\ /g, ' ');
8585
}
8686

87+
// Skip directories - only process files
88+
try {
89+
const absolutePath = path.resolve(this.cwd, filePath);
90+
if (
91+
fs.existsSync(absolutePath) &&
92+
fs.statSync(absolutePath).isDirectory()
93+
) {
94+
match = regex.exec(prompt);
95+
continue;
96+
}
97+
} catch {
98+
// If we can't check (e.g., permissions), let it through
99+
// and handle the error later in getContent()
100+
}
101+
87102
// Parse line range if present
88103
let lineRange: { start: number; end?: number } | undefined;
89104
if (groups.lineRange) {

0 commit comments

Comments
 (0)