From 9018dcc2ae72d4277d3ce4c4fcc1105c794be32c Mon Sep 17 00:00:00 2001 From: xierenhong Date: Wed, 21 Jan 2026 11:25:39 +0800 Subject: [PATCH 1/4] feat(at): ignore directory @mentions --- .../2026-01-21-ignore-directory-at-mention.md | 204 ++++++++++++++++++ src/at.test.ts | 87 ++++++++ src/at.ts | 15 ++ 3 files changed, 306 insertions(+) create mode 100644 docs/designs/2026-01-21-ignore-directory-at-mention.md create mode 100644 src/at.test.ts diff --git a/docs/designs/2026-01-21-ignore-directory-at-mention.md b/docs/designs/2026-01-21-ignore-directory-at-mention.md new file mode 100644 index 000000000..68b49f158 --- /dev/null +++ b/docs/designs/2026-01-21-ignore-directory-at-mention.md @@ -0,0 +1,204 @@ +# 忽略目录 @ 引用 + +**Date:** 2026-01-21 + +## Context + +在当前的 `src/at.ts` 实现中,当用户使用 `@目录` 语法时,系统会递归遍历该目录下的所有文件,并将所有文件的内容注入到 prompt 中。这种行为会导致: + +1. **性能问题**: 大目录会导致大量文件被读取,消耗时间和资源 +2. **token 浪费**: 用户可能并不需要目录下的所有文件内容 +3. **用户体验差**: 用户无法预期会有多少内容被注入 + +用户希望当使用 `@目录` 时,系统完全不处理该路径,保持静默(不添加任何内容,也不显示警告)。 + +## Discussion + +### 关键问题与决策 + +**Q1: 当用户使用 `@目录` 时,希望如何处理?** +- ✅ **选择**: 保持现状不做任何处理(完全忽略) +- ❌ 只显示目录结构树,不包含任何文件内容 +- ❌ 显示目录结构树,但允许用户手动选择要读取的文件 +- ❌ 只读取目录下的第一层文件,不递归子目录 +- ❌ 根据文件类型或规则智能过滤 + +**Q2: 当识别到 @ 后面是目录时,具体应该怎么做?** +- ✅ **选择**: 完全忽略 `@目录`,不添加任何内容 +- ❌ 显示警告提示,告知用户目录不支持 +- ❌ 显示目录结构树,但不读取文件内容 + +### 探索的方案 + +#### 方案 1: 早期过滤 (✅ 最终选择) +在 `extractAtPaths()` 阶段就检测并过滤掉目录路径,只保留文件路径。 + +**优点:** +- 性能最优,避免后续不必要的目录遍历和文件系统操作 +- 代码改动最小,逻辑最清晰 +- 对用户完全透明,不会有任何输出 + +**缺点:** +- 用户可能不知道为什么 `@目录` 没有效果(静默失败) + +**复杂度:** 低 + +#### 方案 2: 中期过滤 +在 `getContent()` 方法中分类文件和目录时,直接丢弃目录分支,不调用 `getAllFilesInDirectory()`。 + +**优点:** +- 逻辑清晰,在分类阶段就明确处理策略 +- 仍然保持较好的性能 + +**缺点:** +- 相比方案1有额外的文件系统检查(`statSync`) +- 代码改动稍多一些 + +#### 方案 3: 添加验证提示 +在识别到目录时,返回一个友好的提示信息,告知用户目录不被支持。 + +**优点:** +- 用户体验更好,明确知道为什么没有内容 +- 可以提供使用建议 + +**缺点:** +- 会在 prompt 中添加内容(虽然是提示信息) +- 与"完全不处理"的需求不完全一致 + +## Approach + +采用**方案 1: 早期过滤**策略。 + +**核心思路:** +在 `extractAtPaths()` 方法中,当解析完 `@路径` 后,立即进行文件系统检查。如果检测到路径指向目录,则直接跳过该路径,不将其加入返回的 `AtPath[]` 数组。 + +**数据流变化:** + +``` +当前流程: +用户输入 @目录 +→ extractAtPaths 提取所有路径 +→ getContent 分类文件/目录 +→ 目录被遍历,所有文件被注入 + +新流程: +用户输入 @目录 +→ extractAtPaths 提取路径时检测到是目录 +→ 直接跳过,不加入返回数组 +→ getContent 收到空数组或只有文件的数组 +→ 目录完全不被处理 +``` + +**预期效果:** +- `@文件` 继续正常工作 +- `@目录` 被完全忽略,不产生任何输出 +- 混合使用时(如 `@file1 @dir1 @file2`),只处理文件部分 + +## Architecture + +### 修改点 + +**核心修改位置:** `src/at.ts` 中的 `extractAtPaths()` 方法 + +### 实现细节 + +1. **在解析路径后立即添加目录检查:** + - 将相对路径转换为绝对路径: `path.resolve(this.cwd, filePath)` + - 检查路径是否存在: `fs.existsSync(absolutePath)` + - 如果存在,检查是否为目录: `fs.statSync(absolutePath).isDirectory()` + - 如果是目录,执行 `continue` 跳过本次循环 + +2. **代码插入位置:** + - 在 `extractAtPaths()` 方法的 while 循环体内 + - 在解析完 `filePath` 和 `lineRange` 之后 + - 在将路径添加到 `pathsMap` 之前 + +3. **伪代码示例:** +```typescript +// 在 extractAtPaths() 方法中 +while (match !== null) { + // ... 解析 filePath 和 lineRange ... + + // 🆕 添加目录检查 + try { + const absolutePath = path.resolve(this.cwd, filePath); + if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isDirectory()) { + match = regex.exec(prompt); + continue; // 跳过目录 + } + } catch { + // 权限问题等异常,保持原逻辑,让后续处理 + } + + // 创建 key 并添加到 pathsMap + const key = `${filePath}:${lineRange ? ... : 'all'}`; + pathsMap.set(key, { path: filePath, lineRange }); + + match = regex.exec(prompt); +} +``` + +### 错误处理 + +1. **文件系统访问异常:** + - 当 `fs.existsSync()` 或 `fs.statSync()` 因权限问题抛出异常时 + - 采用保守策略: 捕获异常后,仍然将路径加入 `pathsMap` + - 理由: 让后续的 `getContent()` 方法处理,保持错误处理的一致性 + +2. **符号链接处理:** + - `fs.statSync()` 会跟随符号链接,返回目标的状态 + - 如果符号链接指向目录,会被正确过滤 + - 如果符号链接损坏,会抛出异常,按上述策略处理 + +### 边界场景 + +| 场景 | 当前行为 | 新行为 | 说明 | +|------|---------|--------|------| +| `@src/` | 遍历所有文件并注入 | 完全忽略 | ✅ 符合需求 | +| `@src/index.ts` | 读取文件内容 | 读取文件内容 | ✅ 保持不变 | +| `@不存在的路径` | 后续报错 | 后续报错 | ✅ 保持不变 | +| `@src/:10-20` | 目录+行号(无意义) | 完全忽略 | ✅ 合理忽略 | +| `@file1 @dir1 @file2` | 3个都处理 | 只处理 file1 和 file2 | ✅ 符合需求 | + +### 性能影响 + +- **增加的开销**: 每个 `@路径` 会增加 1-2 次文件系统调用 +- **减少的开销**: 避免了目录遍历,尤其是大目录时性能提升显著 +- **净影响**: 对于纯文件场景,性能影响可忽略不计;对于包含目录的场景,性能显著提升 + +### 测试策略 + +1. **基础功能测试:** + - ✅ `@文件路径` 仍然正常工作 + - ✅ `@目录路径` 被完全忽略 + - ✅ `@文件:10-20` 带行号的文件正常工作 + - ✅ `@目录:10` 带行号的目录被忽略 + +2. **混合场景测试:** + ``` + 输入: "@src/index.ts @src/ @README.md" + 预期: 只注入 index.ts 和 README.md 的内容,src/ 被忽略 + ``` + +3. **边界测试:** + - 空目录的处理 + - 符号链接指向目录的处理 + - 没有权限访问的路径处理 + - 不存在的路径(保持原有错误提示) + +4. **回归测试:** + - 确保原有的文件处理逻辑完全不受影响 + - 确保 `renderFilesToXml()` 方法仍然正常工作 + - 确保行号范围功能不受影响 + +### 影响范围 + +- **修改文件**: 仅 `src/at.ts` +- **修改方法**: 仅 `extractAtPaths()` 方法 +- **代码行数**: 约 5-10 行 +- **向后兼容**: 完全兼容,所有现有文件处理功能不受影响 +- **不需要修改的部分**: + - `getContent()` 方法 + - `getAllFilesInDirectory()` 方法(将不会被调用) + - `renderDirectoriesToTree()` 方法(将不会被调用) + - `renderFilesToXml()` 方法 diff --git a/src/at.test.ts b/src/at.test.ts new file mode 100644 index 000000000..ea46a0f55 --- /dev/null +++ b/src/at.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'pathe'; +import { At } from './at'; +import os from 'os'; + +describe('At class - directory filtering', () => { + let testDir: string; + let testFile: string; + let testSubDir: string; + + beforeAll(() => { + // Create a temporary test directory structure + testDir = path.join(os.tmpdir(), `at-test-${Date.now()}`); + fs.mkdirSync(testDir, { recursive: true }); + + // Create a test file + testFile = path.join(testDir, 'test.txt'); + fs.writeFileSync(testFile, 'Test file content'); + + // Create a subdirectory + testSubDir = path.join(testDir, 'subdir'); + fs.mkdirSync(testSubDir); + + // Create a file in subdirectory + const subFile = path.join(testSubDir, 'sub.txt'); + fs.writeFileSync(subFile, 'Subdirectory file'); + }); + + afterAll(() => { + // Clean up + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + test('should process file @mention normally', () => { + const at = new At({ + userPrompt: `@${path.relative(testDir, testFile)}`, + cwd: testDir, + }); + + const result = at.getContent(); + + expect(result).toBeTruthy(); + expect(result).toContain('test.txt'); + expect(result).toContain('Test file content'); + }); + + test('should ignore directory @mention completely', () => { + const at = new At({ + userPrompt: `@${path.relative(testDir, testSubDir)}`, + cwd: testDir, + }); + + const result = at.getContent(); + + expect(result).toBeNull(); + }); + + test('should process mixed file and directory mentions', () => { + const at = new At({ + userPrompt: `@${path.relative(testDir, testFile)} @${path.relative(testDir, testSubDir)}`, + cwd: testDir, + }); + + const result = at.getContent(); + + // Should only contain the file content + expect(result).toBeTruthy(); + expect(result).toContain('test.txt'); + expect(result).toContain('Test file content'); + // Should NOT contain subdirectory files + expect(result).not.toContain('Subdirectory file'); + }); + + test('should ignore directory with line range syntax', () => { + const at = new At({ + userPrompt: `@${path.relative(testDir, testSubDir)}:1-10`, + cwd: testDir, + }); + + const result = at.getContent(); + + expect(result).toBeNull(); + }); +}); diff --git a/src/at.ts b/src/at.ts index 2b4c488a8..7f4e891df 100644 --- a/src/at.ts +++ b/src/at.ts @@ -97,6 +97,21 @@ export class At { } } + // Skip directories - only process files + try { + const absolutePath = path.resolve(this.cwd, filePath); + if ( + fs.existsSync(absolutePath) && + fs.statSync(absolutePath).isDirectory() + ) { + match = regex.exec(prompt); + continue; + } + } catch { + // If we can't check (e.g., permissions), let it through + // and handle the error later in getContent() + } + // Create unique key based on path and line range const key = `${filePath}:${lineRange ? `${lineRange.start}-${lineRange.end}` : 'all'}`; if (!pathsMap.has(key)) { From 5db4704503b8cde4c621949e28e4e6e9c4afd37d Mon Sep 17 00:00:00 2001 From: xierenhong Date: Wed, 21 Jan 2026 11:34:59 +0800 Subject: [PATCH 2/4] style: sort imports in at.test.ts --- src/at.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/at.test.ts b/src/at.test.ts index ea46a0f55..00c2b7099 100644 --- a/src/at.test.ts +++ b/src/at.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, test, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; +import os from 'os'; import path from 'pathe'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { At } from './at'; -import os from 'os'; describe('At class - directory filtering', () => { let testDir: string; From bc1f51af7b3f542e8197d18a506d615e8bf3f1f3 Mon Sep 17 00:00:00 2001 From: xierenhong Date: Thu, 22 Jan 2026 22:34:10 +0800 Subject: [PATCH 3/4] fix: correct escape sequence in file path parsing --- src/at.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/at.ts b/src/at.ts index 7f4e891df..925a6ecad 100644 --- a/src/at.ts +++ b/src/at.ts @@ -81,20 +81,7 @@ export class At { let filePath = groups.quoted || groups.unquoted; // Unescape spaces for unquoted paths if (groups.unquoted) { - filePath = filePath.replace(/\\ /g, ' '); - } - - // Parse line range if present - let lineRange: { start: number; end?: number } | undefined; - if (groups.lineRange) { - const rangeMatch = groups.lineRange.match(/^(\d+)(?:-(\d+))?$/); - if (rangeMatch) { - const start = Number.parseInt(rangeMatch[1], 10); - const end = rangeMatch[2] - ? Number.parseInt(rangeMatch[2], 10) - : start; - lineRange = { start, end }; - } + filePath = filePath.replace(/\ /g, ' '); } // Skip directories - only process files @@ -112,6 +99,19 @@ export class At { // and handle the error later in getContent() } + // Parse line range if present + let lineRange: { start: number; end?: number } | undefined; + if (groups.lineRange) { + const rangeMatch = groups.lineRange.match(/^(\d+)(?:-(\d+))?$/); + if (rangeMatch) { + const start = Number.parseInt(rangeMatch[1], 10); + const end = rangeMatch[2] + ? Number.parseInt(rangeMatch[2], 10) + : start; + lineRange = { start, end }; + } + } + // Create unique key based on path and line range const key = `${filePath}:${lineRange ? `${lineRange.start}-${lineRange.end}` : 'all'}`; if (!pathsMap.has(key)) { From f420cf3ccd766d96c4d9c59904ae19b9bc23bdf8 Mon Sep 17 00:00:00 2001 From: xierenhong Date: Thu, 22 Jan 2026 22:43:20 +0800 Subject: [PATCH 4/4] fix: correct file path unescaping for spaces --- src/at.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/at.ts b/src/at.ts index 925a6ecad..ed4386ddc 100644 --- a/src/at.ts +++ b/src/at.ts @@ -81,7 +81,7 @@ export class At { let filePath = groups.quoted || groups.unquoted; // Unescape spaces for unquoted paths if (groups.unquoted) { - filePath = filePath.replace(/\ /g, ' '); + filePath = filePath.replace(/\\ /g, ' '); } // Skip directories - only process files