# 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
写入 .claude/hooks/pending.json"] C --> D["FileSystemWatcher 监听到"] D --> E["读取并删除 pending.json
10s 时效校验"] E --> F{"toolName?"} F -->|"Edit / Write"| G["hookHandler.handleEdit()"] G --> H["ChangeSetManager.recordEdit()"] H --> I{"同文件已有
pending FileChange?"} I -->|"YES"| J["★ 合并: 追加 EditRecord
更新聚合 Diff"] I -->|"NO"| K["新建 FileChange
捕获 originalContent"] J --> L{"autoShowDiffPerEdit?"} K --> L L -->|"true"| M["diffViewer.showEditDiff()
弹出 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 文件列表
hookHandler.showFileListPicker()"] S -->|"false"| U["状态栏通知
'N 个文件有变更'"] T --> V["QuickPick 列表:
📄 file1.ts +3 -2 · 2编辑
📄 file2.ts +10 -0 · 1编辑
──────────────
✅ Accept All
❌ Reject All"] V --> W["点击文件"] V --> X["点击 Accept All"] V --> Y["点击 Reject All"] W --> Z["diffViewer.showFileDiff()
打开 VSCode 内置 Diff"] Z --> AA["状态栏右侧:
$(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
入口"] hook["hookHandler.ts
Hook 处理"] manager["snapshotManager.ts
ChangeSetManager
★ 核心数据层"] viewer["diffViewer.ts
内置 Diff + 自动推进"] bar["statusBar.ts
状态栏"] review["reviewPanel.ts
Webview (备用)"] lens["codeLensProvider.ts
CodeLens"] deco["inlineDecorator.ts
装饰器"] types["models/types.ts
类型"] 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