mirror of
https://github.com/Gmaker689/ai-diff-preview.git
synced 2026-09-26 23:11:51 +08:00
15 KiB
15 KiB
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 - 缓存:
originalContentsMap 避免重复捕获
四、完整流程
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 管理:
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():
- 当前文件还有 pending 编辑 → 重新打开该文件 Diff
- 否则
findNextPending()→ 打开下一个文件 Diff - 无更多 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 配置
{
"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 |
十、模块依赖关系
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 |
十三、构建
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