v0.2.0: VSCode内置Diff + QuickPick总览 + EditRecord粒度 + Bug修复

新增:
- VSCode内置Diff编辑器 (diffViewer.ts) 替代Webview,零保存提示
- EditRecord独立追踪: 每次Edit/Write不合并,支持逐次编辑Accept/Reject
- QuickPick文件列表总览,含Accept All / Reject All
- 智能Auto-Advance: Accept/Reject后自动推进,同文件多编辑智能停留
- 状态栏常驻显示  AI Diff,Diff模式下切换为操作按钮

修复:
- Accept后同文件再编辑不触发选项 (pending-priority匹配)
- Accept All / Reject All写入与状态顺序修复
- acceptFile/rejectFile改为先写入成功再标记状态
- 同文件多个FileChange孤儿编辑问题

变更:
- ChangeSetManager重构为EditRecord[] + FileChange聚合
- HookHandler精简,QuickPick移入hookHandler
- 移除InlineDecorator/CodeLens (主流程),ReviewPanel降级为备用
- package.json: publisher=YonHao Guo, repository, v0.2.0, 新配置项/快捷键

测试: 13/13通过 (7 diffEngine + 6 changeSetManager回归)
This commit is contained in:
2026-06-18 19:37:21 +08:00
parent fcda651760
commit dfa3a585c5
14 changed files with 1454 additions and 958 deletions
+20 -26
View File
@@ -1,46 +1,40 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file.
## [0.2.0] - 2026-06-18 ## [0.2.0] - 2026-06-18
### Added ### Added
- **VSCode 内置 Diff 编辑器**:不再使用自定义 Webview,直接调用 `vscode.diff` 展示差异 - VSCode 内置 Diff 编辑器 (vscode.diff) — 零 Webview 依赖
- **EditRecord 粒度**:每次 Edit/Write 创建独立 EditRecord,支持逐编辑 Accept/Reject - EditRecord 独立追踪 — 每次 Edit/Write 独立记录,不合并
- **QuickPick 文件列表**:Stop 后弹出原生 QuickPick 文件列表,点击即打开内置 Diff - QuickPick 总览 — 文件列表 + Accept All / Reject All
- **导航快捷键**:`Alt+↑/↓` 在变更文件间跳转 - 智能 Auto-Advance — Accept/Reject 后自动推进,同文件多编辑智能停留
- **配置项**:`autoShowDiffPerEdit`(每次编辑自动弹 Diff)、`showAllDiffsOnStop`(Stop 后弹文件列表)、`floatingLabelMode`(浮动标签位置) - 状态栏常驻 — $(diff) AI Diff 始终可见
- **新命令**:`showCurrentFileDiff`、`acceptCurrentEdit`、`rejectCurrentEdit`、`nextDiff`、`prevDiff` - 零保存提示 — 临时文件方案自动清理
- **新快捷键**:`Alt+↑/↓`、`Ctrl+Shift+D`、`Ctrl+Shift+F` - 新命令: showCurrentFileDiff, acceptCurrentEdit, rejectCurrentEdit, nextDiff, prevDiff
- 新快捷键: Alt+↑/↓, Ctrl+Shift+D, Ctrl+Shift+F
- 新配置: autoShowDiffPerEdit, showAllDiffsOnStop, floatingLabelMode
### Fixed ### Fixed
- **Bug Fix**: Accept 后同文件再编辑不再触发 Accept/Reject 选项,且 Diff 合并的问题 - Accept 后同文件再编辑不再触发选项 (FileChange pending-priority 匹配)
- 根因:`recordEdit()` 对同文件多次编辑更新同一个 `FileChange` 对象 - Accept All / Reject All 写入与状态顺序修复
- 修复:已 accepted/rejected 的 `FileChange` 后再次编辑时自动创建新 `FileChange`,清除 `originalContents` 缓存 - accept/reject 后自动推进到下一个待处理文件
### Changed ### Changed
- `ChangeSetManager` 重构为 `EditRecord[]` 独立记录模式 - ChangeSetManager 重构为 EditRecord 独立记录 + FileChange 聚合模式
- `FileChange` 变为聚合视图(持有 `edits: EditRecord[]`) - HookHandler 精简为纯处理逻辑
- `HookHandler` 支持 `autoShowDiffPerEdit` / `showAllDiffsOnStop` 配置分支 - ReviewPanel 降级为备用, 主流程使用 QuickPick + 内置 Diff
- `ReviewPanel` Webview 降级为次要选项(保留兼容) - 版本号升级至 0.2.0
- 版本号升级至 `0.2.0`
## [0.1.0] - 2026-06-17 ## [0.1.0] - 2026-06-17
### Added ### Added
- 项目初始化 - 项目初始化
- 核心架构搭建 - 核心架构搭建
- 快照管理器实现 - 快照管理器
- Diff 引擎实现(基于 Myers 算法) - Diff 引擎 (Myers 算法)
- Webview Review 面板
- 内联装饰器渲染 - 内联装饰器渲染
- CodeLens 提供器 - CodeLens 提供器
- Accept/Reject 事务处理 - Accept/Reject 事务处理
- Claude Hooks 集成 - Claude Hooks 集成
- 状态栏显示 - 状态栏显示
- 基础测试用例 - 基础测试用例
### Technical
- TypeScript + VSCode Extension API
- diff 库集成
- esbuild 构建流程
- Vitest 测试框架
+125 -59
View File
@@ -1,71 +1,112 @@
# AI Code Diff Preview # AI Code Diff Preview
类 Cursor 的 AI 代码差异预览 VSCode 插件,使用 **VSCode 内置 Diff 编辑器** + **浮动 Accept/Reject 标签**。 > 类 Cursor 的 AI 代码差异预览 VSCode 插件
## 功能特性 [![Version](https://img.shields.io/badge/version-0.2.0-blue)](https://github.com/YonHaoGuo/ai-diff-preview/blob/master/CHANGELOG.md)
[![VSCode](https://img.shields.io/badge/vscode-%5E1.85.0-brightgreen)](https://code.visualstudio.com/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
- ✅ **VSCode 原生 Diff 编辑器** — 使用内置 `vscode.diff`,不自定义 Webview AI Code Diff Preview 为 VSCode 注入 Cursor 风格的 Accept/Reject 差异审查体验。AI 对话中的每一次代码编辑自动收集,对话结束后弹出完整的变更文件列表。点击任意文件即可在 **VSCode 内置 Diff 编辑器**中逐文件审查,状态栏实时显示 Accept/Reject 按钮,操作完成后自动推进到下一个待处理文件。
- ✅ **EditRecord 独立追踪** — 每次 Edit/Write 独立记录,不合并
- ✅ **逐次编辑 Accept/Reject** — 细粒度控制每次 AI 修改
- ✅ **QuickPick 文件列表** — 对话结束后弹出原生文件选择器
- ✅ **快捷键导航** — `Alt+↑/↓` 跳转变更文件
- ✅ **Bug 修复** — Accept 后再编辑不再丢失追踪
- ✅ **冲突检测** — 自动检测用户手动修改导致的冲突
## 工作流程 ## 特性
``` - **VSCode 原生 Diff 编辑器** — 使用 `vscode.diff` 命令,100% 原生交互体验,无自定义 Webview
Claude Edit/Write → 收集到 ChangeSet (EditRecord 粒度) - **EditRecord 独立追踪** — 每次 `Edit`/`Write` 操作作为独立编辑记录,支持逐次编辑 Accept/Reject
├── autoShowDiffPerEdit=true → 立即弹出 VSCode 内置 Diff - **QuickPick 总览** — 对话结束后弹出原生文件列表,含 Accept All / Reject All 一键操作
└── autoShowDiffPerEdit=false → 静默收集,更新状态栏 - **智能 Auto-Advance** — Accept/Reject 后自动推进到下一个待处理文件,同文件多编辑自动停留
- **状态栏常驻** — 插件激活后状态栏始终可见 `$(diff) AI Diff`,有变更时黄底高亮
Claude Stop → 变更集就绪 - **零保存提示** — Diff 编辑器使用临时文件,关闭时自动删除,不会弹出"是否保存"对话框
├── showAllDiffsOnStop=true → QuickPick 文件列表 → 点击打开内置 Diff - **多轮对话追踪** — 支持同一轮对话中对同文件的多次编辑合并、跨轮对话的独立追踪
└── showAllDiffsOnStop=false → 仅通知,用户手动 Ctrl+Shift+D 查看
```
## 安装 ## 安装
```bash ```bash
git clone https://github.com/your-username/ai-diff-preview.git # 克隆仓库
git clone https://github.com/YonHaoGuo/ai-diff-preview.git
cd ai-diff-preview cd ai-diff-preview
# 安装依赖
npm install npm install
# 编译
npm run build npm run build
# 打包 vsix
npm run package
``` ```
或通过 VSCode 扩展市场安装(即将发布)。
## 使用方法 ## 使用方法
### 工作流程
```
┌─────────────────────────────────────────────────────────┐
│ AI 对话中(Claude Code / Cursor / Copilot) │
│ │
│ Edit/Write 工具调用 → .claude/hooks/pending.json │
│ → 插件自动收集变更 (静默) │
│ │
│ Stop/对话结束 → QuickPick 总览弹窗 │
│ ┌──────────────────────────────────────┐ │
│ │ AI Diff 总览 — 3 文件 5 编辑 │ │
│ │ │ │
│ │ 📄 src/utils.ts +3 -2 · 2次编辑 │ │
│ │ 📄 src/index.ts +9 -1 · 2次编辑 │ │
│ │ 📄 src/types.ts +5 -0 · 1次编辑 │ │
│ │ ──────────────────────────────── │ │
│ │ ✅ Accept All ❌ Reject All │ │
│ └──────────────────────────────────────┘ │
│ ↓ 点击文件 │
│ ┌──────────────────────────────────────┐ │
│ │ VSCode 内置 Diff 编辑器 │ │
│ │ ┌──── 原始代码 ──┬── 变更后 ────┐ │ │
│ │ │ ... │ ... │ │ │
│ │ └───────────────┴──────────────┘ │ │
│ │ │ │
│ │ 状态栏: [✓ Accept] [✗ Reject] │ │
│ │ [✓✓ 接受此文件全部] │ │
│ └──────────────────────────────────────┘ │
│ ↓ Accept/Reject │
│ → 自动推进到下一个文件/同文件剩余编辑 │
│ → 全部完成: 🎉 通知 │
└─────────────────────────────────────────────────────────┘
```
### 快捷键 ### 快捷键
| 快捷键 | 条件 | 功能 | | 快捷键 | 条件 | 功能 |
|--------|------|------| | ---------------- | -------------------------- | ----------------------- |
| `Alt+↓` | `aiDiffPreview.isActive` | 下一个变更文件 | | `Alt+↓` | `aiDiffPreview.isActive` | 下一个变更文件 |
| `Alt+↑` | `aiDiffPreview.isActive` | 上一个变更文件 | | `Alt+↑` | `aiDiffPreview.isActive` | 上一个变更文件 |
| `Tab` | `aiDiffPreview.isActive` | 接受当前编辑 | | `Ctrl+Shift+D` | `aiDiffPreview.isActive` | 显示 QuickPick 文件列表 |
| `Esc` | `aiDiffPreview.isActive` | 拒绝所有变更 | | `Ctrl+Shift+F` | `aiDiffPreview.isActive` | 查看当前文件 Diff |
| `Ctrl+Shift+A` | `aiDiffPreview.isActive` | Accept All | | `Ctrl+Shift+A` | `aiDiffPreview.isActive` | Accept All |
| `Ctrl+Shift+R` | `aiDiffPreview.isActive` | Reject All | | `Ctrl+Shift+R` | `aiDiffPreview.isActive` | Reject All |
| `Ctrl+Shift+D` | `aiDiffPreview.isActive` | 显示文件列表 |
| `Ctrl+Shift+F` | `aiDiffPreview.isActive` | 查看当前文件 Diff | ### 命令
| 命令 ID | 标题 |
| ------------------------------------- | ----------------------------- |
| `aiDiffPreview.showDiffPanel` | AI Diff: 显示所有变更文件列表 |
| `aiDiffPreview.showCurrentFileDiff` | AI Diff: 查看当前文件 Diff |
| `aiDiffPreview.acceptCurrentEdit` | AI Diff: 接受当前编辑 |
| `aiDiffPreview.rejectCurrentEdit` | AI Diff: 拒绝当前编辑 |
| `aiDiffPreview.acceptAll` | AI Diff: 接受所有变更 |
| `aiDiffPreview.rejectAll` | AI Diff: 拒绝所有变更 |
| `aiDiffPreview.nextDiff` | AI Diff: 下一个变更 |
| `aiDiffPreview.prevDiff` | AI Diff: 上一个变更 |
### 配置项 ### 配置项
| 配置项 | 类型 | 默认值 | 说明 | | 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------| | ------------------------------------- | ------- | --------------- | ------------------------------------------------ |
| `aiDiffPreview.enableAutoTrigger` | boolean | true | 启用自动触发 | | `aiDiffPreview.enableAutoTrigger` | boolean | `true` | 启用自动触发(监听 pending.json) |
| `aiDiffPreview.autoShowDiffPerEdit` | boolean | false | 每次编辑自动弹 Diff | | `aiDiffPreview.autoShowDiffPerEdit` | boolean | `false` | 每次 Edit/Write 后自动弹出 Diff |
| `aiDiffPreview.showAllDiffsOnStop` | boolean | true | Stop 后弹文件列表 | | `aiDiffPreview.showAllDiffsOnStop` | boolean | `true` | Stop 后自动弹出 QuickPick 总览 |
| `aiDiffPreview.floatingLabelMode` | string | "statusBar" | 浮动标签位置 | | `aiDiffPreview.floatingLabelMode` | string | `"statusBar"` | 浮动标签位置(statusBar / inline / both / none) |
| `aiDiffPreview.maxFileSize` | number | 100000 | 最大文件大小 | | `aiDiffPreview.maxFileSize` | number | `100000` | 最大处理文件大小(字符数) |
## 开发
```bash
npm run watch # 监听模式
npm run test # 运行测试 (13 tests)
npm run lint # 代码检查
npm run package # 打包 vsix
```
## 项目结构 ## 项目结构
@@ -73,25 +114,50 @@ npm run package # 打包 vsix
src/ src/
├── extension.ts # 插件入口 ├── extension.ts # 插件入口
├── trigger/ ├── trigger/
│ └── hookHandler.ts # Hook 处理器 + QuickPick 文件列表 │ └── hookHandler.ts # Hook 处理器 + QuickPick 总览
├── snapshot/ ├── snapshot/
│ └── snapshotManager.ts # 变更集管理器 (ChangeSetManager) │ └── snapshotManager.ts # 变更集管理器 (ChangeSetManager)
├── diff/ ├── diff/
│ └── diffEngine.ts # Diff 计算引擎 │ └── diffEngine.ts # Diff 计算引擎
├── render/ ├── render/
│ ├── diffViewer.ts # ★ VSCode 内置 Diff 查看器 │ ├── diffViewer.ts # VSCode 内置 Diff 查看器 + 自动推进
│ ├── reviewPanel.ts # Webview 面板 (次要) │ ├── reviewPanel.ts # Webview 面板 (实验性/备用)
│ ├── inlineDecorator.ts # 内联装饰器 │ ├── inlineDecorator.ts # 内联装饰器 (备用)
│ ├── codeLensProvider.ts # CodeLens 提供器 │ ├── codeLensProvider.ts # CodeLens 提供器 (备用)
│ └── statusBar.ts # 状态栏 │ └── statusBar.ts # 状态栏管理器 (常驻 + Diff 模式)
├── transaction/ ├── transaction/
│ ├── acceptHandler.ts # Accept 处理器 │ ├── acceptHandler.ts # Accept 处理器 (旧版)
│ └── rejectHandler.ts # Reject 处理器 │ └── rejectHandler.ts # Reject 处理器 (旧版)
└── models/ ├── models/
├── types.ts # 类型定义 │ ├── types.ts # 类型定义
└── constants.ts # 常量定义 │ └── constants.ts # 常量定义
└── __tests__/
├── diffEngine.test.ts # Diff 引擎测试
└── changeSetManager.test.ts # 变更集管理器回归测试
``` ```
## License ## 开发
MIT ```bash
npm run watch # esbuild 监听模式 + sourcemap
npm run build # 生产构建 (minify)
npm run test # 运行测试 (13 tests)
npm run test:watch # 测试监听模式
npm run lint # ESLint
npm run package # vsce package → .vsix
```
## 技术栈
| 领域 | 选型 |
| --------- | -------------------- |
| 框架 | VSCode Extension API |
| 语言 | TypeScript (ES2022) |
| Diff 算法 | `diff` (Myers) |
| 绑定 | esbuild |
| 测试 | Vitest |
| 规范 | ESLint + Prettier |
## 许可证
MIT © 2026 [Gmaker689](https://github.com/Gmaker689)
+241
View File
@@ -0,0 +1,241 @@
# AI Code Diff Preview — 项目文档
## 一、功能概述
AI Code Diff Preview 是一个 VSCode 插件,为 Claude Code 提供类似 Cursor 的 AI 代码变更审查体验:
- **自动收集**:监听 Claude 的 Edit/Write 操作,静默收集所有文件变更
- **总览面板**:Stop 后弹出 Webview 面板,展示每个文件的完整双栏 Diff
- **内置 Diff**:点击单个文件打开 VSCode 原生 Diff 编辑器,状态栏显示 Accept/Reject 按钮
- **粒度控制**:逐文件 Accept/Reject + 全局 Accept All / Reject All
- **编辑合并**:同文件连续多次编辑自动合并为一个 Diff(前次未处理时);已处理后重新编辑则独立追踪
## 二、项目结构
```
src/
├── extension.ts # 插件入口,初始化模块 + 注册命令
├── trigger/
│ └── hookHandler.ts # Hook 事件处理 (Edit/Stop 分发)
├── snapshot/
│ └── snapshotManager.ts # ★ 核心数据层: ChangeSetManager
├── diff/
│ └── diffEngine.ts # Diff 算法 (Myers)
├── render/
│ ├── reviewPanel.ts # ★ 总览 Webview 面板
│ ├── diffViewer.ts # ★ VSCode 内置 Diff 查看器 + accept/reject + 自动推进
│ ├── statusBar.ts # ★ 状态栏 (Diff 模式按钮 / 摘要模式)
│ ├── inlineDecorator.ts # 内联装饰器 (保留, 未启用)
│ └── codeLensProvider.ts # CodeLens (保留, 未启用)
├── transaction/
│ ├── acceptHandler.ts # Accept 处理器 (legacy)
│ └── rejectHandler.ts # Reject 处理器 (legacy)
└── models/
├── types.ts # 类型定义
└── constants.ts # 常量
```
## 三、核心数据模型
### 层级关系
```
ChangeSet (一轮对话)
└── FileChange[] (每个文件一个)
├── originalContent (首次编辑前的文件快照)
├── latestContent (最新文件内容)
├── diffLines[] (originalContent → latestContent 的聚合 Diff)
├── status (pending | accepted | rejected)
└── EditRecord[] (每次 Edit/Write 一条)
├── beforeContent (本次编辑前快照)
├── afterContent (本次编辑后快照)
├── oldString / newString (精确替换内容)
├── diffLines[] (本次编辑的独立 Diff)
└── status (pending | accepted | rejected)
```
### 编辑合并策略
```
同文件多次 Edit 时:
┌─ 已有 pending FileChange? ── YES ──→ ★ 合并: 追加 EditRecord, 更新聚合 Diff
│
└─ NO (首次 / 前轮已 accept/reject)
└──→ 创建新 FileChange, 独立追踪
```
**关键**: 查找 FileChange 时**优先匹配 pending 的**(而非用 `.find()` 取第一个),避免同文件多轮编辑时孤儿 FileChange 的 Bug。
## 四、完整流程
```mermaid
flowchart TD
A["Claude 对话中"] --> B["Edit / Write 工具调用"]
B --> C["写入 .claude/hooks/pending.json"]
C --> D["FileSystemWatcher 监听到"]
D --> E["读取并删除 pending.json"]
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<br/>配置?"}
K --> L
L -->|"true"| M["弹出 VSCode 内置 Diff"]
L -->|"false"| N["静默收集, 不弹 UI"]
F -->|"Stop"| O["hookHandler.handleStop()"]
O --> P["ChangeSetManager.markReady()"]
P --> Q{"有 pending 变更?"}
Q -->|"NO"| R["无操作"]
Q -->|"YES"| S["★ 弹出总览 Webview 面板<br/>reviewPanel.show()"]
S --> T["总览面板: 每个文件一张卡片"]
T --> U["卡片内含:<br/>— 文件信息 + 增减统计<br/>— 双栏 Diff (原始 vs 最新)<br/>— ✔ 接受 / ✘ 拒绝 按钮"]
T --> V["顶部: Accept All / Reject All"]
U --> W["用户点击 ✔ 接受"]
U --> X["用户点击 ✘ 拒绝"]
V --> Y["用户点击 Accept All"]
V --> Z["用户点击 Reject All"]
W --> AA["acceptFile(fileId)<br/>写入 latestContent 到磁盘"]
X --> AB["rejectFile(fileId)<br/>恢复 originalContent"]
Y --> AC["acceptAll()<br/>遍历所有 pending 文件"]
Z --> AD["rejectAll()<br/>遍历所有 pending 文件"]
AA --> AE["刷新总览面板 + 状态栏"]
AB --> AE
AC --> AE
AD --> AE
T --> AF["用户点击文件卡片"]
AF --> AG["打开 VSCode 内置 Diff 编辑器<br/>diffViewer.showFileDiff()"]
AG --> AH["Diff 界面: 状态栏切换为<br/>$(check) Accept | $(close) Reject | $(check-all) 接受全部"]
AH --> AI["用户操作 Accept/Reject"]
AI --> AJ["★ 自动推进到下一个 pending 文件<br/>diffViewer.advanceToNext()"]
AJ --> AK["全部处理完 → 🎉 通知"]
```
## 五、三种 UI 层
### 5.1 Webview 总览面板 (`reviewPanel.ts`)
触发时机:Claude Stop 后自动弹出 / 手动 `Ctrl+Shift+D`
```
┌─ AI Diff 总览 ───── [Accept All] [Reject All] ─┐
│ │
│ 📄 file1.ts 修改 +3 -2 · 2编辑 [✔接受][✘拒绝]│
│ ┌──── 原始代码 ────┬──── 变更后 ────────────┐ │
│ │ 1 line1 │ 1 line1 │ │
│ │ 2 -old line2 │ (空) │ │
│ │ 3 line3 │ 2 +new line2 │ │
│ │ 4 -old line4 │ (空) │ │
│ │ 5 line5 │ 3 +extra line │ │
│ │ │ 4 line5 │ │
│ └──────────────────┴────────────────────────┘ │
│ │
│ 📄 file2.ts 新增 +10 -0 · 1编辑 [✔接受][✘拒绝]│
│ ┌──── 新文件内容 ──────────────────────────┐ │
│ │ 1 +new code line 1 │ │
│ │ 2 +new code line 2 │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
### 5.2 VSCode 内置 Diff 编辑器 (`diffViewer.ts`)
触发时机:从总览面板点击文件 / `Alt+↑/↓` 导航
- 使用 `vscode.diff` 命令打开原生 Diff 编辑器
- 左侧:原始内容 (红色删除行),右侧:变更后内容 (绿色新增行)
- 临时文件机制:写入 `%TEMP%/ai-diff-preview/`,关闭 Diff 后自动删除(**无保存提示**)
- **自动推进**:Accept/Reject 后自动打开下一个 pending 文件
- 若当前文件仍有 pending 编辑 → 重新打开此文件
- 否则 → 打开下一个 pending 文件
- 全部处理完 → 弹出 🎉 通知
### 5.3 状态栏 (`statusBar.ts`)
双模式自动切换:
| 模式 | 触发 | 显示 |
|------|------|------|
| 摘要模式 | 无活跃 Diff | `$(diff) AI Diff: 2文件 5编辑` ← 点击打开总览 |
| Diff 模式 | 内置 Diff 活跃 | `$(check) Accept` `$(close) Reject` `$(check-all) 接受全部` |
## 六、Accept/Reject 行为
| 操作 | Modify 文件 | Create 文件 |
|------|------------|------------|
| Accept (文件级) | 写入 `latestContent` 到磁盘 | 保留文件 |
| Reject (文件级) | 恢复 `originalContent` 到磁盘 | 删除文件 |
| Accept All | 遍历所有 pending 文件执行 Accept | |
| Reject All | 遍历所有 pending 文件执行 Reject | |
## 七、配置项
| 配置 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `enableAutoTrigger` | boolean | true | 监听 pending.json |
| `autoShowDiffPerEdit` | boolean | false | 每次 Edit 后弹内置 Diff |
| `showAllDiffsOnStop` | boolean | true | Stop 后弹总览面板 |
| `floatingLabelMode` | string | "statusBar" | 按钮位置 |
| `maxFileSize` | number | 100000 | 文件大小上限 |
## 八、快捷键
| 快捷键 | 条件 | 功能 |
|--------|------|------|
| `Ctrl+Shift+D` | `aiDiffPreview.isActive` | 打开总览面板 |
| `Alt+↓` | `aiDiffPreview.isActive` | 下一个变更文件 |
| `Alt+↑` | `aiDiffPreview.isActive` | 上一个变更文件 |
| `Tab` | Diff 活跃 | Accept 当前变更 |
| `Esc` | Diff 活跃 | Reject 当前变更 |
| `Ctrl+Shift+A` | `aiDiffPreview.isActive` | Accept All |
| `Ctrl+Shift+R` | `aiDiffPreview.isActive` | Reject All |
## 九、模块依赖关系
```mermaid
flowchart LR
extension["extension.ts<br/>入口"]
hook["hookHandler.ts<br/>Hook 处理"]
manager["snapshotManager.ts<br/>ChangeSetManager<br/>★ 核心数据层"]
review["reviewPanel.ts<br/>总览 Webview"]
viewer["diffViewer.ts<br/>内置 Diff + 自动推进"]
bar["statusBar.ts<br/>状态栏"]
types["models/types.ts<br/>类型"]
extension --> hook
extension --> manager
extension --> review
extension --> viewer
extension --> bar
hook --> manager
hook --> viewer
review --> manager
review --> viewer
viewer --> manager
bar --> manager
bar --> viewer
manager --> types
```
## 十、已修复的 Bug
| Bug | 根因 | 修复 |
|-----|------|------|
| Accept 后同文件再编辑无 Accept/Reject | `recordEdit()` 更新同一 `FileChange`,Accept 后状态机断开 | 引入 `EditRecord` 粒度,Accept/Reject 后重新编辑创建新 FileChange |
| Accept All / Reject All 不生效 | `acceptAllFromDiff()` 调用不存在的方法 | 改为调用 `acceptFile()`,All 方法改为收集列表后逐一处理 |
| Accept 后无后续 Diff | 无推进逻辑 | `advanceToNext()` 自动打开下一个 pending 文件 |
| 临时文件保存提示 | 使用 `openTextDocument({content})` 创建 untitled 文档 | 改为写入临时目录 `%TEMP%/ai-diff-preview/` |
| 同文件多轮编辑孤儿 FileChange | `.find()` 返回第一个 (已 accept) 而非 pending | 改为优先匹配 pending 的 FileChange |
+5 -1
View File
@@ -3,7 +3,11 @@
"displayName": "AI Code Diff Preview", "displayName": "AI Code Diff Preview",
"description": "类 Cursor 的 AI 代码差异预览插件,使用 VSCode 内置 Diff 编辑器 + 浮动 Accept/Reject 标签", "description": "类 Cursor 的 AI 代码差异预览插件,使用 VSCode 内置 Diff 编辑器 + 浮动 Accept/Reject 标签",
"version": "0.2.0", "version": "0.2.0",
"publisher": "your-publisher-name", "publisher": "Gmaker689",
"repository": {
"type": "git",
"url": "https://github.com/Gmaker689/ai-diff-preview"
},
"engines": { "engines": {
"vscode": "^1.85.0" "vscode": "^1.85.0"
}, },
+92 -175
View File
@@ -1,17 +1,10 @@
/** /**
* AI Code Diff Preview - 插件入口 * AI Code Diff Preview - 插件入口
* *
* 工作流程: * UI 说明:
* 1. PostToolUse (Edit/Write) → 收集变更到 ChangeSet (EditRecord 粒度) * 1. Stop 后弹出 QuickPick 文件列表 (含 Accept All / Reject All)
* 2. Stop (对话结束) → 根据配置弹出 QuickPick 文件列表或通知 * 2. 点击文件 → VSCode 内置 Diff 编辑器 + 状态栏 Accept/Reject 按钮
* 3. 用户选择文件 → 打开 VSCode 内置 Diff 编辑器 * 3. Accept/Reject 后自动推进到下一个 pending 文件
* 4. 用户 Accept/Reject → 应用或丢弃变更
*
* 快捷键:
* - Alt+↑/↓: 上一个/下一个变更
* - Tab: 接受当前编辑
* - Esc: 拒绝当前编辑
* - Ctrl+Shift+D: 显示文件列表
*/ */
import * as vscode from 'vscode'; import * as vscode from 'vscode';
@@ -20,227 +13,163 @@ import * as fs from 'fs';
import { ChangeSetManager } from './snapshot/snapshotManager'; import { ChangeSetManager } from './snapshot/snapshotManager';
import { HookHandler } from './trigger/hookHandler'; import { HookHandler } from './trigger/hookHandler';
import { DiffViewer } from './render/diffViewer'; import { DiffViewer } from './render/diffViewer';
import { ReviewPanel } from './render/reviewPanel'; import { StatusBarManager } from './render/statusBar';
import { COMMANDS } from './models/constants'; import { COMMANDS } from './models/constants';
import { ClaudeHookEvent } from './models/types'; import { ClaudeHookEvent } from './models/types';
let changeSetManager: ChangeSetManager; let changeSetManager: ChangeSetManager;
let hookHandler: HookHandler; let hookHandler: HookHandler;
let diffViewer: DiffViewer; let diffViewer: DiffViewer;
let reviewPanel: ReviewPanel; let statusBar: StatusBarManager;
export function activate(context: vscode.ExtensionContext) { export function activate(context: vscode.ExtensionContext) {
console.log('[AI Diff] 插件已激活'); console.log('[AI Diff] 插件已激活');
// 初始化核心模块
changeSetManager = new ChangeSetManager(); changeSetManager = new ChangeSetManager();
hookHandler = new HookHandler(changeSetManager); diffViewer = new DiffViewer(changeSetManager);
diffViewer = hookHandler.getDiffViewer(); hookHandler = new HookHandler(changeSetManager, diffViewer);
reviewPanel = new ReviewPanel(changeSetManager); statusBar = new StatusBarManager(changeSetManager, diffViewer);
// DiffViewer 操作后刷新状态栏
diffViewer.onDiffAction = () => refreshUI();
// 注册命令
registerCommands(context); registerCommands(context);
// 注册 Hook 触发文件监听 // 编辑器切换 → 刷新状态栏
registerHookFileWatcher(context); context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(() => refreshUI())
);
context.subscriptions.push(
vscode.window.onDidChangeVisibleTextEditors(() => refreshUI())
);
// 检查激活时是否已有 pending 触发文件 registerHookFileWatcher(context);
checkPendingHookFile(); checkPendingHookFile();
refreshUI();
} }
// ---- 命令 ----
function registerCommands(context: vscode.ExtensionContext) { function registerCommands(context: vscode.ExtensionContext) {
// === Diff 查看 === // QuickPick 文件列表 (总览)
// 显示文件列表 (QuickPick)
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => { vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => hookHandler.showFileListPicker())
hookHandler.showFileListPicker(); );
// 当前文件 Diff
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.SHOW_CURRENT_FILE_DIFF, () => diffViewer.showCurrentFileDiff())
);
// 接受当前编辑
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.ACCEPT_CURRENT_EDIT, async () => {
await diffViewer.acceptCurrentDiff();
}) })
); );
// 显示当前活动文件的 Diff
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.SHOW_CURRENT_FILE_DIFF, () => {
diffViewer.showCurrentFileDiff();
})
);
// === Accept / Reject ===
// 接受当前编辑(光标所在位置的编辑)
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.ACCEPT_CURRENT_EDIT, () => {
const result = findCurrentEditAtCursor();
if (!result) {
vscode.window.showInformationMessage('AI Diff: 光标不在变更区域内');
return;
}
changeSetManager.acceptEditRecord(result.fileChange.id, result.edit.id);
vscode.window.showInformationMessage(`AI Diff: 已接受编辑 #${result.edit.id.slice(0, 6)}`);
})
);
// 拒绝当前编辑 // 拒绝当前编辑
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.REJECT_CURRENT_EDIT, () => { vscode.commands.registerCommand(COMMANDS.REJECT_CURRENT_EDIT, async () => {
const result = findCurrentEditAtCursor(); await diffViewer.rejectCurrentDiff();
if (!result) {
vscode.window.showInformationMessage('AI Diff: 光标不在变更区域内');
return;
}
changeSetManager.rejectEditRecord(result.fileChange.id, result.edit.id);
vscode.window.showInformationMessage(`AI Diff: 已拒绝编辑 #${result.edit.id.slice(0, 6)}`);
}) })
); );
// Accept All
// 接受全部
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.ACCEPT_ALL, () => { vscode.commands.registerCommand(COMMANDS.ACCEPT_ALL, () => {
const count = changeSetManager.acceptAll(); const n = changeSetManager.acceptAll();
vscode.window.showInformationMessage(`AI Diff: 已接受 ${count} 个文件的变更`); vscode.window.showInformationMessage(`AI Diff: 已接受 ${n} 个文件`);
refreshUI();
}) })
); );
// Reject All
// 拒绝全部
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.REJECT_ALL, () => { vscode.commands.registerCommand(COMMANDS.REJECT_ALL, () => {
const count = changeSetManager.rejectAll(); const n = changeSetManager.rejectAll();
vscode.window.showInformationMessage(`AI Diff: 已拒绝 ${count} 个文件的变更`); vscode.window.showInformationMessage(`AI Diff: 已拒绝 ${n} 个文件`);
refreshUI();
}) })
); );
// 导航
// === 导航 ===
// 下一个变更
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.NEXT_DIFF, () => { vscode.commands.registerCommand(COMMANDS.NEXT_DIFF, () => navigateDiff(1))
navigateDiff(1); );
}) context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.PREV_DIFF, () => navigateDiff(-1))
); );
// 上一个变更 // Diff 界面状态栏按钮
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.PREV_DIFF, () => { vscode.commands.registerCommand('aiDiffPreview.acceptCurrentDiff', async () => {
navigateDiff(-1); await diffViewer.acceptCurrentDiff();
})
);
// 旧版兼容命令
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.ACCEPT_CHUNK, () => {
vscode.commands.executeCommand(COMMANDS.ACCEPT_CURRENT_EDIT);
}) })
); );
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.REJECT_CHUNK, () => { vscode.commands.registerCommand('aiDiffPreview.rejectCurrentDiff', async () => {
vscode.commands.executeCommand(COMMANDS.REJECT_CURRENT_EDIT); await diffViewer.rejectCurrentDiff();
}) })
); );
context.subscriptions.push(
vscode.commands.registerCommand('aiDiffPreview.acceptAllFromDiff', async () => {
await diffViewer.acceptAllFromDiff();
})
);
// 旧版兼容
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.ACCEPT_CHUNK, async () => { await diffViewer.acceptCurrentDiff(); })
);
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.REJECT_CHUNK, async () => { await diffViewer.rejectCurrentDiff(); })
);
} }
/** // ---- 导航 ----
* 查找光标所在位置的编辑记录
*/
function findCurrentEditAtCursor(): { fileChange: any; edit: any } | null {
const editor = vscode.window.activeTextEditor;
if (!editor) return null;
const set = changeSetManager.getCurrentSet();
if (!set) return null;
const normalizedPath = path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/');
const fileChange = set.changes.find(
c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath && c.status === 'pending'
);
if (!fileChange) return null;
const cursorLine = editor.selection.active.line;
// 在文件聚合 diff 中查找光标所在行对应的 edit
for (const edit of fileChange.edits) {
if (edit.status !== 'pending') continue;
// 找到该 edit 中匹配的行范围
for (const d of edit.diffLines) {
if (d.type === 'context' || d.type === 'add') {
if (d.newLineNum - 1 === cursorLine) {
return { fileChange, edit };
}
}
}
}
// 回退:返回第一个 pending edit
const firstPending = fileChange.edits.find(e => e.status === 'pending');
if (firstPending) {
return { fileChange, edit: firstPending };
}
return null;
}
/**
* 在变更文件之间导航
*/
function navigateDiff(direction: 1 | -1): void { function navigateDiff(direction: 1 | -1): void {
const set = changeSetManager.getCurrentSet(); const set = changeSetManager.getCurrentSet();
if (!set) return; if (!set) return;
const pending = set.changes.filter(c => c.status === 'pending'); const pending = set.changes.filter(c => c.status === 'pending');
if (pending.length === 0) { if (pending.length === 0) {
vscode.window.showInformationMessage('AI Diff: 无待处理变更'); vscode.window.showInformationMessage('AI Diff: 无待处理变更');
return; return;
} }
const editor = vscode.window.activeTextEditor; const editor = vscode.window.activeTextEditor;
const currentPath = editor const currentPath = editor
? path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/') ? path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/')
: null; : null;
// 找到当前文件在 pending 列表中的位置
let idx = currentPath let idx = currentPath
? pending.findIndex(c => path.normalize(c.filePath).replace(/\\/g, '/') === currentPath) ? pending.findIndex(c => path.normalize(c.filePath).replace(/\\/g, '/') === currentPath)
: -1; : -1;
idx = idx === -1 ? 0 : (idx + direction + pending.length) % pending.length; idx = idx === -1 ? 0 : (idx + direction + pending.length) % pending.length;
diffViewer.showFileDiff(pending[idx]); diffViewer.showFileDiff(pending[idx]);
} }
/** // ---- 刷新 ----
* 注册 Hook 触发文件监听器
* function refreshUI(): void {
* 监听 .claude/hooks/pending.json: statusBar.refresh();
* - toolName: Edit/Write → 收集变更 const hasPending = changeSetManager.hasPendingChanges();
* - toolName: Stop → 弹出 Review 通知/文件列表 vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', hasPending);
*/ }
// ---- Hook 监听 ----
function registerHookFileWatcher(context: vscode.ExtensionContext) { function registerHookFileWatcher(context: vscode.ExtensionContext) {
const hookDir = vscode.workspace.workspaceFolders?.[0] const hookDir = vscode.workspace.workspaceFolders?.[0]
? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks') ? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks')
: null; : null;
if (!hookDir) { console.log('[AI Diff] 无工作区目录'); return; }
if (!hookDir) { if (!fs.existsSync(hookDir)) fs.mkdirSync(hookDir, { recursive: true });
console.log('[AI Diff] 无工作区目录,Hook 监听未启动');
return;
}
if (!fs.existsSync(hookDir)) {
fs.mkdirSync(hookDir, { recursive: true });
}
const pendingFile = path.join(hookDir, 'pending.json'); const pendingFile = path.join(hookDir, 'pending.json');
const watcher = vscode.workspace.createFileSystemWatcher( const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(hookDir, 'pending.json') new vscode.RelativePattern(hookDir, 'pending.json')
); );
const handleTrigger = () => { const handle = () => {
try { try {
if (!fs.existsSync(pendingFile)) return; if (!fs.existsSync(pendingFile)) return;
const content = fs.readFileSync(pendingFile, 'utf-8'); const content = fs.readFileSync(pendingFile, 'utf-8');
fs.unlinkSync(pendingFile); fs.unlinkSync(pendingFile);
const event: ClaudeHookEvent = JSON.parse(content); const event: ClaudeHookEvent = JSON.parse(content);
// 只处理最近 10 秒内的事件
if (Date.now() - event.timestamp > 10000) return; if (Date.now() - event.timestamp > 10000) return;
if (event.toolName === 'Stop') { if (event.toolName === 'Stop') {
@@ -248,48 +177,36 @@ function registerHookFileWatcher(context: vscode.ExtensionContext) {
} else if (event.toolName === 'Edit' || event.toolName === 'Write') { } else if (event.toolName === 'Edit' || event.toolName === 'Write') {
hookHandler.handleEdit(event); hookHandler.handleEdit(event);
} }
} catch (e) { refreshUI();
console.error('[AI Diff] 处理触发文件失败:', e); } catch (e) { console.error('[AI Diff] 处理触发文件失败:', e); }
}
}; };
watcher.onDidChange(handleTrigger); watcher.onDidChange(handle);
watcher.onDidCreate(handleTrigger); watcher.onDidCreate(handle);
context.subscriptions.push(watcher); context.subscriptions.push(watcher);
console.log(`[AI Diff] Hook 监听已启动: ${hookDir}`);
console.log(`[AI Diff] Hook 文件监听已启动: ${hookDir}`);
} }
/**
* 检查激活时是否已有 pending 触发文件
*/
function checkPendingHookFile() { function checkPendingHookFile() {
const hookDir = vscode.workspace.workspaceFolders?.[0] const hookDir = vscode.workspace.workspaceFolders?.[0]
? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks') ? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks')
: null; : null;
if (!hookDir) return; if (!hookDir) return;
const pendingFile = path.join(hookDir, 'pending.json'); const pendingFile = path.join(hookDir, 'pending.json');
if (!fs.existsSync(pendingFile)) return; if (!fs.existsSync(pendingFile)) return;
try { try {
const content = fs.readFileSync(pendingFile, 'utf-8'); const content = fs.readFileSync(pendingFile, 'utf-8');
fs.unlinkSync(pendingFile); fs.unlinkSync(pendingFile);
const event: ClaudeHookEvent = JSON.parse(content); const event: ClaudeHookEvent = JSON.parse(content);
if (Date.now() - event.timestamp < 15000) { if (Date.now() - event.timestamp < 15000) {
if (event.toolName === 'Stop') { if (event.toolName === 'Stop') hookHandler.handleStop();
hookHandler.handleStop(); else if (event.toolName === 'Edit' || event.toolName === 'Write') hookHandler.handleEdit(event);
} else if (event.toolName === 'Edit' || event.toolName === 'Write') { refreshUI();
hookHandler.handleEdit(event);
}
} }
} catch (e) { } catch (e) { console.error('[AI Diff] 处理 pending hook 失败:', e); }
console.error('[AI Diff] 处理 pending hook 失败:', e);
}
} }
export function deactivate() { export function deactivate() {
console.log('[AI Diff] 插件已停用'); console.log('[AI Diff] 插件已停用');
reviewPanel?.dispose(); diffViewer?.cleanupTmpFiles();
} }
+92 -64
View File
@@ -1,96 +1,124 @@
/** /**
* CodeLens 提供器 - 提供 Accept/Reject 按钮 * CodeLens 提供器 - 在编辑器行上方显示 Accept/Reject 按钮
*
* 基于 ChangeSetManager 和 EditRecord,为每次编辑提供可点击的操作按钮。
* 按钮显示在对应编辑的变更区域上方。
*/ */
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { SnapshotManager } from '../snapshot/snapshotManager'; import * as path from 'path';
import { ChangeSetManager } from '../snapshot/snapshotManager';
import { COMMANDS } from '../models/constants'; import { COMMANDS } from '../models/constants';
import { FileChange, EditRecord } from '../models/types';
export class CodeLensProvider implements vscode.CodeLensProvider { export class AIDiffCodeLensProvider implements vscode.CodeLensProvider {
private _onDidChangeCodeLenses = new vscode.EventEmitter<void>(); private _onDidChangeCodeLenses = new vscode.EventEmitter<void>();
readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
constructor(private snapshotManager: SnapshotManager) {} constructor(private changeSetManager: ChangeSetManager) {}
/**
* 刷新 CodeLens
*/
refresh(): void { refresh(): void {
this._onDidChangeCodeLenses.fire(); this._onDidChangeCodeLenses.fire();
} }
/**
* 提供 CodeLens
*/
provideCodeLenses( provideCodeLenses(
document: vscode.TextDocument, document: vscode.TextDocument,
token: vscode.CancellationToken _token: vscode.CancellationToken
): vscode.CodeLens[] { ): vscode.CodeLens[] {
const snapshot = this.snapshotManager.getSnapshotByFilePath(document.uri.fsPath); const set = this.changeSetManager.getCurrentSet();
if (!snapshot || snapshot.status !== 'active') { if (!set || set.status !== 'ready') return [];
return [];
}
const codeLenses: vscode.CodeLens[] = []; const normalizedPath = path.normalize(document.uri.fsPath).replace(/\\/g, '/');
const fileChange = set.changes.find(
c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath && c.status === 'pending'
);
if (!fileChange) return [];
// 为每个待处理的块添加 CodeLens const lenses: vscode.CodeLens[] = [];
snapshot.chunks.forEach(chunk => {
if (chunk.status !== 'pending') return;
const range = new vscode.Range( // 文件标题行 (第 0 行): 全部接受/拒绝
new vscode.Position(chunk.startLine, 0), lenses.push(
new vscode.Position(chunk.startLine, 0) new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), {
title: `$(diff) AI Diff · ${fileChange.edits.length} 次编辑`,
command: COMMANDS.SHOW_CURRENT_FILE_DIFF,
tooltip: '在 VSCode 内置 Diff 中查看变更',
}),
new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), {
title: '$(check-all) 接受全部',
command: COMMANDS.ACCEPT_ALL,
tooltip: '接受此文件所有变更',
}),
new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), {
title: '$(trash) 拒绝全部',
command: COMMANDS.REJECT_ALL,
tooltip: '拒绝此文件所有变更',
})
);
// 为每个待处理的 EditRecord 添加按钮
for (const edit of fileChange.edits) {
if (edit.status !== 'pending') continue;
// 找到该编辑在 diffLines 中的第一个变更行
const firstChangedLine = this.findFirstChangeLine(edit);
if (firstChangedLine < 0) continue;
const pos = new vscode.Position(firstChangedLine, 0);
const range = new vscode.Range(pos, pos);
const toolLabel = edit.toolName === 'Write' ? '写入' : '编辑';
const summary = this.editSummary(edit);
// 标题行
lenses.push(
new vscode.CodeLens(range, {
title: `— AI ${toolLabel} #${edit.id.slice(0, 4)} · ${summary}`,
command: COMMANDS.SHOW_CURRENT_FILE_DIFF,
tooltip: '点击查看本次编辑的 Diff',
})
); );
// Accept 按钮 // Accept / Reject 按钮
codeLenses.push( lenses.push(
new vscode.CodeLens(range, { new vscode.CodeLens(range, {
title: '$(check) Accept', title: '$(check) Accept',
command: COMMANDS.ACCEPT_CHUNK, command: COMMANDS.ACCEPT_CURRENT_EDIT,
arguments: [snapshot.id, chunk.id], arguments: [fileChange.id, edit.id],
tooltip: '接受此变更块', tooltip: `接受此${toolLabel}`,
}) }),
);
// Reject 按钮
codeLenses.push(
new vscode.CodeLens(range, { new vscode.CodeLens(range, {
title: '$(close) Reject', title: '$(close) Reject',
command: COMMANDS.REJECT_CHUNK, command: COMMANDS.REJECT_CURRENT_EDIT,
arguments: [snapshot.id, chunk.id], arguments: [fileChange.id, edit.id],
tooltip: '拒绝此变更块', tooltip: `拒绝此${toolLabel}`,
})
);
});
// 如果有多个块,添加全局操作按钮
const pendingChunks = snapshot.chunks.filter(c => c.status === 'pending');
if (pendingChunks.length > 1) {
const firstChunk = pendingChunks[0];
const range = new vscode.Range(
new vscode.Position(firstChunk.startLine, 0),
new vscode.Position(firstChunk.startLine, 0)
);
codeLenses.push(
new vscode.CodeLens(range, {
title: '$(check-all) Accept All',
command: COMMANDS.ACCEPT_ALL,
arguments: [snapshot.id],
tooltip: '接受所有变更',
})
);
codeLenses.push(
new vscode.CodeLens(range, {
title: '$(trash) Reject All',
command: COMMANDS.REJECT_ALL,
arguments: [snapshot.id],
tooltip: '拒绝所有变更',
}) })
); );
} }
return codeLenses; return lenses;
}
/**
* 找到 EditRecord 在文件的 diffLines 中对应的第一个变更行号
*/
private findFirstChangeLine(edit: EditRecord): number {
for (const d of edit.diffLines) {
if (d.type === 'add' || d.type === 'delete') {
return d.newLineNum > 0 ? d.newLineNum - 1 : d.oldLineNum - 1;
}
}
return -1;
}
/**
* 编辑摘要: "+3 -2"
*/
private editSummary(edit: EditRecord): string {
const adds = edit.diffLines.filter(d => d.type === 'add').length;
const dels = edit.diffLines.filter(d => d.type === 'delete').length;
const parts: string[] = [];
if (adds > 0) parts.push(`+${adds}`);
if (dels > 0) parts.push(`-${dels}`);
return parts.join(' ');
} }
} }
+187 -78
View File
@@ -1,122 +1,231 @@
/** /**
* Diff 查看器 - 调用 VSCode 内置 Diff 编辑器 * Diff 查看器 - VSCode 内置 Diff 编辑器 + Accept/Reject 状态栏
* *
* 使用 vscode.diff 命令展示原始内容 vs 变更后内容, * 关键:
* 完全不使用自定义 Webview,保持 VSCode 原生交互体验。 * 1. 临时文件(非 untitled)避免保存提示
* 2. Accept/Reject 后自动推进到下一个 pending 文件
* 3. 状态栏按钮在 Diff 模式下可见
*/ */
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { v4 as uuidv4 } from 'uuid';
import { EditRecord, FileChange } from '../models/types'; import { EditRecord, FileChange } from '../models/types';
import { ChangeSetManager } from '../snapshot/snapshotManager'; import { ChangeSetManager } from '../snapshot/snapshotManager';
export interface DiffSession {
fileId: string;
editId?: string;
beforeTmpPath: string;
afterTmpPath: string;
beforeUri: vscode.Uri;
afterUri: vscode.Uri;
}
export class DiffViewer { export class DiffViewer {
private activeSession: DiffSession | null = null;
private closeListener: vscode.Disposable | null = null;
private tmpFiles: string[] = [];
/** 外部回调:Diff 操作完成后通知(用于刷新 UI) */
onDiffAction?: () => void;
constructor(private changeSetManager: ChangeSetManager) {} constructor(private changeSetManager: ChangeSetManager) {}
/** getActiveSession(): DiffSession | null {
* 打开单次编辑的 Diff(VSCode 内置 Diff 编辑器) return this.activeSession;
*/ }
async showEditDiff(editRecord: EditRecord): Promise<void> {
const beforeDoc = await vscode.workspace.openTextDocument({
content: editRecord.beforeContent,
language: this.getLanguageId(editRecord.filePath),
});
const afterDoc = await vscode.workspace.openTextDocument({
content: editRecord.afterContent,
language: this.getLanguageId(editRecord.filePath),
});
// ---- 打开 Diff ----
async showEditDiff(editRecord: EditRecord, fileChangeId: string): Promise<void> {
const ext = path.extname(editRecord.filePath);
const fileName = path.basename(editRecord.filePath); const fileName = path.basename(editRecord.filePath);
const toolLabel = editRecord.toolName === 'Write' ? '写入' : '编辑'; const toolLabel = editRecord.toolName === 'Write' ? '写入' : '编辑';
const beforePath = this.makeTmpFile(`ai-diff-before-${editRecord.id.slice(0, 8)}${ext}`, editRecord.beforeContent);
const afterPath = this.makeTmpFile(`ai-diff-after-${editRecord.id.slice(0, 8)}${ext}`, editRecord.afterContent);
this.setSession({
fileId: fileChangeId,
editId: editRecord.id,
beforeTmpPath: beforePath,
afterTmpPath: afterPath,
beforeUri: vscode.Uri.file(beforePath),
afterUri: vscode.Uri.file(afterPath),
});
await vscode.commands.executeCommand( await vscode.commands.executeCommand(
'vscode.diff', 'vscode.diff',
beforeDoc.uri, this.activeSession!.beforeUri,
afterDoc.uri, this.activeSession!.afterUri,
`${fileName} — AI ${toolLabel} #${editRecord.id.slice(0, 6)} (原始 ↔ 变更后)` `${fileName} — AI ${toolLabel} #${editRecord.id.slice(0, 6)} · 状态栏操作`
); );
} }
/**
* 打开整个文件的聚合 Diff
*/
async showFileDiff(fileChange: FileChange): Promise<void> { async showFileDiff(fileChange: FileChange): Promise<void> {
const beforeDoc = await vscode.workspace.openTextDocument({ const ext = path.extname(fileChange.filePath);
content: fileChange.originalContent,
language: this.getLanguageId(fileChange.filePath),
});
const afterDoc = await vscode.workspace.openTextDocument({
content: fileChange.latestContent,
language: this.getLanguageId(fileChange.filePath),
});
const fileName = path.basename(fileChange.filePath); const fileName = path.basename(fileChange.filePath);
const beforePath = this.makeTmpFile(`ai-diff-before-${fileChange.id.slice(0, 8)}${ext}`, fileChange.originalContent);
const afterPath = this.makeTmpFile(`ai-diff-after-${fileChange.id.slice(0, 8)}${ext}`, fileChange.latestContent);
this.setSession({
fileId: fileChange.id,
beforeTmpPath: beforePath,
afterTmpPath: afterPath,
beforeUri: vscode.Uri.file(beforePath),
afterUri: vscode.Uri.file(afterPath),
});
await vscode.commands.executeCommand( await vscode.commands.executeCommand(
'vscode.diff', 'vscode.diff',
beforeDoc.uri, this.activeSession!.beforeUri,
afterDoc.uri, this.activeSession!.afterUri,
`${fileName} — 原始 ↔ 变更后 (${fileChange.edits.length} 次编辑)` `${fileName} — ${fileChange.edits.length} 次 AI 编辑 · 状态栏操作`
); );
} }
/**
* 打开当前活动文件在编辑器中的 Diff
*/
async showCurrentFileDiff(): Promise<void> { async showCurrentFileDiff(): Promise<void> {
const changeSet = this.changeSetManager.getCurrentSet(); const set = this.changeSetManager.getCurrentSet();
if (!changeSet) return; if (!set) return;
const editor = vscode.window.activeTextEditor; const editor = vscode.window.activeTextEditor;
if (!editor) return; if (!editor) return;
const normalizedPath = path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/'); const normalizedPath = path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/');
const fileChange = changeSet.changes.find( const fc = set.changes.find(
c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath && c.status === 'pending' c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath && c.status === 'pending'
); );
if (!fileChange) { if (fc) await this.showFileDiff(fc);
vscode.window.showInformationMessage('AI Diff: 当前文件无待处理变更'); else vscode.window.showInformationMessage('AI Diff: 当前文件无待处理变更');
return;
}
await this.showFileDiff(fileChange);
} }
/** // ---- Diff 界面操作 (Bug 1 修复: 自动推进) ----
* 聚焦文件到编辑器(跳转到该文件并定位到第一个变更行)
*/
async focusFileInEditor(fileChange: FileChange): Promise<void> {
const uri = vscode.Uri.file(fileChange.filePath);
const doc = await vscode.workspace.openTextDocument(uri);
const editor = await vscode.window.showTextDocument(doc, { preview: false });
// 滚动到第一个变更行 async acceptCurrentDiff(): Promise<void> {
if (fileChange.diffLines.length > 0) { if (!this.activeSession) return;
const firstChange = fileChange.diffLines.find( const { fileId, editId } = this.activeSession;
d => d.type === 'add' || d.type === 'delete'
); if (editId) {
if (firstChange) { this.changeSetManager.acceptEditRecord(fileId, editId);
const line = (firstChange.newLineNum || firstChange.oldLineNum) - 1; } else {
const pos = new vscode.Position(Math.max(0, line), 0); this.changeSetManager.acceptFile(fileId);
editor.selection = new vscode.Selection(pos, pos); }
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
vscode.window.showInformationMessage('AI Diff: 已接受 ✓');
await this.closeDiff();
this.onDiffAction?.();
// ★ Bug 1 修复: 自动推进到下一个待处理文件
this.advanceToNext(fileId);
}
async rejectCurrentDiff(): Promise<void> {
if (!this.activeSession) return;
const { fileId, editId } = this.activeSession;
if (editId) {
this.changeSetManager.rejectEditRecord(fileId, editId);
} else {
this.changeSetManager.rejectFile(fileId);
}
vscode.window.showInformationMessage('AI Diff: 已拒绝 ✗');
await this.closeDiff();
this.onDiffAction?.();
// ★ Bug 1 修复: 自动推进
this.advanceToNext(fileId);
}
/** ★ 接受当前文件的所有编辑 (Diff 界面 Accept All) */
async acceptAllFromDiff(): Promise<void> {
if (!this.activeSession) return;
const { fileId } = this.activeSession;
const ok = this.changeSetManager.acceptFile(fileId);
if (ok) {
vscode.window.showInformationMessage('AI Diff: 已接受此文件全部 ✓');
}
await this.closeDiff();
this.onDiffAction?.();
this.advanceToNext(fileId);
}
// ---- ★ Bug 1: 自动推进 ----
private advanceToNext(afterFileId: string): void {
// 如果此文件仍有待处理编辑,重新打开此文件
if (!this.changeSetManager.isFileFullyProcessed(afterFileId)) {
const set = this.changeSetManager.getCurrentSet();
const same = set?.changes.find(c => c.id === afterFileId);
if (same && same.status === 'pending') {
setTimeout(() => this.showFileDiff(same), 150);
return;
} }
} }
// 否则推进到下一个 pending 文件
const next = this.changeSetManager.findNextPending(afterFileId);
if (next) {
setTimeout(() => this.showFileDiff(next), 150);
} else {
setTimeout(() => {
vscode.window.showInformationMessage('AI Diff: 🎉 所有变更已处理完毕!');
}, 200);
}
} }
private getLanguageId(filePath: string): string { // ---- 关闭 ----
const ext = path.extname(filePath).toLowerCase();
const map: Record<string, string> = { async closeDiff(): Promise<void> {
'.ts': 'typescript', '.tsx': 'typescriptreact', if (this.activeSession) {
'.js': 'javascript', '.jsx': 'javascriptreact', try { await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); } catch { /* ok */ }
'.json': 'json', '.md': 'markdown', }
'.css': 'css', '.scss': 'scss', '.less': 'less', this.clearSession();
'.html': 'html', '.htm': 'html', }
'.py': 'python', '.rs': 'rust', '.go': 'go',
'.java': 'java', '.cpp': 'cpp', '.c': 'c', // ---- 内部 ----
'.yaml': 'yaml', '.yml': 'yaml',
'.xml': 'xml', '.sql': 'sql', '.sh': 'shell', private makeTmpFile(name: string, content: string): string {
}; const dir = path.join(os.tmpdir(), 'ai-diff-preview');
return map[ext] || 'plaintext'; if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${uuidv4().slice(0, 8)}-${name}`);
fs.writeFileSync(filePath, content, 'utf-8');
this.tmpFiles.push(filePath);
return filePath;
}
private setSession(session: DiffSession): void {
this.clearSession();
this.activeSession = session;
this.closeListener = vscode.window.onDidChangeVisibleTextEditors(() => {
if (!this.activeSession) return;
const stillOpen = vscode.window.visibleTextEditors.some(
e => e.document.uri.toString() === this.activeSession!.beforeUri.toString() ||
e.document.uri.toString() === this.activeSession!.afterUri.toString()
);
if (!stillOpen) this.clearSession();
});
}
private clearSession(): void {
if (this.activeSession) {
[this.activeSession.beforeTmpPath, this.activeSession.afterTmpPath].forEach(p => {
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* ok */ }
});
this.tmpFiles = this.tmpFiles.filter(
f => f !== this.activeSession!.beforeTmpPath && f !== this.activeSession!.afterTmpPath
);
}
this.activeSession = null;
if (this.closeListener) { this.closeListener.dispose(); this.closeListener = null; }
}
cleanupTmpFiles(): void {
for (const p of this.tmpFiles) {
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* ok */ }
}
this.tmpFiles = [];
} }
} }
+58 -63
View File
@@ -1,133 +1,128 @@
/** /**
* 内联装饰器 - 渲染 Diff 高亮和按钮 * 内联装饰器 - 在编辑器中渲染 Diff 红绿高亮
*
* 基于 FileChange.diffLines 在编辑器内显示:
* - 绿色背景 + gutter 圆点: 新增行
* - 红色背景 + 删除线: 删除行
* - 行末浮动标签: ✓ Accept 本次编辑 · ✗ Reject
*/ */
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { DiffChunk, ChunkType } from '../models/types'; import { FileChange, DiffLine } from '../models/types';
import { COLORS, DECORATION_TYPES } from '../models/constants'; import { COLORS } from '../models/constants';
export class InlineDecorator { export class InlineDecorator {
private addDecorationType: vscode.TextEditorDecorationType; private addDecorationType: vscode.TextEditorDecorationType;
private deleteDecorationType: vscode.TextEditorDecorationType; private deleteDecorationType: vscode.TextEditorDecorationType;
private modifyDecorationType: vscode.TextEditorDecorationType; private modifyDecorationType: vscode.TextEditorDecorationType;
private conflictDecorationType: vscode.TextEditorDecorationType; private acceptLabelType: vscode.TextEditorDecorationType;
private acceptButtonDecorationType: vscode.TextEditorDecorationType;
constructor() { constructor() {
// 新增行: 绿色背景
this.addDecorationType = vscode.window.createTextEditorDecorationType({ this.addDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.ADD_BACKGROUND, backgroundColor: COLORS.ADD_BACKGROUND,
isWholeLine: true, isWholeLine: true,
gutterIconPath: this.createGutterIconPath(COLORS.ADD_GUTTER), gutterIconPath: this.gutterIcon(COLORS.ADD_GUTTER),
gutterIconSize: 'contain', gutterIconSize: 'contain',
}); });
// 删除行: 红色背景 + 删除线
this.deleteDecorationType = vscode.window.createTextEditorDecorationType({ this.deleteDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.DELETE_BACKGROUND, backgroundColor: COLORS.DELETE_BACKGROUND,
isWholeLine: true, isWholeLine: true,
textDecoration: 'line-through', textDecoration: 'line-through',
gutterIconPath: this.createGutterIconPath(COLORS.DELETE_GUTTER), gutterIconPath: this.gutterIcon(COLORS.DELETE_GUTTER),
gutterIconSize: 'contain', gutterIconSize: 'contain',
}); });
// 修改行: 黄色背景
this.modifyDecorationType = vscode.window.createTextEditorDecorationType({ this.modifyDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.MODIFY_BACKGROUND, backgroundColor: COLORS.MODIFY_BACKGROUND,
isWholeLine: true, isWholeLine: true,
gutterIconPath: this.createGutterIconPath(COLORS.MODIFY_GUTTER),
gutterIconSize: 'contain',
}); });
this.conflictDecorationType = vscode.window.createTextEditorDecorationType({ // 浮动标签: 在新增行的末尾显示 ✓ Accept
backgroundColor: COLORS.CONFLICT_BACKGROUND, this.acceptLabelType = vscode.window.createTextEditorDecorationType({
isWholeLine: true,
borderColor: COLORS.MODIFY_GUTTER,
borderStyle: 'solid',
borderWidth: '1px',
});
this.acceptButtonDecorationType = vscode.window.createTextEditorDecorationType({
after: { after: {
contentText: ' ✓ Accept', contentText: ' ← AI 变更',
color: '#ffffff', color: '#888',
backgroundColor: COLORS.ADD_GUTTER, fontStyle: 'italic',
margin: '0 0 0 1em', margin: '0 0 0 16px',
border: '1px solid rgba(255,255,255,0.2)',
cursor: 'pointer',
}, },
isWholeLine: true,
}); });
} }
private createGutterIconPath(color: string): vscode.Uri {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<circle cx="8" cy="8" r="6" fill="${color}"/>
</svg>`;
return vscode.Uri.parse(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`);
}
/** /**
* 渲染变更块 * 在编辑器渲染文件的所有待处理变更
*/ */
renderChunks(editor: vscode.TextEditor, chunks: DiffChunk[]): void { renderFileChange(editor: vscode.TextEditor, fileChange: FileChange): void {
if (fileChange.status !== 'pending') {
this.clearEditor(editor);
return;
}
const addRanges: vscode.Range[] = []; const addRanges: vscode.Range[] = [];
const deleteRanges: vscode.Range[] = []; const deleteRanges: vscode.Range[] = [];
const modifyRanges: vscode.Range[] = []; const modifyRanges: vscode.Range[] = [];
const conflictRanges: vscode.Range[] = []; const labelRanges: vscode.Range[] = [];
const buttonRanges: vscode.Range[] = [];
chunks.forEach(chunk => { for (const d of fileChange.diffLines) {
if (chunk.status === 'accepted' || chunk.status === 'rejected') { if (d.type === 'context') continue;
return; // 跳过已处理的块
}
const range = new vscode.Range( // 使用 newLineNum(编辑器当前行号)
new vscode.Position(chunk.startLine, 0), const line = d.newLineNum > 0 ? d.newLineNum - 1 : d.oldLineNum - 1;
new vscode.Position(chunk.endLine, 0) if (line < 0) continue;
);
switch (chunk.type) { const range = new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER);
switch (d.type) {
case 'add': case 'add':
addRanges.push(range); addRanges.push(range);
buttonRanges.push(range); labelRanges.push(range);
break; break;
case 'delete': case 'delete':
deleteRanges.push(range); deleteRanges.push(range);
break; break;
case 'modify':
modifyRanges.push(range);
buttonRanges.push(range);
break;
} }
}
if (chunk.status === 'conflict') {
conflictRanges.push(range);
}
});
editor.setDecorations(this.addDecorationType, addRanges); editor.setDecorations(this.addDecorationType, addRanges);
editor.setDecorations(this.deleteDecorationType, deleteRanges); editor.setDecorations(this.deleteDecorationType, deleteRanges);
editor.setDecorations(this.modifyDecorationType, modifyRanges); editor.setDecorations(this.modifyDecorationType, modifyRanges);
editor.setDecorations(this.conflictDecorationType, conflictRanges); editor.setDecorations(this.acceptLabelType, labelRanges);
editor.setDecorations(this.acceptButtonDecorationType, buttonRanges);
} }
/** /**
* 清除所有装饰器 * 清除编辑器的所有装饰
*/ */
clearDecorations(editor: vscode.TextEditor): void { clearEditor(editor: vscode.TextEditor): void {
editor.setDecorations(this.addDecorationType, []); editor.setDecorations(this.addDecorationType, []);
editor.setDecorations(this.deleteDecorationType, []); editor.setDecorations(this.deleteDecorationType, []);
editor.setDecorations(this.modifyDecorationType, []); editor.setDecorations(this.modifyDecorationType, []);
editor.setDecorations(this.conflictDecorationType, []); editor.setDecorations(this.acceptLabelType, []);
editor.setDecorations(this.acceptButtonDecorationType, []);
} }
/** /**
* 释放资源 * 清除所有活跃编辑器的装饰
*/ */
clearAll(): void {
for (const editor of vscode.window.visibleTextEditors) {
this.clearEditor(editor);
}
}
dispose(): void { dispose(): void {
this.addDecorationType.dispose(); this.addDecorationType.dispose();
this.deleteDecorationType.dispose(); this.deleteDecorationType.dispose();
this.modifyDecorationType.dispose(); this.modifyDecorationType.dispose();
this.conflictDecorationType.dispose(); this.acceptLabelType.dispose();
this.acceptButtonDecorationType.dispose(); }
private gutterIcon(color: string): vscode.Uri {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<rect x="2" y="2" width="12" height="12" rx="3" fill="${color}" opacity="0.8"/>
</svg>`;
return vscode.Uri.parse(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`);
} }
} }
+164 -220
View File
@@ -1,39 +1,47 @@
/** /**
* Review 面板 - 内嵌 Webview 悬浮窗 * ★ 总览面板 — 展示所有变更文件的详细 Diff
* *
* ★ v0.2.0: 支持 per-edit 和 per-file 两级 Accept/Reject * 功能:
* - 每个文件卡片展开后显示各次编辑记录 * - 每个文件一个可折叠的卡片,默认展开
* - 每次编辑有独立的 ✓/✗ 按钮 * - 卡片内: 双栏 Diff(原始 ↔ 变更后)
* - 文件级 Accept/Reject 一键处理所有编辑 * - 文件级 Accept/Reject 按钮
* - 顶部全局 Accept All / Reject All
* - 全部处理后显示完成提示
*/ */
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { ChangeSetManager } from '../snapshot/snapshotManager'; import { ChangeSetManager } from '../snapshot/snapshotManager';
import { ChangeSet, FileChange, EditRecord, ChangeStatus } from '../models/types'; import { DiffViewer } from './diffViewer';
import { ChangeSet } from '../models/types';
export class ReviewPanel { export class ReviewPanel {
private panel: vscode.WebviewPanel | undefined; private panel: vscode.WebviewPanel | undefined;
private changeSet: ChangeSet | undefined;
constructor( constructor(
private changeSetManager: ChangeSetManager private changeSetManager: ChangeSetManager,
private diffViewer?: DiffViewer
) {} ) {}
/** 显示总览面板 */
show(): void { show(): void {
const changeSet = this.changeSetManager.getCurrentSet(); const cs = this.changeSetManager.getCurrentSet();
if (!changeSet || changeSet.changes.length === 0) { if (!cs || cs.changes.length === 0) {
vscode.window.showInformationMessage('AI Diff: 无变更');
return; return;
} }
this.changeSet = cs;
if (this.panel) { if (this.panel) {
this.panel.reveal(vscode.ViewColumn.Active); this.panel.reveal(vscode.ViewColumn.Beside);
this.sendData(changeSet); this.sendData(cs);
return; return;
} }
this.panel = vscode.window.createWebviewPanel( this.panel = vscode.window.createWebviewPanel(
'aiDiffReview', 'aiDiffOverview',
'AI Diff Review', 'AI Diff 总览',
{ viewColumn: vscode.ViewColumn.Active, preserveFocus: false }, { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
{ enableScripts: true, retainContextWhenHidden: true } { enableScripts: true, retainContextWhenHidden: true }
); );
@@ -42,30 +50,23 @@ export class ReviewPanel {
this.panel.webview.onDidReceiveMessage((msg: any) => { this.panel.webview.onDidReceiveMessage((msg: any) => {
switch (msg.type) { switch (msg.type) {
case 'ready': case 'ready':
this.sendData(changeSet); this.sendData(this.changeSet!);
break; break;
// 单次编辑 Accept/Reject
case 'acceptEdit':
this.changeSetManager.acceptEditRecord(msg.fileId, msg.editId);
this.refresh();
break;
case 'rejectEdit':
this.changeSetManager.rejectEditRecord(msg.fileId, msg.editId);
this.refresh();
break;
// 整个文件 Accept/Reject
case 'acceptFile': case 'acceptFile':
this.changeSetManager.acceptFile(msg.fileId); this.changeSetManager.acceptFile(msg.fileId);
this.refresh(); this.refresh();
// 如果有 DiffViewer 且与此文件匹配,关闭它
if (this.diffViewer?.getActiveSession()?.fileId === msg.fileId) {
this.diffViewer.closeDiff();
}
break; break;
case 'rejectFile': case 'rejectFile':
this.changeSetManager.rejectFile(msg.fileId); this.changeSetManager.rejectFile(msg.fileId);
this.refresh(); this.refresh();
if (this.diffViewer?.getActiveSession()?.fileId === msg.fileId) {
this.diffViewer.closeDiff();
}
break; break;
// 全局操作
case 'acceptAll': case 'acceptAll':
this.changeSetManager.acceptAll(); this.changeSetManager.acceptAll();
this.refresh(); this.refresh();
@@ -80,15 +81,16 @@ export class ReviewPanel {
this.panel.onDidDispose(() => { this.panel = undefined; }); this.panel.onDidDispose(() => { this.panel = undefined; });
} }
private refresh(): void { refresh(): void {
const changeSet = this.changeSetManager.getCurrentSet(); const cs = this.changeSetManager.getCurrentSet();
if (changeSet && this.panel) { if (cs && this.panel) {
this.sendData(changeSet); this.changeSet = cs;
this.sendData(cs);
} }
} }
private sendData(changeSet: ChangeSet): void { private sendData(cs: ChangeSet): void {
this.panel?.webview.postMessage({ type: 'init', changeSet }); this.panel?.webview.postMessage({ type: 'init', changeSet: cs });
} }
dispose(): void { dispose(): void {
@@ -96,101 +98,90 @@ export class ReviewPanel {
this.panel = undefined; this.panel = undefined;
} }
// ---- HTML ----
private getHtml(): string { private getHtml(): string {
return /*html*/`<!DOCTYPE html> return /*html*/`<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';">
<style> <style>
* { margin:0; padding:0; box-sizing:border-box; } *{margin:0;padding:0;box-sizing:border-box}
body { font-family:var(--vscode-font-family),sans-serif; font-size:13px; color:var(--vscode-foreground); background:var(--vscode-editor-background); } body{font-family:var(--vscode-font-family);font-size:13px;color:var(--vscode-foreground);background:var(--vscode-editor-background);padding:0}
.toolbar { .header{
display:flex; align-items:center; justify-content:space-between; display:flex;align-items:center;justify-content:space-between;
padding:10px 16px; background:var(--vscode-titleBar-activeBackground); padding:12px 16px;background:var(--vscode-titleBar-activeBackground);
border-bottom:1px solid var(--vscode-panel-border); position:sticky; top:0; z-index:10; border-bottom:1px solid var(--vscode-panel-border);position:sticky;top:0;z-index:10
} }
.toolbar h2 { font-size:14px; font-weight:600; } .header h2{font-size:15px;font-weight:600}
.toolbar .actions { display:flex; gap:8px; } .header .btn-row{display:flex;gap:8px}
.btn { .btn{
padding:6px 16px; border:none; border-radius:4px; cursor:pointer; padding:6px 14px;border:none;border-radius:4px;cursor:pointer;
font-size:12px; font-weight:500; transition:opacity .15s; font-size:12px;font-weight:600;transition:opacity .15s
} }
.btn:hover { opacity:.85; } .btn:hover{opacity:.85}
.btn-accept { background:#2ea043; color:#fff; } .btn-accept{background:#2ea043;color:#fff}
.btn-reject { background:#da3633; color:#fff; } .btn-reject{background:#da3633;color:#fff}
.files { padding:8px 0; } .files{padding:8px 0 24px}
.file-card { .file-card{
margin:6px 12px; border:1px solid var(--vscode-panel-border); margin:8px 10px;border:1px solid var(--vscode-panel-border);
border-radius:6px; overflow:hidden; border-radius:8px;overflow:hidden
} }
.file-header { .file-card.accepted{opacity:.6;border-color:#2ea043}
display:flex; align-items:center; justify-content:space-between; .file-card.rejected{opacity:.5;border-color:#da3633;text-decoration:line-through}
padding:8px 12px; background:var(--vscode-sideBar-background);
border-bottom:1px solid var(--vscode-panel-border); cursor:pointer;
}
.file-path { font-weight:500; display:flex; align-items:center; gap:8px; }
.badge {
font-size:10px; padding:2px 6px; border-radius:3px; font-weight:600;
}
.badge-modify { background:rgba(210,153,34,.2); color:#d29922; }
.badge-create { background:rgba(46,160,67,.2); color:#3fb950; }
.badge-accepted { background:rgba(46,160,67,.3); color:#3fb950; }
.badge-rejected { background:rgba(248,81,73,.3); color:#f85149; text-decoration:line-through; }
.file-actions { display:flex; gap:6px; }
.btn-sm { padding:3px 10px; border:none; border-radius:3px; cursor:pointer; font-size:11px; }
/* Diff 双栏 */ .file-header{
.diff-wrap { display:grid; grid-template-columns:1fr 1fr; font-family:'Cascadia Code','Fira Code',Consolas,monospace; font-size:12px; line-height:20px; } display:flex;align-items:center;justify-content:space-between;
.diff-side { overflow-x:auto; } padding:10px 14px;background:var(--vscode-sideBar-background);
.diff-side.left { border-right:1px solid var(--vscode-panel-border); } cursor:pointer;user-select:none
.diff-title {
padding:4px 12px; font-size:11px; font-weight:600;
color:var(--vscode-descriptionForeground); border-bottom:1px solid var(--vscode-panel-border);
} }
.diff-side.left .diff-title { background:rgba(248,81,73,.06); } .file-header:hover{background:var(--vscode-list-hoverBackground)}
.diff-side.right .diff-title { background:rgba(46,160,67,.06); } .file-title{display:flex;align-items:center;gap:8px;font-weight:600}
.diff-line { display:flex; min-height:20px; } .badge{
.ln { min-width:36px; padding:0 8px; text-align:right; color:var(--vscode-editorLineNumber-foreground); user-select:none; flex-shrink:0; } font-size:10px;padding:2px 7px;border-radius:3px;font-weight:700
.lc { padding:0 10px; white-space:pre; flex:1; } }
.diff-line.add { background:rgba(46,160,67,.12); } .badge-mod{background:rgba(210,153,34,.2);color:#d29922}
.diff-line.del { background:rgba(248,81,73,.12); } .badge-new{background:rgba(46,160,67,.2);color:#3fb950}
.diff-line.ctx { opacity:.55; } .badge-del{background:rgba(248,81,73,.2);color:#f85149}
.diff-side.left .diff-line.add { background:transparent; } .badge-ok{background:rgba(46,160,67,.3);color:#3fb950}
.diff-side.right .diff-line.del { background:transparent; } .badge-no{background:rgba(248,81,73,.3);color:#f85149}
.empty-line { min-height:20px; } .file-stats{font-size:11px;display:flex;gap:8px;color:var(--vscode-descriptionForeground)}
.stat-add{color:#3fb950}
.stat-del{color:#f85149}
.file-actions{display:flex;gap:6px}
.btn-sm{padding:3px 10px;border:none;border-radius:3px;cursor:pointer;font-size:11px;font-weight:600}
/* ★ 编辑记录列表 */ .diff-wrap{display:grid;grid-template-columns:1fr 1fr}
.edits-list { padding:0; } .diff-table{font-family:'Cascadia Code',Consolas,monospace;font-size:12px;line-height:20px;border-collapse:collapse;width:100%}
.edit-item { .diff-table td{padding:0 8px;white-space:pre;vertical-align:top}
border-bottom:1px solid var(--vscode-panel-border); .diff-table td.ln{text-align:right;color:var(--vscode-editorLineNumber-foreground);min-width:36px;user-select:none}
.diff-table tr.add{background:rgba(46,160,67,.15)}
.diff-table tr.del{background:rgba(248,81,73,.15)}
.diff-table tr.ctx{color:var(--vscode-descriptionForeground)}
.diff-table tr.empty{height:20px}
.diff-side-title{
padding:6px 12px;font-size:11px;font-weight:600;
border-bottom:1px solid var(--vscode-panel-border)
} }
.edit-item:last-child { border-bottom:none; } .left-title{background:rgba(248,81,73,.06);color:#f85149}
.edit-header { .right-title{background:rgba(46,160,67,.06);color:#3fb950}
display:flex; align-items:center; justify-content:space-between;
padding:6px 12px; background:var(--vscode-editor-background); cursor:pointer;
font-size:12px;
}
.edit-header:hover { background:var(--vscode-list-hoverBackground); }
.edit-label { display:flex; align-items:center; gap:6px; color:var(--vscode-descriptionForeground); }
.edit-label .edit-num { font-weight:600; color:var(--vscode-foreground); }
.edit-status { font-size:10px; }
.edit-actions { display:flex; gap:4px; }
.btn-xs { padding:2px 8px; border:none; border-radius:3px; cursor:pointer; font-size:10px; }
.summary-stats { font-size:11px; color:var(--vscode-descriptionForeground); padding:4px 12px; } .empty-state{text-align:center;padding:40px;color:var(--vscode-descriptionForeground)}
.stat-add { color:#3fb950; } .empty-state .icon{font-size:32px}
.stat-del { color:#f85149; } .empty-state h3{margin:12px 0 4px}
</style> </style>
</head> </head>
<body> <body>
<div class="toolbar">
<h2>🔍 AI Diff Review</h2> <div class="header">
<div class="actions"> <h2>🔍 AI Diff 总览</h2>
<button class="btn btn-accept" onclick="acceptAll()">✓ Accept All</button> <div class="btn-row">
<button class="btn btn-reject" onclick="rejectAll()">✗ Reject All</button> <button class="btn btn-accept" onclick="acceptAll()">$(check-all) Accept All</button>
<button class="btn btn-reject" onclick="rejectAll()">$(trash) Reject All</button>
</div> </div>
</div> </div>
<div class="files" id="files"></div> <div class="files" id="files"></div>
@@ -198,7 +189,6 @@ export class ReviewPanel {
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
let cs = null; let cs = null;
vscode.postMessage({type:'ready'}); vscode.postMessage({type:'ready'});
window.addEventListener('message', e => { window.addEventListener('message', e => {
@@ -206,146 +196,100 @@ window.addEventListener('message', e => {
if (m.type === 'init') { cs = m.changeSet; render(); } if (m.type === 'init') { cs = m.changeSet; render(); }
}); });
function acceptAll(){ vscode.postMessage({type:'acceptAll'}); } function acceptAll(){ vscode.postMessage({type:'acceptAll'}) }
function rejectAll(){ vscode.postMessage({type:'rejectAll'}); } function rejectAll(){ vscode.postMessage({type:'rejectAll'}) }
function acceptFile(id){ vscode.postMessage({type:'acceptFile',fileId:id}); } function acceptFile(id){ vscode.postMessage({type:'acceptFile',fileId:id}) }
function rejectFile(id){ vscode.postMessage({type:'rejectFile',fileId:id}); } function rejectFile(id){ vscode.postMessage({type:'rejectFile',fileId:id}) }
function acceptEdit(fid, eid){ vscode.postMessage({type:'acceptEdit',fileId:fid,editId:eid}); }
function rejectEdit(fid, eid){ vscode.postMessage({type:'rejectEdit',fileId:fid,editId:eid}); }
function render(){ function render(){
if(!cs) return; if(!cs) return;
const el = document.getElementById('files'); const el = document.getElementById('files');
el.innerHTML = ''; const pending = cs.changes.filter(c => c.status === 'pending');
cs.changes.forEach(c => el.appendChild(card(c))); const done = cs.changes.filter(c => c.status !== 'pending');
if(cs.changes.length === 0){
el.innerHTML = '<div class="empty-state"><div class="icon">📭</div><h3>无变更</h3><p>所有文件均已处理</p></div>';
return;
}
if(pending.length === 0){
el.innerHTML = '<div class="empty-state"><div class="icon">🎉</div><h3>全部处理完毕</h3><p>' + cs.changes.length + ' 个文件已处理</p></div>';
return;
}
let html = '';
for(const c of cs.changes){
html += renderCard(c);
}
el.innerHTML = html;
} }
function card(c){ function renderCard(c){
const d = document.createElement('div'); const classes = c.status === 'accepted' ? 'file-card accepted' : c.status === 'rejected' ? 'file-card rejected' : 'file-card';
d.className = 'file-card'; const name = c.filePath.split(/[\\\\/]/).pop();
const name = c.filePath.split(/[/\\\\]/).pop(); const typeMap = {modify:['修改','badge-mod'],create:['新增','badge-new'],delete:['删除','badge-del']};
const typeMap = {modify:'修改',create:'新增',delete:'删除'}; const [tlabel,tcls] = typeMap[c.type] || [c.type,''];
const badgeCls = 'badge badge-' + c.type;
let statusBadge = ''; let statusBadge = '';
if(c.status==='accepted') statusBadge = '<span class="badge badge-accepted">已接受</span>'; if(c.status==='accepted') statusBadge = '<span class="badge badge-ok">已接受</span>';
if(c.status==='rejected') statusBadge = '<span class="badge badge-rejected">已拒绝</span>'; if(c.status==='rejected') statusBadge = '<span class="badge badge-no">已拒绝</span>';
// 统计增减行数 let adds=0,dels=0;
let adds = 0, dels = 0; c.diffLines.forEach(l => { if(l.type==='add')adds++; if(l.type==='delete')dels++; });
c.diffLines.forEach(l => { if(l.type==='add') adds++; if(l.type==='delete') dels++; });
d.innerHTML = const actions = c.status === 'pending' ?
'<div class="file-header" onclick="this.nextElementSibling.classList.toggle(' + "'" + 'collapsed' + "'" + ')">' + '<span class="file-actions" onclick="event.stopPropagation()">' +
'<span class="file-path">📄 ' + esc(name) + ' <span class="' + badgeCls + '">' + typeMap[c.type] + '</span>' + statusBadge + '</span>' + '<button class="btn-sm btn-accept" onclick="acceptFile(\'' + c.id + '\')">✔ 接受</button>' +
'<span class="summary-stats"><span class="stat-add">+' + adds + '</span> <span class="stat-del">-' + dels + '</span></span>' + '<button class="btn-sm btn-reject" onclick="rejectFile(\'' + c.id + '\')">✘ 拒绝</button>' +
(c.status==='pending' ? '</span>' : '';
'<span class="file-actions">' +
'<button class="btn-sm btn-accept" onclick="event.stopPropagation();acceptFile(' + "'" + c.id + "'" + ')">✓ 全部接受</button>' + return '<div class="' + classes + '">' +
'<button class="btn-sm btn-reject" onclick="event.stopPropagation();rejectFile(' + "'" + c.id + "'" + ')">✗ 全部拒绝</button>' + '<div class="file-header" onclick="this.nextElementSibling.classList.toggle(\'hidden\')">' +
'</span>' : '' '<span class="file-title">📄 ' + esc(name) + ' <span class="badge ' + tcls + '">' + tlabel + '</span>' + statusBadge + '</span>' +
) + '<span class="file-stats"><span class="stat-add">+' + adds + '</span> <span class="stat-del">-' + dels + '</span> · ' + (c.edits||[]).length + ' 编辑</span>' +
actions +
'</div>' + '</div>' +
'<div class="diff-body">' + '<div class="">' + buildDiff(c) + '</div>' +
buildEdits(c) +
'<div style="padding:6px 12px;font-size:11px;color:var(--vscode-descriptionForeground);border-top:1px solid var(--vscode-panel-border);">📊 聚合 Diff(原始 → 最新)</div>' +
buildDiff(c) +
'</div>';
return d;
}
/** ★ 构建编辑记录列表 */
function buildEdits(c){
if(!c.edits || c.edits.length === 0) return '';
const toolMap = {Edit:'✏️ 编辑', Write:'📝 写入'};
let h = '<div class="edits-list">';
c.edits.forEach((edit, i) => {
let statusCls = '', statusText = '';
if(edit.status==='accepted'){ statusCls='badge-accepted'; statusText='已接受'; }
if(edit.status==='rejected'){ statusCls='badge-rejected'; statusText='已拒绝'; }
let editAdds = 0, editDels = 0;
(edit.diffLines||[]).forEach(l => { if(l.type==='add') editAdds++; if(l.type==='delete') editDels++; });
h += '<div class="edit-item">' +
'<div class="edit-header" onclick="event.stopPropagation();this.nextElementSibling.classList.toggle(' + "'" + 'collapsed' + "'" + ')">' +
'<span class="edit-label">' +
'<span class="edit-num">#' + (i+1) + '</span> ' + (toolMap[edit.toolName]||edit.toolName) +
' <span class="stat-add">+' + editAdds + '</span> <span class="stat-del">-' + editDels + '</span>' +
(statusText ? ' <span class="badge ' + statusCls + ' edit-status">' + statusText + '</span>' : '') +
'</span>' +
(edit.status==='pending' ?
'<span class="edit-actions">' +
'<button class="btn-xs btn-accept" onclick="event.stopPropagation();acceptEdit(' + "'" + c.id + "','" + edit.id + "'" + ')">✓</button>' +
'<button class="btn-xs btn-reject" onclick="event.stopPropagation();rejectEdit(' + "'" + c.id + "','" + edit.id + "'" + ')">✗</button>' +
'</span>' : ''
) +
'</div>' +
'<div class="">' + buildEditDiff(edit) + '</div>' +
'</div>';
});
h += '</div>';
return h;
}
/** 单次编辑的 Diff */
function buildEditDiff(edit){
let lh='', rh='';
(edit.diffLines||[]).forEach(l => {
if(l.type==='context'){
lh += '<div class="diff-line ctx"><span class="ln">'+l.oldLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>';
rh += '<div class="diff-line ctx"><span class="ln">'+l.newLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>';
} else if(l.type==='delete'){
lh += '<div class="diff-line del"><span class="ln">'+l.oldLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>';
rh += '<div class="diff-line empty-line"></div>';
} else if(l.type==='add'){
lh += '<div class="diff-line empty-line"></div>';
rh += '<div class="diff-line add"><span class="ln">'+l.newLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>';
}
});
return '<div class="diff-wrap">' +
'<div class="diff-side left"><div class="diff-title">编辑前</div>'+lh+'</div>' +
'<div class="diff-side right"><div class="diff-title">编辑后</div>'+rh+'</div>' +
'</div>'; '</div>';
} }
function buildDiff(c){ function buildDiff(c){
if(c.type==='create') return buildSingle(c.latestContent.split('\\n'),'right','新文件'); if(c.type === 'create') return buildSingle(c.latestContent, '新文件内容 (右侧)');
if(c.type==='delete') return buildSingle(c.originalContent.split('\\n'),'left','已删除'); if(c.type === 'delete') return buildSingle(c.originalContent, '已删除 (原始内容)');
return buildDual(c.diffLines||[]); return buildDual(c.diffLines || []);
} }
function buildSingle(lines,side,title){ function buildSingle(text, label){
let h = '<div class="diff-wrap"><div class="diff-side ' + side + '"><div class="diff-title">' + title + '</div>'; const lines = text.split('\\n');
lines.forEach((l,i) => { let rows = '';
h += '<div class="diff-line add"><span class="ln">' + (i+1) + '</span><span class="lc">' + esc(l) + '</span></div>'; lines.forEach((l,i) => { rows += '<tr class="add"><td class="ln">'+(i+1)+'</td><td>'+esc(l)+'</td></tr>'; });
}); return '<div style="padding:6px 12px;font-size:11px;color:var(--vscode-descriptionForeground);border-bottom:1px solid var(--vscode-panel-border)">📄 '+label+'</div>' +
return h + '</div></div>'; '<table class="diff-table">'+rows+'</table>';
} }
function buildDual(dlines){ function buildDual(dlines){
let lh='', rh=''; let left='',right='';
dlines.forEach(l => { dlines.forEach(l => {
if(l.type==='context'){ if(l.type==='context'){
lh += '<div class="diff-line ctx"><span class="ln">'+l.oldLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>'; left += '<tr class="ctx"><td class="ln">'+l.oldLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
rh += '<div class="diff-line ctx"><span class="ln">'+l.newLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>'; right += '<tr class="ctx"><td class="ln">'+l.newLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
} else if(l.type==='delete'){ } else if(l.type==='delete'){
lh += '<div class="diff-line del"><span class="ln">'+l.oldLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>'; left += '<tr class="del"><td class="ln">'+l.oldLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
rh += '<div class="diff-line empty-line"></div>'; right += '<tr class="empty"><td></td><td></td></tr>';
} else if(l.type==='add'){ } else if(l.type==='add'){
lh += '<div class="diff-line empty-line"></div>'; left += '<tr class="empty"><td></td><td></td></tr>';
rh += '<div class="diff-line add"><span class="ln">'+l.newLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>'; right += '<tr class="add"><td class="ln">'+l.newLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
} }
}); });
return '<div class="diff-wrap">' + return '<div class="diff-wrap">' +
'<div class="diff-side left"><div class="diff-title">原始代码</div>'+lh+'</div>' + '<div><div class="diff-side-title left-title">— 原始代码</div><table class="diff-table">'+left+'</table></div>' +
'<div class="diff-side right"><div class="diff-title">变更后</div>'+rh+'</div>' + '<div><div class="diff-side-title right-title">+ 变更后</div><table class="diff-table">'+right+'</table></div>' +
'</div>'; '</div>';
} }
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
</script> </script>
<style>.collapsed .diff-body{display:none;} .collapsed{display:none;}</style> <style>.hidden{display:none !important}</style>
</body> </body>
</html>`; </html>`;
} }
+106 -49
View File
@@ -1,70 +1,127 @@
/** /**
* 状态栏管理器 - 显示 Diff 状态信息 * 状态栏管理器 — 常驻显示
*
* 始终在左侧状态栏显示 AI Diff 状态:
* - 有 pending 变更: $(diff) AI Diff: N文件 M编辑 (黄色) — 点击打开文件列表
* - Diff 活跃: 右侧额外显示 Accept/Reject 按钮
* - 无变更: $(diff) AI Diff (暗色) — 点击打开文件列表
*/ */
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { Snapshot } from '../models/types'; import { ChangeSetManager } from '../snapshot/snapshotManager';
import { STATUS_BAR_PRIORITY } from '../models/constants'; import { DiffViewer, DiffSession } from './diffViewer';
import { COMMANDS } from '../models/constants';
export class StatusBarManager { export class StatusBarManager {
private statusBarItem: vscode.StatusBarItem; /** ★ 常驻摘要按钮 */
private chunkCountItem: vscode.StatusBarItem; private summaryItem: vscode.StatusBarItem;
/** Diff 模式 Accept 按钮 */
private acceptBtn: vscode.StatusBarItem;
/** Diff 模式 Reject 按钮 */
private rejectBtn: vscode.StatusBarItem;
/** Diff 模式 Accept All 按钮 */
private acceptAllBtn: vscode.StatusBarItem;
constructor() { constructor(
this.statusBarItem = vscode.window.createStatusBarItem( private changeSetManager: ChangeSetManager,
private diffViewer: DiffViewer
) {
// ★ 常驻摘要 — 始终显示
this.summaryItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left, vscode.StatusBarAlignment.Left,
STATUS_BAR_PRIORITY.DIFF_STATUS 100
); );
this.summaryItem.command = COMMANDS.SHOW_DIFF_PANEL;
this.summaryItem.text = '$(diff) AI Diff';
this.summaryItem.tooltip = 'AI Diff — 点击查看变更列表';
this.summaryItem.show();
this.chunkCountItem = vscode.window.createStatusBarItem( // Accept 按钮
vscode.StatusBarAlignment.Left, this.acceptBtn = vscode.window.createStatusBarItem(
STATUS_BAR_PRIORITY.CHUNK_COUNT vscode.StatusBarAlignment.Right,
100
); );
this.acceptBtn.command = 'aiDiffPreview.acceptCurrentDiff';
// Reject 按钮
this.rejectBtn = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Right,
99
);
this.rejectBtn.command = 'aiDiffPreview.rejectCurrentDiff';
// Accept All 按钮
this.acceptAllBtn = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Right,
98
);
this.acceptAllBtn.command = 'aiDiffPreview.acceptAllFromDiff';
} }
/** enterDiffMode(session: DiffSession): void {
* 更新状态栏 const label = session.editId ? '编辑' : '文件';
*/ this.acceptBtn.text = `$(check) Accept 本次${label}变更`;
update(snapshot: Snapshot): void { this.acceptBtn.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
const pendingChunks = snapshot.chunks.filter(c => c.status === 'pending'); this.acceptBtn.tooltip = `接受此${label}的变更并跳转到下一个`;
const conflictChunks = snapshot.chunks.filter(c => c.status === 'conflict'); this.acceptBtn.show();
// 更新主状态 this.rejectBtn.text = `$(close) Reject 本次${label}变更`;
this.statusBarItem.text = '$(diff) AI Diff'; this.rejectBtn.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
this.statusBarItem.tooltip = 'AI Diff Preview 活跃'; this.rejectBtn.tooltip = `拒绝此${label}的变更并跳转到下一个`;
this.statusBarItem.show(); this.rejectBtn.show();
// 更新变更块数量 this.acceptAllBtn.text = '$(check-all) 接受此文件全部';
if (pendingChunks.length > 0 || conflictChunks.length > 0) { this.acceptAllBtn.tooltip = '接受此文件的所有编辑';
this.chunkCountItem.text = `$(edit) ${pendingChunks.length} 个变更`; this.acceptAllBtn.show();
if (conflictChunks.length > 0) { }
this.chunkCountItem.text += ` (${conflictChunks.length} 个冲突)`;
this.chunkCountItem.backgroundColor = new vscode.ThemeColor( exitDiffMode(): void {
'statusBarItem.warningBackground' this.acceptBtn.hide();
); this.rejectBtn.hide();
} else { this.acceptAllBtn.hide();
this.chunkCountItem.backgroundColor = undefined; }
}
this.chunkCountItem.tooltip = '点击查看变更详情'; /** ★ 常驻刷新 */
this.chunkCountItem.show(); refresh(): void {
const session = this.diffViewer.getActiveSession();
if (session) {
this.enterDiffMode(session);
} else { } else {
this.chunkCountItem.hide(); this.exitDiffMode();
} }
const set = this.changeSetManager.getCurrentSet();
if (!set) {
// ★ 无变更集也常驻显示
this.summaryItem.text = '$(diff) AI Diff';
this.summaryItem.tooltip = 'AI Diff — 等待变更';
this.summaryItem.backgroundColor = undefined;
this.summaryItem.show();
return;
}
const pendingFiles = set.changes.filter(c => c.status === 'pending');
const totalEdits = pendingFiles.reduce(
(sum, c) => sum + c.edits.filter(e => e.status === 'pending').length, 0
);
if (pendingFiles.length === 0 && set.changes.length > 0) {
this.summaryItem.text = '$(diff) AI Diff: 已完成';
this.summaryItem.tooltip = '所有变更已处理';
this.summaryItem.backgroundColor = undefined;
} else if (pendingFiles.length > 0) {
this.summaryItem.text = `$(diff) AI Diff: ${pendingFiles.length}文件 ${totalEdits}编辑`;
this.summaryItem.tooltip = `点击查看变更列表\n${pendingFiles.map(c => `• ${c.filePath} (${c.edits.length}次)`).join('\n')}`;
this.summaryItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
}
this.summaryItem.show();
} }
/**
* 清除状态栏
*/
clear(): void {
this.statusBarItem.hide();
this.chunkCountItem.hide();
}
/**
* 释放资源
*/
dispose(): void { dispose(): void {
this.statusBarItem.dispose(); this.summaryItem.dispose();
this.chunkCountItem.dispose(); this.acceptBtn.dispose();
this.rejectBtn.dispose();
this.acceptAllBtn.dispose();
} }
} }
+88 -146
View File
@@ -19,7 +19,6 @@ function norm(p: string): string {
export class ChangeSetManager { export class ChangeSetManager {
private currentSet: ChangeSet | null = null; private currentSet: ChangeSet | null = null;
/** 原始文件内容快照(每个文件第一次编辑前捕获) */
private originalContents: Map<string, string> = new Map(); private originalContents: Map<string, string> = new Map();
startNewSet(): string { startNewSet(): string {
@@ -49,59 +48,43 @@ export class ChangeSetManager {
} }
/** /**
* ★ 记录一次文件编辑(重构后) * ★ 记录一次文件编辑
* *
* 核心逻辑: * 合并规则:
* 1. 每次 Edit/Write 创建独立 EditRecord * - 优先找同文件 pending 的 FileChange → 追加 EditRecord,更新聚合 diff
* 2. 如果该文件已有 pending 的 FileChange → 追加 EditRecord * - 没有 pending 的 → 创建新 FileChange(首次编辑 / 前轮已处理完)
* 3. 如果该文件的 FileChange 已 accepted/rejected → 创建新 FileChange(修复 Bug)
* 4. 如果该文件没有 FileChange → 创建新 FileChange
*/ */
recordEdit(filePath: string, toolName: string, oldString: string, newString: string, _content: string): void { recordEdit(filePath: string, toolName: string, oldString: string, newString: string, _content: string): void {
if (!this.currentSet) { if (!this.currentSet) this.startNewSet();
this.startNewSet();
}
filePath = norm(filePath); filePath = norm(filePath);
const currentContent = this.readFile(filePath) || ''; const currentContent = this.readFile(filePath) || '';
const now = Date.now();
console.log(`[AI Diff] recordEdit: ${toolName} → ${filePath}`); // ★ 优先匹配 pending 的 FileChange
const sameFileChanges = this.currentSet!.changes.filter(c => norm(c.filePath) === filePath);
const pendingChange = sameFileChanges.find(c => c.status === 'pending');
const hasNonPending = sameFileChanges.some(c => c.status !== 'pending');
// Step 1: 查找该文件是否已有变更记录 console.log(`[AI Diff] recordEdit: ${toolName} → ${filePath} (pending=${!!pendingChange}, nonPending=${hasNonPending})`);
const existingChange = this.currentSet!.changes.find(c => norm(c.filePath) === filePath);
// ★ 如果已有变更记录且已 accepted/rejected,需要重置缓存以重新捕获 // 没有 pending → 清除原始缓存重新捕获
if (existingChange && existingChange.status !== 'pending') { if (!pendingChange) this.originalContents.delete(filePath);
this.originalContents.delete(filePath);
}
// Step 2: 确保已捕获原始内容
const capturedOriginal = this.captureOriginalContent(filePath, toolName, oldString, newString, currentContent); const capturedOriginal = this.captureOriginalContent(filePath, toolName, oldString, newString, currentContent);
// Step 3: 确定本次编辑的 beforeContent // 确定 beforeContent
let beforeContent: string; let beforeContent: string;
if (pendingChange) {
if (existingChange) { // ★ 合并:before = 上一次编辑后的文件状态
const lastEdit = existingChange.edits[existingChange.edits.length - 1]; beforeContent = pendingChange.latestContent;
if (existingChange.status === 'pending') {
// 还在收集中,before 使用上一次编辑的 afterContent
beforeContent = lastEdit.afterContent;
} else {
// ★ Bug 修复:已 accepted/rejected,需要新的 beforeContent
// 重新捕获:当前文件内容 = 本次编辑后的结果
beforeContent = this.reverseEdit(currentContent, oldString, newString);
}
} else { } else {
// 首次编辑该文件 // 新追踪:before = 从当前文件反推的原始内容
beforeContent = capturedOriginal; beforeContent = capturedOriginal;
} }
// Step 3: 计算本次编辑的独立 Diff
const afterContent = currentContent; const afterContent = currentContent;
const editDiffLines = this.computeDiffLines(beforeContent, afterContent); const editDiffLines = this.computeDiffLines(beforeContent, afterContent);
// Step 4: 创建 EditRecord
const editRecord: EditRecord = { const editRecord: EditRecord = {
id: uuidv4(), id: uuidv4(),
filePath, filePath,
@@ -112,23 +95,18 @@ export class ChangeSetManager {
newString: newString || '', newString: newString || '',
diffLines: editDiffLines, diffLines: editDiffLines,
status: 'pending', status: 'pending',
timestamp: now, timestamp: Date.now(),
}; };
// Step 5: 决定 FileChange 策略 if (pendingChange) {
if (existingChange && existingChange.status === 'pending') { // ★ 追加到已有 pending FileChange,更新聚合 diff
// 追加到已有 FileChange pendingChange.edits.push(editRecord);
existingChange.edits.push(editRecord); pendingChange.latestContent = afterContent;
existingChange.latestContent = afterContent; pendingChange.diffLines = this.computeDiffLines(pendingChange.originalContent, afterContent);
existingChange.diffLines = this.computeDiffLines(existingChange.originalContent, afterContent);
console.log(`[AI Diff] 追加 EditRecord 到已有 FileChange: ${filePath}, edits=${existingChange.edits.length}`);
} else { } else {
// 创建新 FileChange(首次编辑 或 之前的已 accepted/rejected) // ★ 新建 FileChange
const changeType: FileChangeType = (toolName === 'Write' && !capturedOriginal) const changeType: FileChangeType = (toolName === 'Write' && !capturedOriginal) ? 'create' : 'modify';
? 'create' const fc: FileChange = {
: 'modify';
const fileChange: FileChange = {
id: uuidv4(), id: uuidv4(),
filePath, filePath,
type: changeType, type: changeType,
@@ -138,85 +116,57 @@ export class ChangeSetManager {
diffLines: editDiffLines, diffLines: editDiffLines,
status: 'pending', status: 'pending',
}; };
this.currentSet!.changes.push(fc);
this.currentSet!.changes.push(fileChange);
if (existingChange) {
console.log(`[AI Diff] ★ 新建 FileChange(旧 FileChange 已 ${existingChange.status}): ${filePath}`);
} else {
console.log(`[AI Diff] 新建 FileChange: ${filePath}, type=${changeType}`);
}
} }
} }
/**
* 捕获文件的原始内容
*/
private captureOriginalContent( private captureOriginalContent(
filePath: string, filePath: string, toolName: string, oldString: string, newString: string, currentContent: string
toolName: string,
oldString: string,
newString: string,
currentContent: string
): string { ): string {
if (this.originalContents.has(filePath)) { if (this.originalContents.has(filePath)) {
return this.originalContents.get(filePath)!; return this.originalContents.get(filePath)!;
} }
let original: string; let original: string;
if (toolName === 'Write') { if (toolName === 'Write') {
// Write: 无法获取旧内容
original = ''; original = '';
} else if (toolName === 'Edit' && oldString) { } else if (toolName === 'Edit' && oldString) {
// Edit: 从当前文件中反推原始内容
const idx = currentContent.indexOf(newString); const idx = currentContent.indexOf(newString);
if (idx !== -1) { if (idx !== -1) {
original = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length); original = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length);
} else { } else {
// newString 不在文件中,可能已被之前的编辑影响
original = oldString; original = oldString;
} }
} else { } else {
original = currentContent; original = currentContent;
} }
this.originalContents.set(filePath, original); this.originalContents.set(filePath, original);
console.log(`[AI Diff] 捕获原始内容: ${filePath} (${original.length} 字符)`); console.log(`[AI Diff] 捕获原始内容: ${filePath} (${original.length} 字符)`);
return original; return original;
} }
/**
* 反推编辑前的内容(用于已 accepted/rejected 后新编辑的场景)
* currentContent = 编辑后内容,reverseEdit = 从后往前还原
*/
private reverseEdit(currentContent: string, oldString: string, newString: string): string { private reverseEdit(currentContent: string, oldString: string, newString: string): string {
if (!newString) return currentContent; if (!newString) return currentContent;
const idx = currentContent.indexOf(newString); const idx = currentContent.indexOf(newString);
if (idx !== -1) { if (idx !== -1) {
const before = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length); return currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length);
return before;
} }
return currentContent; return currentContent;
} }
// ---- 状态更新 ---- // ---- 状态更新 ----
/** 更新单个编辑记录的状态 */
updateEditStatus(fileId: string, editId: string, status: ChangeStatus): void { updateEditStatus(fileId: string, editId: string, status: ChangeStatus): void {
if (!this.currentSet) return; if (!this.currentSet) return;
const change = this.currentSet.changes.find(c => c.id === fileId); const change = this.currentSet.changes.find(c => c.id === fileId);
if (!change) return; if (!change) return;
const edit = change.edits.find(e => e.id === editId); const edit = change.edits.find(e => e.id === editId);
if (edit) edit.status = status; if (edit) edit.status = status;
// 如果该文件所有编辑都已处理,更新文件状态
if (change.edits.every(e => e.status !== 'pending')) { if (change.edits.every(e => e.status !== 'pending')) {
const allAccepted = change.edits.every(e => e.status === 'accepted'); const allAccepted = change.edits.every(e => e.status === 'accepted');
change.status = allAccepted ? 'accepted' : 'rejected'; change.status = allAccepted ? 'accepted' : 'rejected';
} }
} }
/** 更新整个文件的状态(同时更新所有编辑) */
updateFileStatus(fileId: string, status: ChangeStatus): void { updateFileStatus(fileId: string, status: ChangeStatus): void {
if (!this.currentSet) return; if (!this.currentSet) return;
const change = this.currentSet.changes.find(c => c.id === fileId); const change = this.currentSet.changes.find(c => c.id === fileId);
@@ -234,132 +184,132 @@ export class ChangeSetManager {
}); });
} }
// ---- Accept(应用到磁盘) ---- // ---- Accept ----
/**
* 接受单个编辑 — 内容已在磁盘上,只需标记状态
*/
acceptEditRecord(fileId: string, editId: string): boolean { acceptEditRecord(fileId: string, editId: string): boolean {
this.updateEditStatus(fileId, editId, 'accepted');
const change = this.findChange(fileId); const change = this.findChange(fileId);
if (!change) return false; if (!change) return false;
const edit = change.edits.find(e => e.id === editId);
// 如果整个文件所有编辑都已接受,写入最终的 latestContent if (!edit) return false;
// 此 edit 变更已在磁盘上,只需标记状态
this.updateEditStatus(fileId, editId, 'accepted');
if (change.edits.every(e => e.status === 'accepted')) { if (change.edits.every(e => e.status === 'accepted')) {
return this.writeFile(change.filePath, change.latestContent); return this.writeFile(change.filePath, change.latestContent);
} }
return true; return true;
} }
/** /** ★ 接受整个文件:先写入再标记 */
* 接受整个文件的所有编辑
*/
acceptFile(fileId: string): boolean { acceptFile(fileId: string): boolean {
this.updateFileStatus(fileId, 'accepted');
const change = this.findChange(fileId); const change = this.findChange(fileId);
if (!change) return false; if (!change) return false;
return this.writeFile(change.filePath, change.latestContent); const ok = this.writeFile(change.filePath, change.latestContent);
if (!ok) return false;
this.updateFileStatus(fileId, 'accepted');
return true;
} }
/** /** ★ 接受所有 pending 的文件 */
* 接受所有变更
*/
acceptAll(): number { acceptAll(): number {
if (!this.currentSet) return 0; if (!this.currentSet) return 0;
// snapshot pending list before looping (loop won't affect already-processed items)
const pendingIds = this.currentSet.changes.filter(c => c.status === 'pending').map(c => c.id);
let count = 0; let count = 0;
for (const change of this.currentSet.changes) { for (const id of pendingIds) {
if (change.status === 'pending' && this.acceptFile(change.id)) { if (this.acceptFile(id)) count++;
count++;
}
} }
return count; return count;
} }
// ---- Reject(恢复原始内容) ---- // ---- Reject ----
/**
* 拒绝单个编辑 — 从磁盘文件中还原该编辑的变更
*/
rejectEditRecord(fileId: string, editId: string): boolean { rejectEditRecord(fileId: string, editId: string): boolean {
const change = this.findChange(fileId); const change = this.findChange(fileId);
if (!change) return false; if (!change) return false;
const edit = change.edits.find(e => e.id === editId); const edit = change.edits.find(e => e.id === editId);
if (!edit) return false; if (!edit) return false;
// 从当前文件中还原该编辑:将 newString 替换回 oldString let reverted: string;
try { try {
const currentContent = this.readFile(change.filePath); const currentContent = this.readFile(change.filePath);
if (currentContent !== null) { if (currentContent === null) return false;
const revertedContent = this.revertEditInContent(currentContent, edit); reverted = this.revertEditInContent(currentContent, edit);
this.writeFile(change.filePath, revertedContent);
}
} catch (e) { } catch (e) {
console.error(`[AI Diff] 还原编辑失败: ${change.filePath}`, e); console.error(`[AI Diff] 还原编辑失败: ${change.filePath}`, e);
return false; return false;
} }
const ok = this.writeFile(change.filePath, reverted);
if (!ok) return false;
this.updateEditStatus(fileId, editId, 'rejected'); this.updateEditStatus(fileId, editId, 'rejected');
return true; return true;
} }
/** /** ★ 拒绝整个文件:先恢复再标记 */
* 拒绝整个文件的所有编辑 — 恢复 originalContent
*/
rejectFile(fileId: string): boolean { rejectFile(fileId: string): boolean {
const change = this.findChange(fileId); const change = this.findChange(fileId);
if (!change) return false; if (!change) return false;
this.updateFileStatus(fileId, 'rejected');
try { try {
if (change.type === 'create') { if (change.type === 'create') {
// 新建的文件 → 删除 if (fs.existsSync(change.filePath)) fs.unlinkSync(change.filePath);
if (fs.existsSync(change.filePath)) {
fs.unlinkSync(change.filePath);
}
} else { } else {
// 修改的文件 → 恢复原始内容 const ok = this.writeFile(change.filePath, change.originalContent);
this.writeFile(change.filePath, change.originalContent); if (!ok) return false;
} }
console.log(`[AI Diff] 已拒绝: ${change.filePath}`); console.log(`[AI Diff] 已拒绝文件: ${change.filePath}`);
return true;
} catch (e) { } catch (e) {
console.error(`[AI Diff] 恢复文件失败: ${change.filePath}`, e); console.error(`[AI Diff] 恢复文件失败: ${change.filePath}`, e);
return false; return false;
} }
this.updateFileStatus(fileId, 'rejected');
return true;
} }
/** /** ★ 拒绝所有 pending 的文件 */
* 拒绝所有变更
*/
rejectAll(): number { rejectAll(): number {
if (!this.currentSet) return 0; if (!this.currentSet) return 0;
const pendingIds = this.currentSet.changes.filter(c => c.status === 'pending').map(c => c.id);
let count = 0; let count = 0;
for (const change of this.currentSet.changes) { for (const id of pendingIds) {
if (change.status === 'pending' && this.rejectFile(change.id)) { if (this.rejectFile(id)) count++;
count++;
}
} }
return count; return count;
} }
// ---- 工具方法 ---- // ---- 工具 ----
/**
* 在内容中还原单个编辑
*/
private revertEditInContent(content: string, edit: EditRecord): string { private revertEditInContent(content: string, edit: EditRecord): string {
// 找到 newString 并替换回 oldString
const idx = content.indexOf(edit.newString); const idx = content.indexOf(edit.newString);
if (idx !== -1) { if (idx !== -1) {
return content.substring(0, idx) + edit.oldString + content.substring(idx + edit.newString.length); return content.substring(0, idx) + edit.oldString + content.substring(idx + edit.newString.length);
} }
// 找不到精确匹配,回退:使用 beforeContent
console.warn(`[AI Diff] 无法精确定位编辑替换位置,回退到 beforeContent`); console.warn(`[AI Diff] 无法精确定位编辑替换位置,回退到 beforeContent`);
return edit.beforeContent; return edit.beforeContent;
} }
/** ★ 查找下一个待处理的文件变更(用于自动推进) */
findNextPending(afterFileId?: string): FileChange | undefined {
if (!this.currentSet) return undefined;
const pending = this.currentSet.changes.filter(c => c.status === 'pending');
if (pending.length === 0) return undefined;
if (!afterFileId) return pending[0];
const idx = pending.findIndex(c => c.id === afterFileId);
if (idx < 0 || idx >= pending.length - 1) {
// 当前是最后一个 pending,回到第一个(循环)
return pending.length > 1 ? pending[0] : undefined;
}
return pending[idx + 1];
}
/** 检查文件是否完全处理完毕(所有编辑都已 accepted/rejected) */
isFileFullyProcessed(fileId: string): boolean {
const change = this.findChange(fileId);
if (!change) return true;
return change.edits.every(e => e.status !== 'pending');
}
clear(): void { clear(): void {
this.currentSet = null; this.currentSet = null;
this.originalContents.clear(); this.originalContents.clear();
@@ -379,7 +329,7 @@ export class ChangeSetManager {
private writeFile(filePath: string, content: string): boolean { private writeFile(filePath: string, content: string): boolean {
try { try {
fs.writeFileSync(filePath, content, 'utf-8'); fs.writeFileSync(filePath, content, 'utf-8');
console.log(`[AI Diff] 写入文件: ${filePath}`); console.log(`[AI Diff] 写入文件: ${filePath} (${content.length} 字符)`);
return true; return true;
} catch (e) { } catch (e) {
console.error(`[AI Diff] 写入文件失败: ${filePath}`, e); console.error(`[AI Diff] 写入文件失败: ${filePath}`, e);
@@ -395,10 +345,7 @@ export class ChangeSetManager {
for (const change of changes) { for (const change of changes) {
const changeLines = change.value.split('\n'); const changeLines = change.value.split('\n');
if (changeLines[changeLines.length - 1] === '') { if (changeLines[changeLines.length - 1] === '') changeLines.pop();
changeLines.pop();
}
for (const line of changeLines) { for (const line of changeLines) {
if (change.added) { if (change.added) {
lines.push({ type: 'add', oldLineNum: 0, newLineNum: newLine, content: line }); lines.push({ type: 'add', oldLineNum: 0, newLineNum: newLine, content: line });
@@ -413,18 +360,13 @@ export class ChangeSetManager {
} }
} }
} }
return lines; return lines;
} }
private readFile(filePath: string): string | null { private readFile(filePath: string): string | null {
try { try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
} }
} }
/** @legacy 向后兼容别名,供未迁移的模块使用 */ /** @legacy 向后兼容别名 */
export { ChangeSetManager as SnapshotManager }; export { ChangeSetManager as SnapshotManager };
+77 -77
View File
@@ -1,13 +1,8 @@
/** /**
* Hook 处理器 * Hook 处理器
* *
* PostToolUse (Edit/Write) → 收集变更 * Edit/Write → recordEdit()
* + autoShowDiffPerEdit=true → 立即弹出 VSCode 内置 Diff * Stop → QuickPick 总览 (含 Accept All / Reject All)
* + autoShowDiffPerEdit=false → 静默收集
*
* Stop → 对话结束
* + showAllDiffsOnStop=true → 弹出 QuickPick 文件列表
* + showAllDiffsOnStop=false → 仅状态栏通知
*/ */
import * as vscode from 'vscode'; import * as vscode from 'vscode';
@@ -17,131 +12,136 @@ import { DiffViewer } from '../render/diffViewer';
import { ClaudeHookEvent, FileChange } from '../models/types'; import { ClaudeHookEvent, FileChange } from '../models/types';
import { CONFIG_SECTION, CONFIG_KEYS } from '../models/constants'; import { CONFIG_SECTION, CONFIG_KEYS } from '../models/constants';
const SETTING_AUTO_SHOW_DIFF = `${CONFIG_SECTION}.${CONFIG_KEYS.AUTO_SHOW_DIFF_PER_EDIT}`;
const SETTING_SHOW_ALL_ON_STOP = `${CONFIG_SECTION}.${CONFIG_KEYS.SHOW_ALL_DIFFS_ON_STOP}`;
export class HookHandler { export class HookHandler {
private diffViewer: DiffViewer;
constructor( constructor(
private changeSetManager: ChangeSetManager private changeSetManager: ChangeSetManager,
) { private diffViewer: DiffViewer
this.diffViewer = new DiffViewer(changeSetManager); ) {}
}
getDiffViewer(): DiffViewer { getDiffViewer(): DiffViewer {
return this.diffViewer; return this.diffViewer;
} }
/**
* 处理 PostToolUse 事件(Edit/Write)
* 收集变更,根据配置决定是否自动弹 Diff
*/
handleEdit(event: ClaudeHookEvent): void { handleEdit(event: ClaudeHookEvent): void {
event.filePath = path.normalize(event.filePath); event.filePath = path.normalize(event.filePath);
console.log(`[AI Diff] 收集变更: ${event.toolName} → ${event.filePath}`); console.log(`[AI Diff] 收集变更: ${event.toolName} → ${event.filePath}`);
this.changeSetManager.recordEdit( this.changeSetManager.recordEdit(
event.filePath, event.filePath, event.toolName,
event.toolName, event.oldString || '', event.newString || '', event.content || ''
event.oldString || '',
event.newString || '',
event.content || ''
); );
// 根据配置决定是否自动弹 Diff const autoShow = vscode.workspace
const autoShow = vscode.workspace.getConfiguration(CONFIG_SECTION).get<boolean>(CONFIG_KEYS.AUTO_SHOW_DIFF_PER_EDIT, false); .getConfiguration(CONFIG_SECTION)
.get<boolean>(CONFIG_KEYS.AUTO_SHOW_DIFF_PER_EDIT, false);
if (autoShow) { if (autoShow) {
const set = this.changeSetManager.getCurrentSet(); const set = this.changeSetManager.getCurrentSet();
if (set) { if (set) {
const normalizedPath = path.normalize(event.filePath).replace(/\\/g, '/'); const norm = path.normalize(event.filePath).replace(/\\/g, '/');
const fileChange = set.changes.find( const fc = set.changes.find(c => path.normalize(c.filePath).replace(/\\/g, '/') === norm);
c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath if (fc) this.diffViewer.showEditDiff(fc.edits[fc.edits.length - 1], fc.id);
);
if (fileChange) {
const lastEdit = fileChange.edits[fileChange.edits.length - 1];
this.diffViewer.showEditDiff(lastEdit);
}
} }
} }
// 更新上下文键
vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', true); vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', true);
} }
/**
* 处理 Stop 事件(对话结束)
*/
handleStop(): void { handleStop(): void {
const changeSet = this.changeSetManager.markReady(); const changeSet = this.changeSetManager.markReady();
if (!changeSet) { if (!changeSet) { console.log('[AI Diff] 对话结束,无文件变更'); return; }
console.log('[AI Diff] 对话结束,无文件变更');
return;
}
const count = changeSet.changes.length;
const pendingCount = changeSet.changes.filter(c => c.status === 'pending').length; const pendingCount = changeSet.changes.filter(c => c.status === 'pending').length;
console.log(`[AI Diff] 对话结束,${count} 个文件有变更 (pending: ${pendingCount})`); console.log(`[AI Diff] 对话结束,pending: ${pendingCount}/${changeSet.changes.length}`);
const showAll = vscode.workspace.getConfiguration(CONFIG_SECTION).get<boolean>(CONFIG_KEYS.SHOW_ALL_DIFFS_ON_STOP, true); const showAll = vscode.workspace
.getConfiguration(CONFIG_SECTION)
.get<boolean>(CONFIG_KEYS.SHOW_ALL_DIFFS_ON_STOP, true);
if (showAll && pendingCount > 0) { if (showAll && pendingCount > 0) {
// 弹出 QuickPick 文件列表
this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending')); this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending'));
} else { } else {
// 仅状态栏通知
const msg = pendingCount > 0 const msg = pendingCount > 0
? `AI Diff: ${pendingCount} 个文件有待处理变更` ? `AI Diff: ${pendingCount} 个文件有变更`
: `AI Diff: ${count} 个文件变更已完成`; : `AI Diff: ${changeSet.changes.length} 个文件已处理`;
vscode.window.showInformationMessage(msg, '查看变更').then(selection => { vscode.window.showInformationMessage(msg, '查看总览').then(sel => {
if (selection === '查看变更') { if (sel === '查看总览') this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending'));
this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending'));
}
}); });
} }
} }
/**
* 弹出 QuickPick 文件列表
*/
async showFileListPicker(fileChanges?: FileChange[]): Promise<void> { async showFileListPicker(fileChanges?: FileChange[]): Promise<void> {
let changes = fileChanges; let changes = fileChanges;
if (!changes) { if (!changes) {
const set = this.changeSetManager.getCurrentSet(); changes = this.changeSetManager.getCurrentSet()?.changes.filter(c => c.status === 'pending') || [];
if (!set) return;
changes = set.changes.filter(c => c.status === 'pending');
} }
if (changes.length === 0) { if (changes.length === 0) {
vscode.window.showInformationMessage('AI Diff: 无待处理变更'); vscode.window.showInformationMessage('AI Diff: 无待处理变更');
return; return;
} }
const items: vscode.QuickPickItem[] = changes.map(c => { const totalEdits = changes.reduce((s, c) => s + c.edits.filter(e => e.status === 'pending').length, 0);
const totalAdds = changes.reduce((s, c) => s + c.diffLines.filter(d => d.type === 'add').length, 0);
const totalDels = changes.reduce((s, c) => s + c.diffLines.filter(d => d.type === 'delete').length, 0);
interface Item extends vscode.QuickPickItem {
itemKind: 'file' | 'separator' | 'action';
fileChange?: FileChange;
action?: 'acceptAll' | 'rejectAll';
}
const items: Item[] = [];
const typeMap: Record<string, string> = { modify: '$(edit)', create: '$(new-file)', delete: '$(trash)' };
for (const c of changes) {
const name = path.basename(c.filePath); const name = path.basename(c.filePath);
const adds = c.diffLines.filter(d => d.type === 'add').length; const adds = c.diffLines.filter(d => d.type === 'add').length;
const dels = c.diffLines.filter(d => d.type === 'delete').length; const dels = c.diffLines.filter(d => d.type === 'delete').length;
const typeMap: Record<string, string> = { modify: '$(edit)', create: '$(new-file)', delete: '$(trash)' }; items.push({
itemKind: 'file',
return {
label: `${typeMap[c.type] || '$(file)'} ${name}`, label: `${typeMap[c.type] || '$(file)'} ${name}`,
description: `+${adds} -${dels} · ${c.edits.length} 次编辑`, description: `+${adds} -${dels} · ${c.edits.length} 次编辑`,
detail: c.filePath, detail: c.filePath,
// 存储 fileChange id 以便查找 fileChange: c,
id: c.id, });
}; }
items.push({
itemKind: 'separator',
label: '──────────────────────────────',
description: '',
}); });
const selected = await vscode.window.showQuickPick(items, { items.push({
placeHolder: `AI Diff Review — ${changes.length} 个文件有变更`, itemKind: 'action',
matchOnDescription: true, label: '$(check-all) ✅ Accept All — 接受所有变更',
matchOnDetail: true, description: `${changes.length} 文件 · +${totalAdds} -${totalDels} · ${totalEdits} 编辑`,
action: 'acceptAll',
}); });
if (selected && selected.id) { items.push({
const fileChange = changes.find(c => c.id === selected.id); itemKind: 'action',
if (fileChange) { label: '$(trash) ❌ Reject All — 拒绝所有变更',
await this.diffViewer.showFileDiff(fileChange); description: `${changes.length} 文件 · +${totalAdds} -${totalDels} · ${totalEdits} 编辑`,
action: 'rejectAll',
});
const selected = await vscode.window.showQuickPick<Item>(items, {
placeHolder: `AI Diff 总览 — ${changes.length} 文件 ${totalEdits} 编辑 (+${totalAdds} -${totalDels})`,
matchOnDescription: false,
matchOnDetail: false,
});
if (!selected) return;
if (selected.itemKind === 'file' && selected.fileChange) {
await this.diffViewer.showFileDiff(selected.fileChange);
} else if (selected.itemKind === 'action') {
if (selected.action === 'acceptAll') {
const n = this.changeSetManager.acceptAll();
vscode.window.showInformationMessage(`AI Diff: 已接受 ${n} 个文件 ✓`);
} else if (selected.action === 'rejectAll') {
const n = this.changeSetManager.rejectAll();
vscode.window.showInformationMessage(`AI Diff: 已拒绝 ${n} 个文件 ✗`);
} }
} }
} }
+151
View File
@@ -0,0 +1,151 @@
/**
* Hook 处理器
*
* Edit/Write → recordEdit()
* Stop → QuickPick 总览 (含 Accept All / Reject All)
*/
import * as vscode from 'vscode';
import * as path from 'path';
import { ChangeSetManager } from '../snapshot/snapshotManager';
import { DiffViewer } from '../render/diffViewer';
import { ClaudeHookEvent, FileChange } from '../models/types';
import { CONFIG_SECTION, CONFIG_KEYS } from '../models/constants';
export class HookHandler {
constructor(
private changeSetManager: ChangeSetManager,
private diffViewer: DiffViewer
) {}
getDiffViewer(): DiffViewer {
return this.diffViewer;
}
handleEdit(event: ClaudeHookEvent): void {
event.filePath = path.normalize(event.filePath);
console.log(`[AI Diff] 收集变更: ${event.toolName} → ${event.filePath}`);
this.changeSetManager.recordEdit(
event.filePath, event.toolName,
event.oldString || '', event.newString || '', event.content || ''
);
const autoShow = vscode.workspace
.getConfiguration(CONFIG_SECTION)
.get<boolean>(CONFIG_KEYS.AUTO_SHOW_DIFF_PER_EDIT, false);
if (autoShow) {
const set = this.changeSetManager.getCurrentSet();
if (set) {
const norm = path.normalize(event.filePath).replace(/\\/g, '/');
const fc = set.changes.find(c => path.normalize(c.filePath).replace(/\\/g, '/') === norm);
if (fc) this.diffViewer.showEditDiff(fc.edits[fc.edits.length - 1], fc.id);
}
}
vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', true);
}
handleStop(): void {
const changeSet = this.changeSetManager.markReady();
if (!changeSet) { console.log('[AI Diff] 对话结束,无文件变更'); return; }
const pendingCount = changeSet.changes.filter(c => c.status === 'pending').length;
console.log(`[AI Diff] 对话结束,pending: ${pendingCount}/${changeSet.changes.length}`);
const showAll = vscode.workspace
.getConfiguration(CONFIG_SECTION)
.get<boolean>(CONFIG_KEYS.SHOW_ALL_DIFFS_ON_STOP, true);
if (showAll && pendingCount > 0) {
this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending'));
} else {
const msg = pendingCount > 0
? `AI Diff: ${pendingCount} 个文件有变更`
: `AI Diff: ${changeSet.changes.length} 个文件已处理`;
vscode.window.showInformationMessage(msg, '查看总览').then(sel => {
if (sel === '查看总览') this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending'));
});
}
}
/**
* QuickPick 总览: 文件列表 + Accept All / Reject All
*/
async showFileListPicker(fileChanges?: FileChange[]): Promise<void> {
let changes = fileChanges;
if (!changes) {
changes = this.changeSetManager.getCurrentSet()?.changes.filter(c => c.status === 'pending') || [];
}
if (changes.length === 0) {
vscode.window.showInformationMessage('AI Diff: 无待处理变更');
return;
}
const totalEdits = changes.reduce((s, c) => s + c.edits.filter(e => e.status === 'pending').length, 0);
const totalAdds = changes.reduce((s, c) => s + c.diffLines.filter(d => d.type === 'add').length, 0);
const totalDels = changes.reduce((s, c) => s + c.diffLines.filter(d => d.type === 'delete').length, 0);
interface Item extends vscode.QuickPickItem {
itemKind: 'file' | 'separator' | 'action';
fileChange?: FileChange;
action?: 'acceptAll' | 'rejectAll';
}
const items: Item[] = [];
const typeMap: Record<string, string> = { modify: '$(edit)', create: '$(new-file)', delete: '$(trash)' };
for (const c of changes) {
const name = path.basename(c.filePath);
const adds = c.diffLines.filter(d => d.type === 'add').length;
const dels = c.diffLines.filter(d => d.type === 'delete').length;
items.push({
itemKind: 'file',
label: `${typeMap[c.type] || '$(file)'} ${name}`,
description: `+${adds} -${dels} · ${c.edits.length} 次编辑`,
detail: c.filePath,
fileChange: c,
});
}
items.push({
itemKind: 'separator',
label: '──────────────────────────────',
description: '',
});
items.push({
itemKind: 'action',
label: '$(check-all) ✅ Accept All — 接受所有变更',
description: `${changes.length} 文件 · +${totalAdds} -${totalDels} · ${totalEdits} 编辑`,
action: 'acceptAll',
});
items.push({
itemKind: 'action',
label: '$(trash) ❌ Reject All — 拒绝所有变更',
description: `${changes.length} 文件 · +${totalAdds} -${totalDels} · ${totalEdits} 编辑`,
action: 'rejectAll',
});
const selected = await vscode.window.showQuickPick<Item>(items, {
placeHolder: `AI Diff 总览 — ${changes.length} 文件 ${totalEdits} 编辑 (+${totalAdds} -${totalDels})`,
matchOnDescription: false,
matchOnDetail: false,
});
if (!selected) return;
if (selected.itemKind === 'file' && selected.fileChange) {
await this.diffViewer.showFileDiff(selected.fileChange);
} else if (selected.itemKind === 'action') {
if (selected.action === 'acceptAll') {
const n = this.changeSetManager.acceptAll();
vscode.window.showInformationMessage(`AI Diff: 已接受 ${n} 个文件 ✓`);
} else if (selected.action === 'rejectAll') {
const n = this.changeSetManager.rejectAll();
vscode.window.showInformationMessage(`AI Diff: 已拒绝 ${n} 个文件 ✗`);
}
}
}
}
+48
View File
@@ -52,6 +52,7 @@
## 第五轮测试 - 内嵌悬浮窗验证 ## 第五轮测试 - 内嵌悬浮窗验证
这次修改用于验证: 这次修改用于验证:
1. Webview 面板能否正常弹出 1. Webview 面板能否正常弹出
2. 双栏 Diff 是否正确显示新增/删除行 2. 双栏 Diff 是否正确显示新增/删除行
3. Accept 按钮是否生效 3. Accept 按钮是否生效
@@ -64,3 +65,50 @@
- 修复了所有已知问题 - 修复了所有已知问题
- 现在应该能正常工作 - 现在应该能正常工作
- 验证: 面板弹出 + Accept/Reject + 文件回写 - 验证: 面板弹出 + Accept/Reject + 文件回写
## 第七轮测试
改动内容:
- 语法错误已修复
- API 调用已优化
- 新增错误处理逻辑
以下为原始代码(已删除):
```js
function oldApi() {
return fetch('/api').then(r => r.json());
}
```
替换为新的 async/await 写法:
```js
async function newApi() {
const res = await fetch('/api');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
```
## 第八轮测试
- 代码添加了错误处理
- 验证 CodeLens 按钮和状态栏是否正常显示
- 验证 Accept / Reject 快捷键是否生效
## 第九轮测试 - 多文件变更
同时修改了 3 个文件:
1. 测试.md - 本文件
2. test1.md - 新增功能模块
3. test2.md - 新增 API 和模块
验证:
- 状态栏显示 "3文件 N编辑"
- 点击弹出 Webview 总览面板
- 每个文件可展开查看详细 Diff
- Accept/Reject 按钮正常生效