Files

404 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AI Code Diff Preview — 架构文档
> **版本 0.2.0** | VSCode 插件,为 Claude Code 提供类 Cursor 的 AI 代码变更审查体验
## 一、功能概述
- **自动收集**:监听 Claude 的 Edit/Write 操作,静默收集所有文件变更
- **QuickPick 总览**:Stop 后弹出文件列表,含 Accept All / Reject All
- **内置 Diff**:点击文件打开 VSCode 原生 Diff 编辑器,状态栏显示 Accept/Reject 按钮
- **粒度控制**:逐编辑 / 逐文件 / 全局 Accept/Reject
- **编辑合并**:同文件连续编辑(前次未处理时)自动合并为一个 FileChange;已处理后重新编辑则独立追踪
- **自动推进**:Accept/Reject 后自动打开下一个 pending 文件
---
## 二、项目结构
```
src/
├── extension.ts # 插件入口: 初始化 + 命令注册 + 文件监听
├── trigger/
│ └── hookHandler.ts # Hook 事件处理 (Edit → 收集, Stop → QuickPick)
├── snapshot/
│ └── snapshotManager.ts # ★ 核心数据层: ChangeSetManager
├── diff/
│ └── diffEngine.ts # Diff 算法 (遗留模块)
├── render/
│ ├── diffViewer.ts # ★ VSCode 内置 Diff + Accept/Reject + 自动推进
│ ├── statusBar.ts # ★ 状态栏 (常驻摘要 + Diff 模式 Accept/Reject)
│ ├── reviewPanel.ts # Webview 总览面板 (备用, 非主流程)
│ ├── codeLensProvider.ts # CodeLens (编辑器行内 Accept/Reject)
│ └── inlineDecorator.ts # 内联装饰器 (红绿高亮)
├── transaction/
│ ├── acceptHandler.ts # Accept 处理器 (legacy)
│ └── rejectHandler.ts # Reject 处理器 (legacy)
└── models/
├── types.ts # 类型定义
└── constants.ts # 常量 (命令 ID, 配置键, 颜色)
```
---
## 三、核心数据模型
### 层级关系
```
ChangeSet (一轮对话)
└── FileChange[] (每个文件一个)
├── originalContent (首次编辑前的文件快照)
├── latestContent (最新文件内容)
├── diffLines[] (originalContent → latestContent 的聚合 Diff)
├── type (create | modify | delete)
├── status (pending | accepted | rejected)
└── EditRecord[] (每次 Edit/Write 一条, 不合并)
├── beforeContent (本次编辑前快照)
├── afterContent (本次编辑后快照)
├── oldString (Edit: 被替换文本; Write: 空)
├── newString (Edit: 替换后文本; Write: 完整内容)
├── diffLines[] (本次编辑的独立 Diff)
└── status (pending | accepted | rejected)
```
### 编辑合并策略
```
同文件多次 Edit 时:
┌─ 已有 pending FileChange? ── YES ──→ ★ 合并: 追加 EditRecord, 更新聚合 Diff
│
└─ NO (首次 / 前轮已 accept/reject)
└──→ 创建新 FileChange, 独立追踪
```
**关键**: 查找 FileChange 时**优先匹配 pending 的**(`changes.find(c => c.status === 'pending')`),避免同文件多轮编辑时孤儿 FileChange 的 Bug。
### 原始内容捕获
`captureOriginalContent()` 反推机制:
- **Edit (首次)**: 从当前文件中找到 `newString`,替换回 `oldString` → 编辑前内容
- **Edit (非首次)**: 使用 `pendingChange.latestContent` 作为 `beforeContent`
- **Write**: `originalContent = ''`,类型为 `create`
- **缓存**: `originalContents` Map 避免重复捕获
---
## 四、完整流程
```mermaid
flowchart TD
A["Claude 对话中"] --> B["Edit / Write 工具调用"]
B --> C["PostToolUse Hook<br/>写入 .claude/hooks/pending.json"]
C --> D["FileSystemWatcher 监听到"]
D --> E["读取并删除 pending.json<br/>10s 时效校验"]
E --> F{"toolName?"}
F -->|"Edit / Write"| G["hookHandler.handleEdit()"]
G --> H["ChangeSetManager.recordEdit()"]
H --> I{"同文件已有<br/>pending FileChange?"}
I -->|"YES"| J["★ 合并: 追加 EditRecord<br/>更新聚合 Diff"]
I -->|"NO"| K["新建 FileChange<br/>捕获 originalContent"]
J --> L{"autoShowDiffPerEdit?"}
K --> L
L -->|"true"| M["diffViewer.showEditDiff()<br/>弹出 VSCode 内置 Diff"]
L -->|"false"| N["静默收集"]
F -->|"Stop"| O["hookHandler.handleStop()"]
O --> P["ChangeSetManager.markReady()"]
P --> Q{"有 pending 变更?"}
Q -->|"NO"| R["无操作"]
Q -->|"YES"| S{"showAllDiffsOnStop?"}
S -->|"true"| T["★ 弹出 QuickPick 文件列表<br/>hookHandler.showFileListPicker()"]
S -->|"false"| U["状态栏通知<br/>'N 个文件有变更'"]
T --> V["QuickPick 列表:<br/>📄 file1.ts +3 -2 · 2编辑<br/>📄 file2.ts +10 -0 · 1编辑<br/>──────────────<br/>✅ Accept All<br/>❌ Reject All"]
V --> W["点击文件"]
V --> X["点击 Accept All"]
V --> Y["点击 Reject All"]
W --> Z["diffViewer.showFileDiff()<br/>打开 VSCode 内置 Diff"]
Z --> AA["状态栏右侧:<br/>$(check) Accept | $(close) Reject"]
AA --> AB["用户操作 Accept/Reject"]
AB --> AC["★ 自动推进 advanceToNext()"]
AC --> AD["全部处理完 → 🎉 通知"]
X --> AE["changeSetManager.acceptAll()"]
Y --> AF["changeSetManager.rejectAll()"]
```
---
## 五、UI 层
### 5.1 状态栏 (`statusBar.ts`)
常驻显示,双区域布局:
```
┌─────────────────────────────────────────────────────────────┐
│ [$(diff) AI Diff: 2文件 5编辑] [✓ Accept] [✗ Reject]│
│ (左侧常驻摘要, 点击打开 QuickPick) (右侧, 仅 Diff 模式) │
└─────────────────────────────────────────────────────────────┘
```
| 模式 | 条件 | 左侧 | 右侧 |
| ---- | ---- | ---- | ---- |
| 空闲 | 无变更集 | `$(diff) AI Diff` (暗色) | 隐藏 |
| 有 pending | 有待处理变更 | `$(diff) AI Diff: N文件 M编辑` (黄色) | 隐藏 |
| Diff 活跃 | 内置 Diff 打开 | 同上 | `Accept` (黄底) + `Reject` (红底) |
| 已完成 | 全部处理完 | `$(diff) AI Diff: 已完成` | 隐藏 |
### 5.2 VSCode 内置 Diff (`diffViewer.ts`)
触发时机:QuickPick 点击文件 / `Alt+↑↓` 导航 / `autoShowDiffPerEdit` 自动弹出
**临时文件方案**:
- 路径:`%TEMP%/ai-diff-preview/{uuid}-{before|after}-{id}.{ext}`
- 创建:`showEditDiff()` / `showFileDiff()` 时写入
- 删除:Accept/Reject 时 `deleteSessionFiles()` 主动清理
- 批量清理:`deactivate()` 时 `cleanupTmpFiles()`
**Session 管理**:
```typescript
interface DiffSession {
fileId: string; // FileChange.id
editId?: string; // EditRecord.id (单次编辑模式)
beforeTmpPath: string;
afterTmpPath: string;
beforeUri: vscode.Uri;
afterUri: vscode.Uri;
}
```
- **isOpening 锁**:Diff 打开过程中禁止 `onDidChangeVisibleTextEditors` 误清 session
- **关闭检测**:before/after URI 都不可见时自动清除 session
**自动推进** `advanceToNext()`:
1. 当前文件还有 pending 编辑 → 重新打开该文件 Diff
2. 否则 `findNextPending()` → 打开下一个文件 Diff
3. 无更多 pending → "🎉 所有变更已处理完毕!"
### 5.3 QuickPick 文件列表 (`hookHandler.showFileListPicker()`)
Stop 后的主 UI(当 `showAllDiffsOnStop = true`):
```
┌──────────────────────────────────────────────────┐
│ AI Diff 总览 — 2 文件 3 编辑 (+13 -2) │
│ │
│ $(edit) file1.ts +3 -2 · 2 编辑 │
│ $(new-file) file2.ts +10 -0 · 1 编辑 │
│ ────────────────────────────────────── │
│ $(check-all) ✅ Accept All — 接受所有变更 │
│ $(trash) ❌ Reject All — 拒绝所有变更 │
└──────────────────────────────────────────────────┘
```
- 点击文件 → `diffViewer.showFileDiff()` 打开内置 Diff
- 点击 Accept All / Reject All → 批量操作
### 5.4 Webview 总览面板 (`reviewPanel.ts`)
**备用方案**,非主流程。提供卡片式双栏 Diff 展示,功能与 QuickPick 重叠。
### 5.5 CodeLens (`codeLensProvider.ts`)
编辑器行内显示 Accept/Reject 按钮(当 `floatingLabelMode` 包含 inline 时):
- 文件标题行:`$(diff) AI Diff · N 次编辑` | `$(check-all) 接受全部` | `$(trash) 拒绝全部`
- 每个 EditRecord 变更区域:`AI 编辑 #xxxx · +3 -2` | `Accept` | `Reject`
### 5.6 内联装饰器 (`inlineDecorator.ts`)
在编辑器中渲染红绿高亮:
- 绿色背景 + gutter 圆点:新增行
- 红色背景 + 删除线:删除行
- 行末 `← AI 变更` 标签
---
## 六、Accept/Reject 行为
| 操作 | Modify 文件 | Create 文件 | Delete 文件 |
| ---- | ---------- | ---------- | ---------- |
| Accept (文件级) | 写入 `latestContent` | 保留文件 | 保留删除状态 |
| Reject (文件级) | 恢复 `originalContent` | 删除文件 | 恢复原文件 |
| Accept All | 遍历所有 pending 文件执行 Accept | | |
| Reject All | 遍历所有 pending 文件执行 Reject | | |
### 编辑级 Accept/Reject
| 操作 | 行为 |
| ---- | ---- |
| Accept Edit | 标记该 EditRecord 为 accepted;若文件所有 edit 都 accepted → 写入 `latestContent` |
| Reject Edit | 从当前文件内容中还原 `newString → oldString`;标记为 rejected |
---
## 七、触发机制
### 文件监听
```
.claude/hooks/pending.json
│
▼ (create / change 事件)
extension.ts:
1. FileSystemWatcher 监听 pending.json
2. 读取 → 删除 (防重复处理)
3. JSON.parse → ClaudeHookEvent
4. 时间戳校验: Date.now() - timestamp > 10000 → 丢弃
5. 分发: Edit/Write → handleEdit(), Stop → handleStop()
```
### 启动恢复
`checkPendingHookFile()`: activate 时检查遗留 pending 文件,15 秒内有效。
### Claude Code Hooks 配置
```json
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "node -e \"...(写入 pending.json)...\"", "timeout": 5 }]
}],
"Stop": [{
"hooks": [{ "type": "command", "command": "node -e \"...(写入 type=stop 的 pending.json)...\"", "timeout": 5 }]
}]
}
}
```
---
## 八、配置项
| 配置 | 类型 | 默认 | 说明 |
| ---- | ---- | ---- | ---- |
| `enableAutoTrigger` | boolean | true | 监听 pending.json |
| `autoShowDiffPerEdit` | boolean | false | 每次 Edit 后弹内置 Diff |
| `showAllDiffsOnStop` | boolean | true | Stop 后弹 QuickPick 文件列表 |
| `floatingLabelMode` | string | "statusBar" | inline / statusBar / both / none |
| `maxFileSize` | number | 100000 | 文件大小上限 (字符) |
### 配置组合
| autoShowDiffPerEdit | showAllDiffsOnStop | 行为 |
| :---: | :---: | ------ |
| false | true | **默认**: 编辑时静默收集,Stop 后弹 QuickPick 统一审查 |
| true | false | 每次编辑弹 Diff,Stop 后无额外操作 |
| true | true | 每次编辑弹 Diff + Stop 后再弹 QuickPick |
| false | false | 仅状态栏显示,用户手动触发 |
---
## 九、快捷键
| 快捷键 | 条件 | 功能 |
| ------ | ---- | ---- |
| `Tab` | `isActive && editorTextFocus` | Accept 当前编辑 |
| `Esc` | `isActive && editorTextFocus` | Reject 所有变更 |
| `Ctrl+Shift+A` | `isActive` | Accept All |
| `Ctrl+Shift+R` | `isActive` | Reject All |
| `Alt+↓` | `isActive` | 下一个变更文件 |
| `Alt+↑` | `isActive` | 上一个变更文件 |
| `Ctrl+Shift+D` | `isActive` | 打开 QuickPick 文件列表 |
| `Ctrl+Shift+F` | `isActive` | 查看当前文件 Diff |
---
## 十、模块依赖关系
```mermaid
flowchart LR
extension["extension.ts<br/>入口"]
hook["hookHandler.ts<br/>Hook 处理"]
manager["snapshotManager.ts<br/>ChangeSetManager<br/>★ 核心数据层"]
viewer["diffViewer.ts<br/>内置 Diff + 自动推进"]
bar["statusBar.ts<br/>状态栏"]
review["reviewPanel.ts<br/>Webview (备用)"]
lens["codeLensProvider.ts<br/>CodeLens"]
deco["inlineDecorator.ts<br/>装饰器"]
types["models/types.ts<br/>类型"]
extension --> hook
extension --> manager
extension --> viewer
extension --> bar
extension --> review
extension --> lens
extension --> deco
hook --> manager
hook --> viewer
viewer --> manager
bar --> manager
bar --> viewer
review --> manager
review --> viewer
lens --> manager
manager --> types
```
---
## 十一、已修复的 Bug
| Bug | 根因 | 修复 |
| --- | ---- | ---- |
| Accept 后同文件再编辑无 Accept/Reject | `recordEdit()` 更新同一 FileChange,Accept 后状态机断开 | 引入 EditRecord 粒度,Accept 后再编辑创建新 FileChange |
| Accept All / Reject All 不生效 | `acceptAllFromDiff()` 调用不存在的方法 | 改为调用 `acceptFile()`,收集列表后逐一处理 |
| Accept 后无后续 Diff | 无推进逻辑 | `advanceToNext()` 自动打开下一个 pending 文件 |
| 临时文件保存提示 | 使用 `openTextDocument({content})` 创建 untitled 文档 | 改为写入 `%TEMP%/ai-diff-preview/` |
| 同文件多轮编辑孤儿 FileChange | `.find()` 返回第一个 (已 accept) 而非 pending | 优先匹配 `status === 'pending'` 的 FileChange |
| Diff 打开时 session 被误清 | `onDidChangeVisibleTextEditors` 在 Diff 加载中触发 | `isOpening` 原子锁保护 |
---
## 十二、命令注册
| 命令 ID | 功能 |
| ------- | ---- |
| `aiDiffPreview.showDiffPanel` | QuickPick 文件列表总览 |
| `aiDiffPreview.showCurrentFileDiff` | 查看当前文件 Diff |
| `aiDiffPreview.acceptCurrentEdit` | 接受当前编辑 |
| `aiDiffPreview.rejectCurrentEdit` | 拒绝当前编辑 |
| `aiDiffPreview.acceptAll` | 接受所有变更 |
| `aiDiffPreview.rejectAll` | 拒绝所有变更 |
| `aiDiffPreview.nextDiff` | 下一个变更 |
| `aiDiffPreview.prevDiff` | 上一个变更 |
| `aiDiffPreview.acceptCurrentDiff` | Diff 内接受 (状态栏) |
| `aiDiffPreview.rejectCurrentDiff` | Diff 内拒绝 (状态栏) |
| `aiDiffPreview.acceptAllFromDiff` | Diff 内接受整个文件 (状态栏) |
| `aiDiffPreview.acceptChunk` | 旧版兼容 → acceptCurrentDiff |
| `aiDiffPreview.rejectChunk` | 旧版兼容 → rejectCurrentDiff |
---
## 十三、构建
```bash
npm install # 安装依赖
npm run build # esbuild → dist/extension.js (CJS, minify)
npm run watch # 监听模式 (sourcemap)
npm run test # vitest run
npm run test:watch # vitest watch
npm run lint # eslint
npm run package # vsce package → .vsix
```
- **esbuild**: `src/extension.ts → dist/extension.js`,external: vscode,CJS 格式
- **TypeScript**: ES2022,CommonJS,strict mode