mirror of
https://github.com/Gmaker689/ai-diff-preview.git
synced 2026-09-26 23:11:51 +08:00
404 lines
15 KiB
Markdown
404 lines
15 KiB
Markdown
# 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
|