Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions docs/designs/2026-01-21-ignore-directory-at-mention.md
Original file line number Diff line number Diff line change
@@ -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()` 方法
87 changes: 87 additions & 0 deletions src/at.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import fs from 'fs';
import os from 'os';
import path from 'pathe';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { At } from './at';

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();
});
});
17 changes: 16 additions & 1 deletion src/at.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,22 @@ 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
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()
}

// Parse line range if present
Expand Down
Loading