Files

404 lines
15 KiB
Markdown
Raw Permalink Normal View History

2026-06-19 14:04:25 +08:00
# AI Code Diff Preview — 架构文档
2026-06-19 14:04:25 +08:00
> **版本 0.2.0** | VSCode 插件,为 Claude Code 提供类 Cursor 的 AI 代码变更审查体验
2026-06-19 14:04:25 +08:00
## 一、功能概述
- **自动收集**:监听 Claude 的 Edit/Write 操作,静默收集所有文件变更
2026-06-19 14:04:25 +08:00
- **QuickPick 总览**:Stop 后弹出文件列表,含 Accept All / Reject All
- **内置 Diff**:点击文件打开 VSCode 原生 Diff 编辑器,状态栏显示 Accept/Reject 按钮
- **粒度控制**:逐编辑 / 逐文件 / 全局 Accept/Reject
- **编辑合并**:同文件连续编辑(前次未处理时)自动合并为一个 FileChange;已处理后重新编辑则独立追踪
- **自动推进**:Accept/Reject 后自动打开下一个 pending 文件
---
## 二、项目结构
```
src/
2026-06-19 14:04:25 +08:00
├── extension.ts # 插件入口: 初始化 + 命令注册 + 文件监听
├── trigger/
2026-06-19 14:04:25 +08:00
│ └── hookHandler.ts # Hook 事件处理 (Edit → 收集, Stop → QuickPick)
├── snapshot/
2026-06-19 14:04:25 +08:00
│ └── snapshotManager.ts # ★ 核心数据层: ChangeSetManager
├── diff/
2026-06-19 14:04:25 +08:00
│ └── diffEngine.ts # Diff 算法 (遗留模块)
├── render/
2026-06-19 14:04:25 +08:00
│ ├── diffViewer.ts # ★ VSCode 内置 Diff + Accept/Reject + 自动推进
│ ├── statusBar.ts # ★ 状态栏 (常驻摘要 + Diff 模式 Accept/Reject)
│ ├── reviewPanel.ts # Webview 总览面板 (备用, 非主流程)
│ ├── codeLensProvider.ts # CodeLens (编辑器行内 Accept/Reject)
│ └── inlineDecorator.ts # 内联装饰器 (红绿高亮)
├── transaction/
2026-06-19 14:04:25 +08:00
│ ├── acceptHandler.ts # Accept 处理器 (legacy)
│ └── rejectHandler.ts # Reject 处理器 (legacy)
└── models/
2026-06-19 14:04:25 +08:00
├── types.ts # 类型定义
└── constants.ts # 常量 (命令 ID, 配置键, 颜色)
```
2026-06-19 14:04:25 +08:00
---
## 三、核心数据模型
### 层级关系
```
ChangeSet (一轮对话)
└── FileChange[] (每个文件一个)
2026-06-19 14:04:25 +08:00
├── originalContent (首次编辑前的文件快照)
├── latestContent (最新文件内容)
├── diffLines[] (originalContent → latestContent 的聚合 Diff)
├── type (create | modify | delete)
├── status (pending | accepted | rejected)
└── EditRecord[] (每次 Edit/Write 一条, 不合并)
├── beforeContent (本次编辑前快照)
├── afterContent (本次编辑后快照)
2026-06-19 14:04:25 +08:00
├── oldString (Edit: 被替换文本; Write: 空)
├── newString (Edit: 替换后文本; Write: 完整内容)
├── diffLines[] (本次编辑的独立 Diff)
└── status (pending | accepted | rejected)
```
### 编辑合并策略
```
同文件多次 Edit 时:
┌─ 已有 pending FileChange? ── YES ──→ ★ 合并: 追加 EditRecord, 更新聚合 Diff
│
└─ NO (首次 / 前轮已 accept/reject)
└──→ 创建新 FileChange, 独立追踪
```
2026-06-19 14:04:25 +08:00
**关键**: 查找 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 工具调用"]
2026-06-19 14:04:25 +08:00
B --> C["PostToolUse Hook<br/>写入 .claude/hooks/pending.json"]
C --> D["FileSystemWatcher 监听到"]
2026-06-19 14:04:25 +08:00
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"]
2026-06-19 14:04:25 +08:00
J --> L{"autoShowDiffPerEdit?"}
K --> L
2026-06-19 14:04:25 +08:00
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["无操作"]
2026-06-19 14:04:25 +08:00
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`)
常驻显示,双区域布局:
2026-06-19 14:04:25 +08:00
```
┌─────────────────────────────────────────────────────────────┐
│ [$(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`)
2026-06-19 14:04:25 +08:00
触发时机:QuickPick 点击文件 / `Alt+↑↓` 导航 / `autoShowDiffPerEdit` 自动弹出
2026-06-19 14:04:25 +08:00
**临时文件方案**:
2026-06-19 14:04:25 +08:00
- 路径:`%TEMP%/ai-diff-preview/{uuid}-{before|after}-{id}.{ext}`
- 创建:`showEditDiff()` / `showFileDiff()` 时写入
- 删除:Accept/Reject 时 `deleteSessionFiles()` 主动清理
- 批量清理:`deactivate()` 时 `cleanupTmpFiles()`
2026-06-19 14:04:25 +08:00
**Session 管理**:
```typescript
interface DiffSession {
fileId: string; // FileChange.id
editId?: string; // EditRecord.id (单次编辑模式)
beforeTmpPath: string;
afterTmpPath: string;
beforeUri: vscode.Uri;
afterUri: vscode.Uri;
}
```
2026-06-19 14:04:25 +08:00
- **isOpening 锁**:Diff 打开过程中禁止 `onDidChangeVisibleTextEditors` 误清 session
- **关闭检测**:before/after URI 都不可见时自动清除 session
**自动推进** `advanceToNext()`:
1. 当前文件还有 pending 编辑 → 重新打开该文件 Diff
2. 否则 `findNextPending()` → 打开下一个文件 Diff
3. 无更多 pending → "🎉 所有变更已处理完毕!"
2026-06-19 14:04:25 +08:00
### 5.3 QuickPick 文件列表 (`hookHandler.showFileListPicker()`)
2026-06-19 14:04:25 +08:00
Stop 后的主 UI(当 `showAllDiffsOnStop = true`):
```
2026-06-19 14:04:25 +08:00
┌──────────────────────────────────────────────────┐
│ 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 — 拒绝所有变更 │
└──────────────────────────────────────────────────┘
```
2026-06-19 14:04:25 +08:00
- 点击文件 → `diffViewer.showFileDiff()` 打开内置 Diff
- 点击 Accept All / Reject All → 批量操作
2026-06-19 14:04:25 +08:00
### 5.4 Webview 总览面板 (`reviewPanel.ts`)
2026-06-19 14:04:25 +08:00
**备用方案**,非主流程。提供卡片式双栏 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`
2026-06-19 14:04:25 +08:00
### 5.6 内联装饰器 (`inlineDecorator.ts`)
2026-06-19 14:04:25 +08:00
在编辑器中渲染红绿高亮:
2026-06-19 14:04:25 +08:00
- 绿色背景 + gutter 圆点:新增行
- 红色背景 + 删除线:删除行
- 行末 `← AI 变更` 标签
---
## 六、Accept/Reject 行为
2026-06-19 14:04:25 +08:00
| 操作 | 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 |
2026-06-19 14:04:25 +08:00
---
## 七、触发机制
### 文件监听
```
.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 }]
}]
}
}
```
---
## 八、配置项
| 配置 | 类型 | 默认 | 说明 |
2026-06-19 14:04:25 +08:00
| ---- | ---- | ---- | ---- |
| `enableAutoTrigger` | boolean | true | 监听 pending.json |
| `autoShowDiffPerEdit` | boolean | false | 每次 Edit 后弹内置 Diff |
2026-06-19 14:04:25 +08:00
| `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 | 仅状态栏显示,用户手动触发 |
2026-06-19 14:04:25 +08:00
---
## 九、快捷键
| 快捷键 | 条件 | 功能 |
2026-06-19 14:04:25 +08:00
| ------ | ---- | ---- |
| `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 |
---
2026-06-19 14:04:25 +08:00
## 十、模块依赖关系
```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/>状态栏"]
2026-06-19 14:04:25 +08:00
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
2026-06-19 14:04:25 +08:00
extension --> review
extension --> lens
extension --> deco
hook --> manager
hook --> viewer
viewer --> manager
bar --> manager
bar --> viewer
2026-06-19 14:04:25 +08:00
review --> manager
review --> viewer
lens --> manager
manager --> types
```
2026-06-19 14:04:25 +08:00
---
## 十一、已修复的 Bug
| Bug | 根因 | 修复 |
2026-06-19 14:04:25 +08:00
| --- | ---- | ---- |
| Accept 后同文件再编辑无 Accept/Reject | `recordEdit()` 更新同一 FileChange,Accept 后状态机断开 | 引入 EditRecord 粒度,Accept 后再编辑创建新 FileChange |
| Accept All / Reject All 不生效 | `acceptAllFromDiff()` 调用不存在的方法 | 改为调用 `acceptFile()`,收集列表后逐一处理 |
| Accept 后无后续 Diff | 无推进逻辑 | `advanceToNext()` 自动打开下一个 pending 文件 |
2026-06-19 14:04:25 +08:00
| 临时文件保存提示 | 使用 `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