Files
ai-diff-preview/docs/ARCHITECTURE.md
T

15 KiB
Raw Blame History

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 避免重复捕获

四、完整流程

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():

  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 配置

{
  "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