mirror of
https://github.com/Gmaker689/ai-diff-preview.git
synced 2026-10-08 05:41:51 +08:00
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:
+19
-25
@@ -1,46 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.2.0] - 2026-06-18
|
||||
|
||||
### Added
|
||||
- **VSCode 内置 Diff 编辑器**:不再使用自定义 Webview,直接调用 `vscode.diff` 展示差异
|
||||
- **EditRecord 粒度**:每次 Edit/Write 创建独立 EditRecord,支持逐编辑 Accept/Reject
|
||||
- **QuickPick 文件列表**:Stop 后弹出原生 QuickPick 文件列表,点击即打开内置 Diff
|
||||
- **导航快捷键**:`Alt+↑/↓` 在变更文件间跳转
|
||||
- **配置项**:`autoShowDiffPerEdit`(每次编辑自动弹 Diff)、`showAllDiffsOnStop`(Stop 后弹文件列表)、`floatingLabelMode`(浮动标签位置)
|
||||
- **新命令**:`showCurrentFileDiff`、`acceptCurrentEdit`、`rejectCurrentEdit`、`nextDiff`、`prevDiff`
|
||||
- **新快捷键**:`Alt+↑/↓`、`Ctrl+Shift+D`、`Ctrl+Shift+F`
|
||||
- VSCode 内置 Diff 编辑器 (vscode.diff) — 零 Webview 依赖
|
||||
- EditRecord 独立追踪 — 每次 Edit/Write 独立记录,不合并
|
||||
- QuickPick 总览 — 文件列表 + Accept All / Reject All
|
||||
- 智能 Auto-Advance — Accept/Reject 后自动推进,同文件多编辑智能停留
|
||||
- 状态栏常驻 — $(diff) AI Diff 始终可见
|
||||
- 零保存提示 — 临时文件方案自动清理
|
||||
- 新命令: showCurrentFileDiff, acceptCurrentEdit, rejectCurrentEdit, nextDiff, prevDiff
|
||||
- 新快捷键: Alt+↑/↓, Ctrl+Shift+D, Ctrl+Shift+F
|
||||
- 新配置: autoShowDiffPerEdit, showAllDiffsOnStop, floatingLabelMode
|
||||
|
||||
### Fixed
|
||||
- **Bug Fix**: Accept 后同文件再编辑不再触发 Accept/Reject 选项,且 Diff 合并的问题
|
||||
- 根因:`recordEdit()` 对同文件多次编辑更新同一个 `FileChange` 对象
|
||||
- 修复:已 accepted/rejected 的 `FileChange` 后再次编辑时自动创建新 `FileChange`,清除 `originalContents` 缓存
|
||||
- Accept 后同文件再编辑不再触发选项 (FileChange pending-priority 匹配)
|
||||
- Accept All / Reject All 写入与状态顺序修复
|
||||
- accept/reject 后自动推进到下一个待处理文件
|
||||
|
||||
### Changed
|
||||
- `ChangeSetManager` 重构为 `EditRecord[]` 独立记录模式
|
||||
- `FileChange` 变为聚合视图(持有 `edits: EditRecord[]`)
|
||||
- `HookHandler` 支持 `autoShowDiffPerEdit` / `showAllDiffsOnStop` 配置分支
|
||||
- `ReviewPanel` Webview 降级为次要选项(保留兼容)
|
||||
- 版本号升级至 `0.2.0`
|
||||
- ChangeSetManager 重构为 EditRecord 独立记录 + FileChange 聚合模式
|
||||
- HookHandler 精简为纯处理逻辑
|
||||
- ReviewPanel 降级为备用, 主流程使用 QuickPick + 内置 Diff
|
||||
- 版本号升级至 0.2.0
|
||||
|
||||
## [0.1.0] - 2026-06-17
|
||||
|
||||
### Added
|
||||
- 项目初始化
|
||||
- 核心架构搭建
|
||||
- 快照管理器实现
|
||||
- Diff 引擎实现(基于 Myers 算法)
|
||||
- 快照管理器
|
||||
- Diff 引擎 (Myers 算法)
|
||||
- Webview Review 面板
|
||||
- 内联装饰器渲染
|
||||
- CodeLens 提供器
|
||||
- Accept/Reject 事务处理
|
||||
- Claude Hooks 集成
|
||||
- 状态栏显示
|
||||
- 基础测试用例
|
||||
|
||||
### Technical
|
||||
- TypeScript + VSCode Extension API
|
||||
- diff 库集成
|
||||
- esbuild 构建流程
|
||||
- Vitest 测试框架
|
||||
|
||||
@@ -1,71 +1,112 @@
|
||||
# AI Code Diff Preview
|
||||
|
||||
类 Cursor 的 AI 代码差异预览 VSCode 插件,使用 **VSCode 内置 Diff 编辑器** + **浮动 Accept/Reject 标签**。
|
||||
> 类 Cursor 的 AI 代码差异预览 VSCode 插件
|
||||
|
||||
## 功能特性
|
||||
[](https://github.com/YonHaoGuo/ai-diff-preview/blob/master/CHANGELOG.md)
|
||||
[](https://code.visualstudio.com/)
|
||||
[](LICENSE)
|
||||
|
||||
- ✅ **VSCode 原生 Diff 编辑器** — 使用内置 `vscode.diff`,不自定义 Webview
|
||||
- ✅ **EditRecord 独立追踪** — 每次 Edit/Write 独立记录,不合并
|
||||
- ✅ **逐次编辑 Accept/Reject** — 细粒度控制每次 AI 修改
|
||||
- ✅ **QuickPick 文件列表** — 对话结束后弹出原生文件选择器
|
||||
- ✅ **快捷键导航** — `Alt+↑/↓` 跳转变更文件
|
||||
- ✅ **Bug 修复** — Accept 后再编辑不再丢失追踪
|
||||
- ✅ **冲突检测** — 自动检测用户手动修改导致的冲突
|
||||
AI Code Diff Preview 为 VSCode 注入 Cursor 风格的 Accept/Reject 差异审查体验。AI 对话中的每一次代码编辑自动收集,对话结束后弹出完整的变更文件列表。点击任意文件即可在 **VSCode 内置 Diff 编辑器**中逐文件审查,状态栏实时显示 Accept/Reject 按钮,操作完成后自动推进到下一个待处理文件。
|
||||
|
||||
## 工作流程
|
||||
## 特性
|
||||
|
||||
```
|
||||
Claude Edit/Write → 收集到 ChangeSet (EditRecord 粒度)
|
||||
├── autoShowDiffPerEdit=true → 立即弹出 VSCode 内置 Diff
|
||||
└── autoShowDiffPerEdit=false → 静默收集,更新状态栏
|
||||
|
||||
Claude Stop → 变更集就绪
|
||||
├── showAllDiffsOnStop=true → QuickPick 文件列表 → 点击打开内置 Diff
|
||||
└── showAllDiffsOnStop=false → 仅通知,用户手动 Ctrl+Shift+D 查看
|
||||
```
|
||||
- **VSCode 原生 Diff 编辑器** — 使用 `vscode.diff` 命令,100% 原生交互体验,无自定义 Webview
|
||||
- **EditRecord 独立追踪** — 每次 `Edit`/`Write` 操作作为独立编辑记录,支持逐次编辑 Accept/Reject
|
||||
- **QuickPick 总览** — 对话结束后弹出原生文件列表,含 Accept All / Reject All 一键操作
|
||||
- **智能 Auto-Advance** — Accept/Reject 后自动推进到下一个待处理文件,同文件多编辑自动停留
|
||||
- **状态栏常驻** — 插件激活后状态栏始终可见 `$(diff) AI Diff`,有变更时黄底高亮
|
||||
- **零保存提示** — Diff 编辑器使用临时文件,关闭时自动删除,不会弹出"是否保存"对话框
|
||||
- **多轮对话追踪** — 支持同一轮对话中对同文件的多次编辑合并、跨轮对话的独立追踪
|
||||
|
||||
## 安装
|
||||
|
||||
```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
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 编译
|
||||
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` | 上一个变更文件 |
|
||||
| `Tab` | `aiDiffPreview.isActive` | 接受当前编辑 |
|
||||
| `Esc` | `aiDiffPreview.isActive` | 拒绝所有变更 |
|
||||
| `Ctrl+Shift+A` | `aiDiffPreview.isActive` | Accept All |
|
||||
| `Ctrl+Shift+R` | `aiDiffPreview.isActive` | Reject All |
|
||||
| `Ctrl+Shift+D` | `aiDiffPreview.isActive` | 显示文件列表 |
|
||||
| `Ctrl+Shift+F` | `aiDiffPreview.isActive` | 查看当前文件 Diff |
|
||||
| 快捷键 | 条件 | 功能 |
|
||||
| ---------------- | -------------------------- | ----------------------- |
|
||||
| `Alt+↓` | `aiDiffPreview.isActive` | 下一个变更文件 |
|
||||
| `Alt+↑` | `aiDiffPreview.isActive` | 上一个变更文件 |
|
||||
| `Ctrl+Shift+D` | `aiDiffPreview.isActive` | 显示 QuickPick 文件列表 |
|
||||
| `Ctrl+Shift+F` | `aiDiffPreview.isActive` | 查看当前文件 Diff |
|
||||
| `Ctrl+Shift+A` | `aiDiffPreview.isActive` | Accept All |
|
||||
| `Ctrl+Shift+R` | `aiDiffPreview.isActive` | Reject All |
|
||||
|
||||
### 命令
|
||||
|
||||
| 命令 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.autoShowDiffPerEdit` | boolean | false | 每次编辑自动弹 Diff |
|
||||
| `aiDiffPreview.showAllDiffsOnStop` | boolean | true | Stop 后弹文件列表 |
|
||||
| `aiDiffPreview.floatingLabelMode` | string | "statusBar" | 浮动标签位置 |
|
||||
| `aiDiffPreview.maxFileSize` | number | 100000 | 最大文件大小 |
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm run watch # 监听模式
|
||||
npm run test # 运行测试 (13 tests)
|
||||
npm run lint # 代码检查
|
||||
npm run package # 打包 vsix
|
||||
```
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
| ------------------------------------- | ------- | --------------- | ------------------------------------------------ |
|
||||
| `aiDiffPreview.enableAutoTrigger` | boolean | `true` | 启用自动触发(监听 pending.json) |
|
||||
| `aiDiffPreview.autoShowDiffPerEdit` | boolean | `false` | 每次 Edit/Write 后自动弹出 Diff |
|
||||
| `aiDiffPreview.showAllDiffsOnStop` | boolean | `true` | Stop 后自动弹出 QuickPick 总览 |
|
||||
| `aiDiffPreview.floatingLabelMode` | string | `"statusBar"` | 浮动标签位置(statusBar / inline / both / none) |
|
||||
| `aiDiffPreview.maxFileSize` | number | `100000` | 最大处理文件大小(字符数) |
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -73,25 +114,50 @@ npm run package # 打包 vsix
|
||||
src/
|
||||
├── extension.ts # 插件入口
|
||||
├── trigger/
|
||||
│ └── hookHandler.ts # Hook 处理器 + QuickPick 文件列表
|
||||
│ └── hookHandler.ts # Hook 处理器 + QuickPick 总览
|
||||
├── snapshot/
|
||||
│ └── snapshotManager.ts # 变更集管理器 (ChangeSetManager)
|
||||
├── diff/
|
||||
│ └── diffEngine.ts # Diff 计算引擎
|
||||
├── render/
|
||||
│ ├── diffViewer.ts # ★ VSCode 内置 Diff 查看器
|
||||
│ ├── reviewPanel.ts # Webview 面板 (次要)
|
||||
│ ├── inlineDecorator.ts # 内联装饰器
|
||||
│ ├── codeLensProvider.ts # CodeLens 提供器
|
||||
│ └── statusBar.ts # 状态栏
|
||||
│ ├── diffViewer.ts # VSCode 内置 Diff 查看器 + 自动推进
|
||||
│ ├── reviewPanel.ts # Webview 面板 (实验性/备用)
|
||||
│ ├── inlineDecorator.ts # 内联装饰器 (备用)
|
||||
│ ├── codeLensProvider.ts # CodeLens 提供器 (备用)
|
||||
│ └── statusBar.ts # 状态栏管理器 (常驻 + Diff 模式)
|
||||
├── transaction/
|
||||
│ ├── acceptHandler.ts # Accept 处理器
|
||||
│ └── rejectHandler.ts # Reject 处理器
|
||||
└── models/
|
||||
├── types.ts # 类型定义
|
||||
└── constants.ts # 常量定义
|
||||
│ ├── acceptHandler.ts # Accept 处理器 (旧版)
|
||||
│ └── rejectHandler.ts # Reject 处理器 (旧版)
|
||||
├── models/
|
||||
│ ├── types.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)
|
||||
|
||||
@@ -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
@@ -3,7 +3,11 @@
|
||||
"displayName": "AI Code Diff Preview",
|
||||
"description": "类 Cursor 的 AI 代码差异预览插件,使用 VSCode 内置 Diff 编辑器 + 浮动 Accept/Reject 标签",
|
||||
"version": "0.2.0",
|
||||
"publisher": "your-publisher-name",
|
||||
"publisher": "Gmaker689",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Gmaker689/ai-diff-preview"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
|
||||
+91
-174
@@ -1,17 +1,10 @@
|
||||
/**
|
||||
* AI Code Diff Preview - 插件入口
|
||||
*
|
||||
* 工作流程:
|
||||
* 1. PostToolUse (Edit/Write) → 收集变更到 ChangeSet (EditRecord 粒度)
|
||||
* 2. Stop (对话结束) → 根据配置弹出 QuickPick 文件列表或通知
|
||||
* 3. 用户选择文件 → 打开 VSCode 内置 Diff 编辑器
|
||||
* 4. 用户 Accept/Reject → 应用或丢弃变更
|
||||
*
|
||||
* 快捷键:
|
||||
* - Alt+↑/↓: 上一个/下一个变更
|
||||
* - Tab: 接受当前编辑
|
||||
* - Esc: 拒绝当前编辑
|
||||
* - Ctrl+Shift+D: 显示文件列表
|
||||
* UI 说明:
|
||||
* 1. Stop 后弹出 QuickPick 文件列表 (含 Accept All / Reject All)
|
||||
* 2. 点击文件 → VSCode 内置 Diff 编辑器 + 状态栏 Accept/Reject 按钮
|
||||
* 3. Accept/Reject 后自动推进到下一个 pending 文件
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
@@ -20,227 +13,163 @@ import * as fs from 'fs';
|
||||
import { ChangeSetManager } from './snapshot/snapshotManager';
|
||||
import { HookHandler } from './trigger/hookHandler';
|
||||
import { DiffViewer } from './render/diffViewer';
|
||||
import { ReviewPanel } from './render/reviewPanel';
|
||||
import { StatusBarManager } from './render/statusBar';
|
||||
import { COMMANDS } from './models/constants';
|
||||
import { ClaudeHookEvent } from './models/types';
|
||||
|
||||
let changeSetManager: ChangeSetManager;
|
||||
let hookHandler: HookHandler;
|
||||
let diffViewer: DiffViewer;
|
||||
let reviewPanel: ReviewPanel;
|
||||
let statusBar: StatusBarManager;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
console.log('[AI Diff] 插件已激活');
|
||||
|
||||
// 初始化核心模块
|
||||
changeSetManager = new ChangeSetManager();
|
||||
hookHandler = new HookHandler(changeSetManager);
|
||||
diffViewer = hookHandler.getDiffViewer();
|
||||
reviewPanel = new ReviewPanel(changeSetManager);
|
||||
diffViewer = new DiffViewer(changeSetManager);
|
||||
hookHandler = new HookHandler(changeSetManager, diffViewer);
|
||||
statusBar = new StatusBarManager(changeSetManager, diffViewer);
|
||||
|
||||
// DiffViewer 操作后刷新状态栏
|
||||
diffViewer.onDiffAction = () => refreshUI();
|
||||
|
||||
// 注册命令
|
||||
registerCommands(context);
|
||||
|
||||
// 注册 Hook 触发文件监听
|
||||
registerHookFileWatcher(context);
|
||||
// 编辑器切换 → 刷新状态栏
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(() => refreshUI())
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeVisibleTextEditors(() => refreshUI())
|
||||
);
|
||||
|
||||
// 检查激活时是否已有 pending 触发文件
|
||||
registerHookFileWatcher(context);
|
||||
checkPendingHookFile();
|
||||
refreshUI();
|
||||
}
|
||||
|
||||
// ---- 命令 ----
|
||||
|
||||
function registerCommands(context: vscode.ExtensionContext) {
|
||||
// === Diff 查看 ===
|
||||
|
||||
// 显示文件列表 (QuickPick)
|
||||
// QuickPick 文件列表 (总览)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => {
|
||||
hookHandler.showFileListPicker();
|
||||
vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => 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(
|
||||
vscode.commands.registerCommand(COMMANDS.REJECT_CURRENT_EDIT, () => {
|
||||
const result = findCurrentEditAtCursor();
|
||||
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)}`);
|
||||
vscode.commands.registerCommand(COMMANDS.REJECT_CURRENT_EDIT, async () => {
|
||||
await diffViewer.rejectCurrentDiff();
|
||||
})
|
||||
);
|
||||
|
||||
// 接受全部
|
||||
// Accept All
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(COMMANDS.ACCEPT_ALL, () => {
|
||||
const count = changeSetManager.acceptAll();
|
||||
vscode.window.showInformationMessage(`AI Diff: 已接受 ${count} 个文件的变更`);
|
||||
const n = changeSetManager.acceptAll();
|
||||
vscode.window.showInformationMessage(`AI Diff: 已接受 ${n} 个文件`);
|
||||
refreshUI();
|
||||
})
|
||||
);
|
||||
|
||||
// 拒绝全部
|
||||
// Reject All
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(COMMANDS.REJECT_ALL, () => {
|
||||
const count = changeSetManager.rejectAll();
|
||||
vscode.window.showInformationMessage(`AI Diff: 已拒绝 ${count} 个文件的变更`);
|
||||
const n = changeSetManager.rejectAll();
|
||||
vscode.window.showInformationMessage(`AI Diff: 已拒绝 ${n} 个文件`);
|
||||
refreshUI();
|
||||
})
|
||||
);
|
||||
|
||||
// === 导航 ===
|
||||
|
||||
// 下一个变更
|
||||
// 导航
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(COMMANDS.NEXT_DIFF, () => {
|
||||
navigateDiff(1);
|
||||
})
|
||||
vscode.commands.registerCommand(COMMANDS.NEXT_DIFF, () => navigateDiff(1))
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(COMMANDS.PREV_DIFF, () => navigateDiff(-1))
|
||||
);
|
||||
|
||||
// 上一个变更
|
||||
// Diff 界面状态栏按钮
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(COMMANDS.PREV_DIFF, () => {
|
||||
navigateDiff(-1);
|
||||
})
|
||||
);
|
||||
|
||||
// 旧版兼容命令
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(COMMANDS.ACCEPT_CHUNK, () => {
|
||||
vscode.commands.executeCommand(COMMANDS.ACCEPT_CURRENT_EDIT);
|
||||
vscode.commands.registerCommand('aiDiffPreview.acceptCurrentDiff', async () => {
|
||||
await diffViewer.acceptCurrentDiff();
|
||||
})
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(COMMANDS.REJECT_CHUNK, () => {
|
||||
vscode.commands.executeCommand(COMMANDS.REJECT_CURRENT_EDIT);
|
||||
vscode.commands.registerCommand('aiDiffPreview.rejectCurrentDiff', async () => {
|
||||
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 {
|
||||
const set = changeSetManager.getCurrentSet();
|
||||
if (!set) return;
|
||||
|
||||
const pending = set.changes.filter(c => c.status === 'pending');
|
||||
if (pending.length === 0) {
|
||||
vscode.window.showInformationMessage('AI Diff: 无待处理变更');
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
const currentPath = editor
|
||||
? path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/')
|
||||
: null;
|
||||
|
||||
// 找到当前文件在 pending 列表中的位置
|
||||
let idx = currentPath
|
||||
? pending.findIndex(c => path.normalize(c.filePath).replace(/\\/g, '/') === currentPath)
|
||||
: -1;
|
||||
|
||||
idx = idx === -1 ? 0 : (idx + direction + pending.length) % pending.length;
|
||||
diffViewer.showFileDiff(pending[idx]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 Hook 触发文件监听器
|
||||
*
|
||||
* 监听 .claude/hooks/pending.json:
|
||||
* - toolName: Edit/Write → 收集变更
|
||||
* - toolName: Stop → 弹出 Review 通知/文件列表
|
||||
*/
|
||||
// ---- 刷新 ----
|
||||
|
||||
function refreshUI(): void {
|
||||
statusBar.refresh();
|
||||
const hasPending = changeSetManager.hasPendingChanges();
|
||||
vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', hasPending);
|
||||
}
|
||||
|
||||
// ---- Hook 监听 ----
|
||||
|
||||
function registerHookFileWatcher(context: vscode.ExtensionContext) {
|
||||
const hookDir = vscode.workspace.workspaceFolders?.[0]
|
||||
? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks')
|
||||
: null;
|
||||
|
||||
if (!hookDir) {
|
||||
console.log('[AI Diff] 无工作区目录,Hook 监听未启动');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(hookDir)) {
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
}
|
||||
if (!hookDir) { console.log('[AI Diff] 无工作区目录'); return; }
|
||||
if (!fs.existsSync(hookDir)) fs.mkdirSync(hookDir, { recursive: true });
|
||||
|
||||
const pendingFile = path.join(hookDir, 'pending.json');
|
||||
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(hookDir, 'pending.json')
|
||||
);
|
||||
|
||||
const handleTrigger = () => {
|
||||
const handle = () => {
|
||||
try {
|
||||
if (!fs.existsSync(pendingFile)) return;
|
||||
const content = fs.readFileSync(pendingFile, 'utf-8');
|
||||
fs.unlinkSync(pendingFile);
|
||||
|
||||
const event: ClaudeHookEvent = JSON.parse(content);
|
||||
|
||||
// 只处理最近 10 秒内的事件
|
||||
if (Date.now() - event.timestamp > 10000) return;
|
||||
|
||||
if (event.toolName === 'Stop') {
|
||||
@@ -248,48 +177,36 @@ function registerHookFileWatcher(context: vscode.ExtensionContext) {
|
||||
} else if (event.toolName === 'Edit' || event.toolName === 'Write') {
|
||||
hookHandler.handleEdit(event);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AI Diff] 处理触发文件失败:', e);
|
||||
}
|
||||
refreshUI();
|
||||
} catch (e) { console.error('[AI Diff] 处理触发文件失败:', e); }
|
||||
};
|
||||
|
||||
watcher.onDidChange(handleTrigger);
|
||||
watcher.onDidCreate(handleTrigger);
|
||||
watcher.onDidChange(handle);
|
||||
watcher.onDidCreate(handle);
|
||||
context.subscriptions.push(watcher);
|
||||
|
||||
console.log(`[AI Diff] Hook 文件监听已启动: ${hookDir}`);
|
||||
console.log(`[AI Diff] Hook 监听已启动: ${hookDir}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查激活时是否已有 pending 触发文件
|
||||
*/
|
||||
function checkPendingHookFile() {
|
||||
const hookDir = vscode.workspace.workspaceFolders?.[0]
|
||||
? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks')
|
||||
: null;
|
||||
if (!hookDir) return;
|
||||
|
||||
const pendingFile = path.join(hookDir, 'pending.json');
|
||||
if (!fs.existsSync(pendingFile)) return;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(pendingFile, 'utf-8');
|
||||
fs.unlinkSync(pendingFile);
|
||||
const event: ClaudeHookEvent = JSON.parse(content);
|
||||
|
||||
if (Date.now() - event.timestamp < 15000) {
|
||||
if (event.toolName === 'Stop') {
|
||||
hookHandler.handleStop();
|
||||
} else if (event.toolName === 'Edit' || event.toolName === 'Write') {
|
||||
hookHandler.handleEdit(event);
|
||||
}
|
||||
if (event.toolName === 'Stop') hookHandler.handleStop();
|
||||
else if (event.toolName === 'Edit' || event.toolName === 'Write') hookHandler.handleEdit(event);
|
||||
refreshUI();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AI Diff] 处理 pending hook 失败:', e);
|
||||
}
|
||||
} catch (e) { console.error('[AI Diff] 处理 pending hook 失败:', e); }
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
console.log('[AI Diff] 插件已停用');
|
||||
reviewPanel?.dispose();
|
||||
diffViewer?.cleanupTmpFiles();
|
||||
}
|
||||
@@ -1,96 +1,124 @@
|
||||
/**
|
||||
* CodeLens 提供器 - 提供 Accept/Reject 按钮
|
||||
* CodeLens 提供器 - 在编辑器行上方显示 Accept/Reject 按钮
|
||||
*
|
||||
* 基于 ChangeSetManager 和 EditRecord,为每次编辑提供可点击的操作按钮。
|
||||
* 按钮显示在对应编辑的变更区域上方。
|
||||
*/
|
||||
|
||||
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 { FileChange, EditRecord } from '../models/types';
|
||||
|
||||
export class CodeLensProvider implements vscode.CodeLensProvider {
|
||||
export class AIDiffCodeLensProvider implements vscode.CodeLensProvider {
|
||||
private _onDidChangeCodeLenses = new vscode.EventEmitter<void>();
|
||||
readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
|
||||
|
||||
constructor(private snapshotManager: SnapshotManager) {}
|
||||
constructor(private changeSetManager: ChangeSetManager) {}
|
||||
|
||||
/**
|
||||
* 刷新 CodeLens
|
||||
*/
|
||||
refresh(): void {
|
||||
this._onDidChangeCodeLenses.fire();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供 CodeLens
|
||||
*/
|
||||
provideCodeLenses(
|
||||
document: vscode.TextDocument,
|
||||
token: vscode.CancellationToken
|
||||
_token: vscode.CancellationToken
|
||||
): vscode.CodeLens[] {
|
||||
const snapshot = this.snapshotManager.getSnapshotByFilePath(document.uri.fsPath);
|
||||
if (!snapshot || snapshot.status !== 'active') {
|
||||
return [];
|
||||
}
|
||||
const set = this.changeSetManager.getCurrentSet();
|
||||
if (!set || set.status !== 'ready') 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
|
||||
snapshot.chunks.forEach(chunk => {
|
||||
if (chunk.status !== 'pending') return;
|
||||
const lenses: vscode.CodeLens[] = [];
|
||||
|
||||
const range = new vscode.Range(
|
||||
new vscode.Position(chunk.startLine, 0),
|
||||
new vscode.Position(chunk.startLine, 0)
|
||||
// 文件标题行 (第 0 行): 全部接受/拒绝
|
||||
lenses.push(
|
||||
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 按钮
|
||||
codeLenses.push(
|
||||
// Accept / Reject 按钮
|
||||
lenses.push(
|
||||
new vscode.CodeLens(range, {
|
||||
title: '$(check) Accept',
|
||||
command: COMMANDS.ACCEPT_CHUNK,
|
||||
arguments: [snapshot.id, chunk.id],
|
||||
tooltip: '接受此变更块',
|
||||
})
|
||||
);
|
||||
|
||||
// Reject 按钮
|
||||
codeLenses.push(
|
||||
command: COMMANDS.ACCEPT_CURRENT_EDIT,
|
||||
arguments: [fileChange.id, edit.id],
|
||||
tooltip: `接受此${toolLabel}`,
|
||||
}),
|
||||
new vscode.CodeLens(range, {
|
||||
title: '$(close) Reject',
|
||||
command: COMMANDS.REJECT_CHUNK,
|
||||
arguments: [snapshot.id, chunk.id],
|
||||
tooltip: '拒绝此变更块',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 如果有多个块,添加全局操作按钮
|
||||
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: '拒绝所有变更',
|
||||
command: COMMANDS.REJECT_CURRENT_EDIT,
|
||||
arguments: [fileChange.id, edit.id],
|
||||
tooltip: `拒绝此${toolLabel}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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
@@ -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 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 { ChangeSetManager } from '../snapshot/snapshotManager';
|
||||
|
||||
export interface DiffSession {
|
||||
fileId: string;
|
||||
editId?: string;
|
||||
beforeTmpPath: string;
|
||||
afterTmpPath: string;
|
||||
beforeUri: vscode.Uri;
|
||||
afterUri: vscode.Uri;
|
||||
}
|
||||
|
||||
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) {}
|
||||
|
||||
/**
|
||||
* 打开单次编辑的 Diff(VSCode 内置 Diff 编辑器)
|
||||
*/
|
||||
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),
|
||||
});
|
||||
getActiveSession(): DiffSession | null {
|
||||
return this.activeSession;
|
||||
}
|
||||
|
||||
// ---- 打开 Diff ----
|
||||
|
||||
async showEditDiff(editRecord: EditRecord, fileChangeId: string): Promise<void> {
|
||||
const ext = path.extname(editRecord.filePath);
|
||||
const fileName = path.basename(editRecord.filePath);
|
||||
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(
|
||||
'vscode.diff',
|
||||
beforeDoc.uri,
|
||||
afterDoc.uri,
|
||||
`${fileName} — AI ${toolLabel} #${editRecord.id.slice(0, 6)} (原始 ↔ 变更后)`
|
||||
this.activeSession!.beforeUri,
|
||||
this.activeSession!.afterUri,
|
||||
`${fileName} — AI ${toolLabel} #${editRecord.id.slice(0, 6)} · 状态栏操作`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开整个文件的聚合 Diff
|
||||
*/
|
||||
async showFileDiff(fileChange: FileChange): Promise<void> {
|
||||
const beforeDoc = await vscode.workspace.openTextDocument({
|
||||
content: fileChange.originalContent,
|
||||
language: this.getLanguageId(fileChange.filePath),
|
||||
});
|
||||
const afterDoc = await vscode.workspace.openTextDocument({
|
||||
content: fileChange.latestContent,
|
||||
language: this.getLanguageId(fileChange.filePath),
|
||||
});
|
||||
|
||||
const ext = path.extname(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(
|
||||
'vscode.diff',
|
||||
beforeDoc.uri,
|
||||
afterDoc.uri,
|
||||
`${fileName} — 原始 ↔ 变更后 (${fileChange.edits.length} 次编辑)`
|
||||
this.activeSession!.beforeUri,
|
||||
this.activeSession!.afterUri,
|
||||
`${fileName} — ${fileChange.edits.length} 次 AI 编辑 · 状态栏操作`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开当前活动文件在编辑器中的 Diff
|
||||
*/
|
||||
async showCurrentFileDiff(): Promise<void> {
|
||||
const changeSet = this.changeSetManager.getCurrentSet();
|
||||
if (!changeSet) return;
|
||||
|
||||
const set = this.changeSetManager.getCurrentSet();
|
||||
if (!set) return;
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) return;
|
||||
|
||||
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'
|
||||
);
|
||||
if (!fileChange) {
|
||||
vscode.window.showInformationMessage('AI Diff: 当前文件无待处理变更');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.showFileDiff(fileChange);
|
||||
if (fc) await this.showFileDiff(fc);
|
||||
else vscode.window.showInformationMessage('AI Diff: 当前文件无待处理变更');
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚焦文件到编辑器(跳转到该文件并定位到第一个变更行)
|
||||
*/
|
||||
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 });
|
||||
// ---- Diff 界面操作 (Bug 1 修复: 自动推进) ----
|
||||
|
||||
// 滚动到第一个变更行
|
||||
if (fileChange.diffLines.length > 0) {
|
||||
const firstChange = fileChange.diffLines.find(
|
||||
d => d.type === 'add' || d.type === 'delete'
|
||||
);
|
||||
if (firstChange) {
|
||||
const line = (firstChange.newLineNum || firstChange.oldLineNum) - 1;
|
||||
const pos = new vscode.Position(Math.max(0, line), 0);
|
||||
editor.selection = new vscode.Selection(pos, pos);
|
||||
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
|
||||
async acceptCurrentDiff(): Promise<void> {
|
||||
if (!this.activeSession) return;
|
||||
const { fileId, editId } = this.activeSession;
|
||||
|
||||
if (editId) {
|
||||
this.changeSetManager.acceptEditRecord(fileId, editId);
|
||||
} else {
|
||||
this.changeSetManager.acceptFile(fileId);
|
||||
}
|
||||
|
||||
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> = {
|
||||
'.ts': 'typescript', '.tsx': 'typescriptreact',
|
||||
'.js': 'javascript', '.jsx': 'javascriptreact',
|
||||
'.json': 'json', '.md': 'markdown',
|
||||
'.css': 'css', '.scss': 'scss', '.less': 'less',
|
||||
'.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',
|
||||
};
|
||||
return map[ext] || 'plaintext';
|
||||
// ---- 关闭 ----
|
||||
|
||||
async closeDiff(): Promise<void> {
|
||||
if (this.activeSession) {
|
||||
try { await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); } catch { /* ok */ }
|
||||
}
|
||||
this.clearSession();
|
||||
}
|
||||
|
||||
// ---- 内部 ----
|
||||
|
||||
private makeTmpFile(name: string, content: string): string {
|
||||
const dir = path.join(os.tmpdir(), 'ai-diff-preview');
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +1,128 @@
|
||||
/**
|
||||
* 内联装饰器 - 渲染 Diff 高亮和按钮
|
||||
* 内联装饰器 - 在编辑器中渲染 Diff 红绿高亮
|
||||
*
|
||||
* 基于 FileChange.diffLines 在编辑器内显示:
|
||||
* - 绿色背景 + gutter 圆点: 新增行
|
||||
* - 红色背景 + 删除线: 删除行
|
||||
* - 行末浮动标签: ✓ Accept 本次编辑 · ✗ Reject
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { DiffChunk, ChunkType } from '../models/types';
|
||||
import { COLORS, DECORATION_TYPES } from '../models/constants';
|
||||
import { FileChange, DiffLine } from '../models/types';
|
||||
import { COLORS } from '../models/constants';
|
||||
|
||||
export class InlineDecorator {
|
||||
private addDecorationType: vscode.TextEditorDecorationType;
|
||||
private deleteDecorationType: vscode.TextEditorDecorationType;
|
||||
private modifyDecorationType: vscode.TextEditorDecorationType;
|
||||
private conflictDecorationType: vscode.TextEditorDecorationType;
|
||||
private acceptButtonDecorationType: vscode.TextEditorDecorationType;
|
||||
private acceptLabelType: vscode.TextEditorDecorationType;
|
||||
|
||||
constructor() {
|
||||
// 新增行: 绿色背景
|
||||
this.addDecorationType = vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: COLORS.ADD_BACKGROUND,
|
||||
isWholeLine: true,
|
||||
gutterIconPath: this.createGutterIconPath(COLORS.ADD_GUTTER),
|
||||
gutterIconPath: this.gutterIcon(COLORS.ADD_GUTTER),
|
||||
gutterIconSize: 'contain',
|
||||
});
|
||||
|
||||
// 删除行: 红色背景 + 删除线
|
||||
this.deleteDecorationType = vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: COLORS.DELETE_BACKGROUND,
|
||||
isWholeLine: true,
|
||||
textDecoration: 'line-through',
|
||||
gutterIconPath: this.createGutterIconPath(COLORS.DELETE_GUTTER),
|
||||
gutterIconPath: this.gutterIcon(COLORS.DELETE_GUTTER),
|
||||
gutterIconSize: 'contain',
|
||||
});
|
||||
|
||||
// 修改行: 黄色背景
|
||||
this.modifyDecorationType = vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: COLORS.MODIFY_BACKGROUND,
|
||||
isWholeLine: true,
|
||||
gutterIconPath: this.createGutterIconPath(COLORS.MODIFY_GUTTER),
|
||||
gutterIconSize: 'contain',
|
||||
});
|
||||
|
||||
this.conflictDecorationType = vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: COLORS.CONFLICT_BACKGROUND,
|
||||
isWholeLine: true,
|
||||
borderColor: COLORS.MODIFY_GUTTER,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '1px',
|
||||
});
|
||||
|
||||
this.acceptButtonDecorationType = vscode.window.createTextEditorDecorationType({
|
||||
// 浮动标签: 在新增行的末尾显示 ✓ Accept
|
||||
this.acceptLabelType = vscode.window.createTextEditorDecorationType({
|
||||
after: {
|
||||
contentText: ' ✓ Accept',
|
||||
color: '#ffffff',
|
||||
backgroundColor: COLORS.ADD_GUTTER,
|
||||
margin: '0 0 0 1em',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
cursor: 'pointer',
|
||||
contentText: ' ← AI 变更',
|
||||
color: '#888',
|
||||
fontStyle: 'italic',
|
||||
margin: '0 0 0 16px',
|
||||
},
|
||||
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 deleteRanges: vscode.Range[] = [];
|
||||
const modifyRanges: vscode.Range[] = [];
|
||||
const conflictRanges: vscode.Range[] = [];
|
||||
const buttonRanges: vscode.Range[] = [];
|
||||
const labelRanges: vscode.Range[] = [];
|
||||
|
||||
chunks.forEach(chunk => {
|
||||
if (chunk.status === 'accepted' || chunk.status === 'rejected') {
|
||||
return; // 跳过已处理的块
|
||||
}
|
||||
for (const d of fileChange.diffLines) {
|
||||
if (d.type === 'context') continue;
|
||||
|
||||
const range = new vscode.Range(
|
||||
new vscode.Position(chunk.startLine, 0),
|
||||
new vscode.Position(chunk.endLine, 0)
|
||||
);
|
||||
// 使用 newLineNum(编辑器当前行号)
|
||||
const line = d.newLineNum > 0 ? d.newLineNum - 1 : d.oldLineNum - 1;
|
||||
if (line < 0) continue;
|
||||
|
||||
switch (chunk.type) {
|
||||
const range = new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER);
|
||||
|
||||
switch (d.type) {
|
||||
case 'add':
|
||||
addRanges.push(range);
|
||||
buttonRanges.push(range);
|
||||
labelRanges.push(range);
|
||||
break;
|
||||
case 'delete':
|
||||
deleteRanges.push(range);
|
||||
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.deleteDecorationType, deleteRanges);
|
||||
editor.setDecorations(this.modifyDecorationType, modifyRanges);
|
||||
editor.setDecorations(this.conflictDecorationType, conflictRanges);
|
||||
editor.setDecorations(this.acceptButtonDecorationType, buttonRanges);
|
||||
editor.setDecorations(this.acceptLabelType, labelRanges);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有装饰器
|
||||
* 清除编辑器的所有装饰
|
||||
*/
|
||||
clearDecorations(editor: vscode.TextEditor): void {
|
||||
clearEditor(editor: vscode.TextEditor): void {
|
||||
editor.setDecorations(this.addDecorationType, []);
|
||||
editor.setDecorations(this.deleteDecorationType, []);
|
||||
editor.setDecorations(this.modifyDecorationType, []);
|
||||
editor.setDecorations(this.conflictDecorationType, []);
|
||||
editor.setDecorations(this.acceptButtonDecorationType, []);
|
||||
editor.setDecorations(this.acceptLabelType, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
* 清除所有活跃编辑器的装饰
|
||||
*/
|
||||
clearAll(): void {
|
||||
for (const editor of vscode.window.visibleTextEditors) {
|
||||
this.clearEditor(editor);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.addDecorationType.dispose();
|
||||
this.deleteDecorationType.dispose();
|
||||
this.modifyDecorationType.dispose();
|
||||
this.conflictDecorationType.dispose();
|
||||
this.acceptButtonDecorationType.dispose();
|
||||
this.acceptLabelType.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
@@ -1,39 +1,47 @@
|
||||
/**
|
||||
* Review 面板 - 内嵌 Webview 悬浮窗
|
||||
* ★ 总览面板 — 展示所有变更文件的详细 Diff
|
||||
*
|
||||
* ★ v0.2.0: 支持 per-edit 和 per-file 两级 Accept/Reject
|
||||
* - 每个文件卡片展开后显示各次编辑记录
|
||||
* - 每次编辑有独立的 ✓/✗ 按钮
|
||||
* - 文件级 Accept/Reject 一键处理所有编辑
|
||||
* 功能:
|
||||
* - 每个文件一个可折叠的卡片,默认展开
|
||||
* - 卡片内: 双栏 Diff(原始 ↔ 变更后)
|
||||
* - 文件级 Accept/Reject 按钮
|
||||
* - 顶部全局 Accept All / Reject All
|
||||
* - 全部处理后显示完成提示
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
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 {
|
||||
private panel: vscode.WebviewPanel | undefined;
|
||||
private changeSet: ChangeSet | undefined;
|
||||
|
||||
constructor(
|
||||
private changeSetManager: ChangeSetManager
|
||||
private changeSetManager: ChangeSetManager,
|
||||
private diffViewer?: DiffViewer
|
||||
) {}
|
||||
|
||||
/** 显示总览面板 */
|
||||
show(): void {
|
||||
const changeSet = this.changeSetManager.getCurrentSet();
|
||||
if (!changeSet || changeSet.changes.length === 0) {
|
||||
const cs = this.changeSetManager.getCurrentSet();
|
||||
if (!cs || cs.changes.length === 0) {
|
||||
vscode.window.showInformationMessage('AI Diff: 无变更');
|
||||
return;
|
||||
}
|
||||
this.changeSet = cs;
|
||||
|
||||
if (this.panel) {
|
||||
this.panel.reveal(vscode.ViewColumn.Active);
|
||||
this.sendData(changeSet);
|
||||
this.panel.reveal(vscode.ViewColumn.Beside);
|
||||
this.sendData(cs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'aiDiffReview',
|
||||
'AI Diff Review',
|
||||
{ viewColumn: vscode.ViewColumn.Active, preserveFocus: false },
|
||||
'aiDiffOverview',
|
||||
'AI Diff 总览',
|
||||
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
|
||||
{ enableScripts: true, retainContextWhenHidden: true }
|
||||
);
|
||||
|
||||
@@ -42,30 +50,23 @@ export class ReviewPanel {
|
||||
this.panel.webview.onDidReceiveMessage((msg: any) => {
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
this.sendData(changeSet);
|
||||
this.sendData(this.changeSet!);
|
||||
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':
|
||||
this.changeSetManager.acceptFile(msg.fileId);
|
||||
this.refresh();
|
||||
// 如果有 DiffViewer 且与此文件匹配,关闭它
|
||||
if (this.diffViewer?.getActiveSession()?.fileId === msg.fileId) {
|
||||
this.diffViewer.closeDiff();
|
||||
}
|
||||
break;
|
||||
case 'rejectFile':
|
||||
this.changeSetManager.rejectFile(msg.fileId);
|
||||
this.refresh();
|
||||
if (this.diffViewer?.getActiveSession()?.fileId === msg.fileId) {
|
||||
this.diffViewer.closeDiff();
|
||||
}
|
||||
break;
|
||||
|
||||
// 全局操作
|
||||
case 'acceptAll':
|
||||
this.changeSetManager.acceptAll();
|
||||
this.refresh();
|
||||
@@ -80,15 +81,16 @@ export class ReviewPanel {
|
||||
this.panel.onDidDispose(() => { this.panel = undefined; });
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const changeSet = this.changeSetManager.getCurrentSet();
|
||||
if (changeSet && this.panel) {
|
||||
this.sendData(changeSet);
|
||||
refresh(): void {
|
||||
const cs = this.changeSetManager.getCurrentSet();
|
||||
if (cs && this.panel) {
|
||||
this.changeSet = cs;
|
||||
this.sendData(cs);
|
||||
}
|
||||
}
|
||||
|
||||
private sendData(changeSet: ChangeSet): void {
|
||||
this.panel?.webview.postMessage({ type: 'init', changeSet });
|
||||
private sendData(cs: ChangeSet): void {
|
||||
this.panel?.webview.postMessage({ type: 'init', changeSet: cs });
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -96,101 +98,90 @@ export class ReviewPanel {
|
||||
this.panel = undefined;
|
||||
}
|
||||
|
||||
// ---- HTML ----
|
||||
|
||||
private getHtml(): string {
|
||||
return /*html*/`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';">
|
||||
<style>
|
||||
* { 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); }
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:var(--vscode-font-family);font-size:13px;color:var(--vscode-foreground);background:var(--vscode-editor-background);padding:0}
|
||||
|
||||
.toolbar {
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
padding:10px 16px; background:var(--vscode-titleBar-activeBackground);
|
||||
border-bottom:1px solid var(--vscode-panel-border); position:sticky; top:0; z-index:10;
|
||||
.header{
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
padding:12px 16px;background:var(--vscode-titleBar-activeBackground);
|
||||
border-bottom:1px solid var(--vscode-panel-border);position:sticky;top:0;z-index:10
|
||||
}
|
||||
.toolbar h2 { font-size:14px; font-weight:600; }
|
||||
.toolbar .actions { display:flex; gap:8px; }
|
||||
.btn {
|
||||
padding:6px 16px; border:none; border-radius:4px; cursor:pointer;
|
||||
font-size:12px; font-weight:500; transition:opacity .15s;
|
||||
.header h2{font-size:15px;font-weight:600}
|
||||
.header .btn-row{display:flex;gap:8px}
|
||||
.btn{
|
||||
padding:6px 14px;border:none;border-radius:4px;cursor:pointer;
|
||||
font-size:12px;font-weight:600;transition:opacity .15s
|
||||
}
|
||||
.btn:hover { opacity:.85; }
|
||||
.btn-accept { background:#2ea043; color:#fff; }
|
||||
.btn-reject { background:#da3633; color:#fff; }
|
||||
.btn:hover{opacity:.85}
|
||||
.btn-accept{background:#2ea043;color:#fff}
|
||||
.btn-reject{background:#da3633;color:#fff}
|
||||
|
||||
.files { padding:8px 0; }
|
||||
.files{padding:8px 0 24px}
|
||||
|
||||
.file-card {
|
||||
margin:6px 12px; border:1px solid var(--vscode-panel-border);
|
||||
border-radius:6px; overflow:hidden;
|
||||
.file-card{
|
||||
margin:8px 10px;border:1px solid var(--vscode-panel-border);
|
||||
border-radius:8px;overflow:hidden
|
||||
}
|
||||
.file-header {
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
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; }
|
||||
.file-card.accepted{opacity:.6;border-color:#2ea043}
|
||||
.file-card.rejected{opacity:.5;border-color:#da3633;text-decoration:line-through}
|
||||
|
||||
/* Diff 双栏 */
|
||||
.diff-wrap { display:grid; grid-template-columns:1fr 1fr; font-family:'Cascadia Code','Fira Code',Consolas,monospace; font-size:12px; line-height:20px; }
|
||||
.diff-side { overflow-x:auto; }
|
||||
.diff-side.left { border-right:1px solid var(--vscode-panel-border); }
|
||||
.diff-title {
|
||||
padding:4px 12px; font-size:11px; font-weight:600;
|
||||
color:var(--vscode-descriptionForeground); border-bottom:1px solid var(--vscode-panel-border);
|
||||
.file-header{
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
padding:10px 14px;background:var(--vscode-sideBar-background);
|
||||
cursor:pointer;user-select:none
|
||||
}
|
||||
.diff-side.left .diff-title { background:rgba(248,81,73,.06); }
|
||||
.diff-side.right .diff-title { background:rgba(46,160,67,.06); }
|
||||
.diff-line { display:flex; min-height:20px; }
|
||||
.ln { min-width:36px; padding:0 8px; text-align:right; color:var(--vscode-editorLineNumber-foreground); user-select:none; flex-shrink:0; }
|
||||
.lc { padding:0 10px; white-space:pre; flex:1; }
|
||||
.diff-line.add { background:rgba(46,160,67,.12); }
|
||||
.diff-line.del { background:rgba(248,81,73,.12); }
|
||||
.diff-line.ctx { opacity:.55; }
|
||||
.diff-side.left .diff-line.add { background:transparent; }
|
||||
.diff-side.right .diff-line.del { background:transparent; }
|
||||
.empty-line { min-height:20px; }
|
||||
.file-header:hover{background:var(--vscode-list-hoverBackground)}
|
||||
.file-title{display:flex;align-items:center;gap:8px;font-weight:600}
|
||||
.badge{
|
||||
font-size:10px;padding:2px 7px;border-radius:3px;font-weight:700
|
||||
}
|
||||
.badge-mod{background:rgba(210,153,34,.2);color:#d29922}
|
||||
.badge-new{background:rgba(46,160,67,.2);color:#3fb950}
|
||||
.badge-del{background:rgba(248,81,73,.2);color:#f85149}
|
||||
.badge-ok{background:rgba(46,160,67,.3);color:#3fb950}
|
||||
.badge-no{background:rgba(248,81,73,.3);color:#f85149}
|
||||
.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}
|
||||
|
||||
/* ★ 编辑记录列表 */
|
||||
.edits-list { padding:0; }
|
||||
.edit-item {
|
||||
border-bottom:1px solid var(--vscode-panel-border);
|
||||
.diff-wrap{display:grid;grid-template-columns:1fr 1fr}
|
||||
.diff-table{font-family:'Cascadia Code',Consolas,monospace;font-size:12px;line-height:20px;border-collapse:collapse;width:100%}
|
||||
.diff-table td{padding:0 8px;white-space:pre;vertical-align:top}
|
||||
.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; }
|
||||
.edit-header {
|
||||
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; }
|
||||
.left-title{background:rgba(248,81,73,.06);color:#f85149}
|
||||
.right-title{background:rgba(46,160,67,.06);color:#3fb950}
|
||||
|
||||
.summary-stats { font-size:11px; color:var(--vscode-descriptionForeground); padding:4px 12px; }
|
||||
.stat-add { color:#3fb950; }
|
||||
.stat-del { color:#f85149; }
|
||||
.empty-state{text-align:center;padding:40px;color:var(--vscode-descriptionForeground)}
|
||||
.empty-state .icon{font-size:32px}
|
||||
.empty-state h3{margin:12px 0 4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<h2>🔍 AI Diff Review</h2>
|
||||
<div class="actions">
|
||||
<button class="btn btn-accept" onclick="acceptAll()">✓ Accept All</button>
|
||||
<button class="btn btn-reject" onclick="rejectAll()">✗ Reject All</button>
|
||||
|
||||
<div class="header">
|
||||
<h2>🔍 AI Diff 总览</h2>
|
||||
<div class="btn-row">
|
||||
<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 class="files" id="files"></div>
|
||||
@@ -198,7 +189,6 @@ export class ReviewPanel {
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
let cs = null;
|
||||
|
||||
vscode.postMessage({type:'ready'});
|
||||
|
||||
window.addEventListener('message', e => {
|
||||
@@ -206,146 +196,100 @@ window.addEventListener('message', e => {
|
||||
if (m.type === 'init') { cs = m.changeSet; render(); }
|
||||
});
|
||||
|
||||
function acceptAll(){ vscode.postMessage({type:'acceptAll'}); }
|
||||
function rejectAll(){ vscode.postMessage({type:'rejectAll'}); }
|
||||
function acceptFile(id){ vscode.postMessage({type:'acceptFile',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 acceptAll(){ vscode.postMessage({type:'acceptAll'}) }
|
||||
function rejectAll(){ vscode.postMessage({type:'rejectAll'}) }
|
||||
function acceptFile(id){ vscode.postMessage({type:'acceptFile',fileId:id}) }
|
||||
function rejectFile(id){ vscode.postMessage({type:'rejectFile',fileId:id}) }
|
||||
|
||||
function render(){
|
||||
if(!cs) return;
|
||||
const el = document.getElementById('files');
|
||||
el.innerHTML = '';
|
||||
cs.changes.forEach(c => el.appendChild(card(c)));
|
||||
const pending = cs.changes.filter(c => c.status === 'pending');
|
||||
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){
|
||||
const d = document.createElement('div');
|
||||
d.className = 'file-card';
|
||||
const name = c.filePath.split(/[/\\\\]/).pop();
|
||||
const typeMap = {modify:'修改',create:'新增',delete:'删除'};
|
||||
const badgeCls = 'badge badge-' + c.type;
|
||||
function renderCard(c){
|
||||
const classes = c.status === 'accepted' ? 'file-card accepted' : c.status === 'rejected' ? 'file-card rejected' : 'file-card';
|
||||
const name = c.filePath.split(/[\\\\/]/).pop();
|
||||
const typeMap = {modify:['修改','badge-mod'],create:['新增','badge-new'],delete:['删除','badge-del']};
|
||||
const [tlabel,tcls] = typeMap[c.type] || [c.type,''];
|
||||
|
||||
let statusBadge = '';
|
||||
if(c.status==='accepted') statusBadge = '<span class="badge badge-accepted">已接受</span>';
|
||||
if(c.status==='rejected') statusBadge = '<span class="badge badge-rejected">已拒绝</span>';
|
||||
if(c.status==='accepted') statusBadge = '<span class="badge badge-ok">已接受</span>';
|
||||
if(c.status==='rejected') statusBadge = '<span class="badge badge-no">已拒绝</span>';
|
||||
|
||||
// 统计增减行数
|
||||
let adds = 0, dels = 0;
|
||||
c.diffLines.forEach(l => { if(l.type==='add') adds++; if(l.type==='delete') dels++; });
|
||||
let adds=0,dels=0;
|
||||
c.diffLines.forEach(l => { if(l.type==='add')adds++; if(l.type==='delete')dels++; });
|
||||
|
||||
d.innerHTML =
|
||||
'<div class="file-header" onclick="this.nextElementSibling.classList.toggle(' + "'" + 'collapsed' + "'" + ')">' +
|
||||
'<span class="file-path">📄 ' + esc(name) + ' <span class="' + badgeCls + '">' + typeMap[c.type] + '</span>' + statusBadge + '</span>' +
|
||||
'<span class="summary-stats"><span class="stat-add">+' + adds + '</span> <span class="stat-del">-' + dels + '</span></span>' +
|
||||
(c.status==='pending' ?
|
||||
'<span class="file-actions">' +
|
||||
'<button class="btn-sm btn-accept" onclick="event.stopPropagation();acceptFile(' + "'" + c.id + "'" + ')">✓ 全部接受</button>' +
|
||||
'<button class="btn-sm btn-reject" onclick="event.stopPropagation();rejectFile(' + "'" + c.id + "'" + ')">✗ 全部拒绝</button>' +
|
||||
'</span>' : ''
|
||||
) +
|
||||
const actions = c.status === 'pending' ?
|
||||
'<span class="file-actions" onclick="event.stopPropagation()">' +
|
||||
'<button class="btn-sm btn-accept" onclick="acceptFile(\'' + c.id + '\')">✔ 接受</button>' +
|
||||
'<button class="btn-sm btn-reject" onclick="rejectFile(\'' + c.id + '\')">✘ 拒绝</button>' +
|
||||
'</span>' : '';
|
||||
|
||||
return '<div class="' + classes + '">' +
|
||||
'<div class="file-header" onclick="this.nextElementSibling.classList.toggle(\'hidden\')">' +
|
||||
'<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 class="diff-body">' +
|
||||
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 class="">' + buildDiff(c) + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildDiff(c){
|
||||
if(c.type==='create') return buildSingle(c.latestContent.split('\\n'),'right','新文件');
|
||||
if(c.type==='delete') return buildSingle(c.originalContent.split('\\n'),'left','已删除');
|
||||
return buildDual(c.diffLines||[]);
|
||||
if(c.type === 'create') return buildSingle(c.latestContent, '新文件内容 (右侧)');
|
||||
if(c.type === 'delete') return buildSingle(c.originalContent, '已删除 (原始内容)');
|
||||
return buildDual(c.diffLines || []);
|
||||
}
|
||||
|
||||
function buildSingle(lines,side,title){
|
||||
let h = '<div class="diff-wrap"><div class="diff-side ' + side + '"><div class="diff-title">' + title + '</div>';
|
||||
lines.forEach((l,i) => {
|
||||
h += '<div class="diff-line add"><span class="ln">' + (i+1) + '</span><span class="lc">' + esc(l) + '</span></div>';
|
||||
});
|
||||
return h + '</div></div>';
|
||||
function buildSingle(text, label){
|
||||
const lines = text.split('\\n');
|
||||
let rows = '';
|
||||
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>' +
|
||||
'<table class="diff-table">'+rows+'</table>';
|
||||
}
|
||||
|
||||
function buildDual(dlines){
|
||||
let lh='', rh='';
|
||||
let left='',right='';
|
||||
dlines.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>';
|
||||
left += '<tr class="ctx"><td class="ln">'+l.oldLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
|
||||
right += '<tr class="ctx"><td class="ln">'+l.newLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
|
||||
} 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>';
|
||||
left += '<tr class="del"><td class="ln">'+l.oldLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
|
||||
right += '<tr class="empty"><td></td><td></td></tr>';
|
||||
} 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>';
|
||||
left += '<tr class="empty"><td></td><td></td></tr>';
|
||||
right += '<tr class="add"><td class="ln">'+l.newLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
|
||||
}
|
||||
});
|
||||
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 class="diff-side-title left-title">— 原始代码</div><table class="diff-table">'+left+'</table></div>' +
|
||||
'<div><div class="diff-side-title right-title">+ 变更后</div><table class="diff-table">'+right+'</table></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') }
|
||||
</script>
|
||||
<style>.collapsed .diff-body{display:none;} .collapsed{display:none;}</style>
|
||||
<style>.hidden{display:none !important}</style>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
+105
-48
@@ -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 { Snapshot } from '../models/types';
|
||||
import { STATUS_BAR_PRIORITY } from '../models/constants';
|
||||
import { ChangeSetManager } from '../snapshot/snapshotManager';
|
||||
import { DiffViewer, DiffSession } from './diffViewer';
|
||||
import { COMMANDS } from '../models/constants';
|
||||
|
||||
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() {
|
||||
this.statusBarItem = vscode.window.createStatusBarItem(
|
||||
constructor(
|
||||
private changeSetManager: ChangeSetManager,
|
||||
private diffViewer: DiffViewer
|
||||
) {
|
||||
// ★ 常驻摘要 — 始终显示
|
||||
this.summaryItem = vscode.window.createStatusBarItem(
|
||||
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(
|
||||
vscode.StatusBarAlignment.Left,
|
||||
STATUS_BAR_PRIORITY.CHUNK_COUNT
|
||||
// Accept 按钮
|
||||
this.acceptBtn = vscode.window.createStatusBarItem(
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新状态栏
|
||||
*/
|
||||
update(snapshot: Snapshot): void {
|
||||
const pendingChunks = snapshot.chunks.filter(c => c.status === 'pending');
|
||||
const conflictChunks = snapshot.chunks.filter(c => c.status === 'conflict');
|
||||
enterDiffMode(session: DiffSession): void {
|
||||
const label = session.editId ? '编辑' : '文件';
|
||||
this.acceptBtn.text = `$(check) Accept 本次${label}变更`;
|
||||
this.acceptBtn.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
this.acceptBtn.tooltip = `接受此${label}的变更并跳转到下一个`;
|
||||
this.acceptBtn.show();
|
||||
|
||||
// 更新主状态
|
||||
this.statusBarItem.text = '$(diff) AI Diff';
|
||||
this.statusBarItem.tooltip = 'AI Diff Preview 活跃';
|
||||
this.statusBarItem.show();
|
||||
this.rejectBtn.text = `$(close) Reject 本次${label}变更`;
|
||||
this.rejectBtn.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
|
||||
this.rejectBtn.tooltip = `拒绝此${label}的变更并跳转到下一个`;
|
||||
this.rejectBtn.show();
|
||||
|
||||
// 更新变更块数量
|
||||
if (pendingChunks.length > 0 || conflictChunks.length > 0) {
|
||||
this.chunkCountItem.text = `$(edit) ${pendingChunks.length} 个变更`;
|
||||
if (conflictChunks.length > 0) {
|
||||
this.chunkCountItem.text += ` (${conflictChunks.length} 个冲突)`;
|
||||
this.chunkCountItem.backgroundColor = new vscode.ThemeColor(
|
||||
'statusBarItem.warningBackground'
|
||||
);
|
||||
} else {
|
||||
this.chunkCountItem.backgroundColor = undefined;
|
||||
}
|
||||
this.chunkCountItem.tooltip = '点击查看变更详情';
|
||||
this.chunkCountItem.show();
|
||||
this.acceptAllBtn.text = '$(check-all) 接受此文件全部';
|
||||
this.acceptAllBtn.tooltip = '接受此文件的所有编辑';
|
||||
this.acceptAllBtn.show();
|
||||
}
|
||||
|
||||
exitDiffMode(): void {
|
||||
this.acceptBtn.hide();
|
||||
this.rejectBtn.hide();
|
||||
this.acceptAllBtn.hide();
|
||||
}
|
||||
|
||||
/** ★ 常驻刷新 */
|
||||
refresh(): void {
|
||||
const session = this.diffViewer.getActiveSession();
|
||||
if (session) {
|
||||
this.enterDiffMode(session);
|
||||
} 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 {
|
||||
this.statusBarItem.dispose();
|
||||
this.chunkCountItem.dispose();
|
||||
this.summaryItem.dispose();
|
||||
this.acceptBtn.dispose();
|
||||
this.rejectBtn.dispose();
|
||||
this.acceptAllBtn.dispose();
|
||||
}
|
||||
}
|
||||
+88
-146
@@ -19,7 +19,6 @@ function norm(p: string): string {
|
||||
|
||||
export class ChangeSetManager {
|
||||
private currentSet: ChangeSet | null = null;
|
||||
/** 原始文件内容快照(每个文件第一次编辑前捕获) */
|
||||
private originalContents: Map<string, string> = new Map();
|
||||
|
||||
startNewSet(): string {
|
||||
@@ -49,59 +48,43 @@ export class ChangeSetManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 记录一次文件编辑(重构后)
|
||||
* ★ 记录一次文件编辑
|
||||
*
|
||||
* 核心逻辑:
|
||||
* 1. 每次 Edit/Write 创建独立 EditRecord
|
||||
* 2. 如果该文件已有 pending 的 FileChange → 追加 EditRecord
|
||||
* 3. 如果该文件的 FileChange 已 accepted/rejected → 创建新 FileChange(修复 Bug)
|
||||
* 4. 如果该文件没有 FileChange → 创建新 FileChange
|
||||
* 合并规则:
|
||||
* - 优先找同文件 pending 的 FileChange → 追加 EditRecord,更新聚合 diff
|
||||
* - 没有 pending 的 → 创建新 FileChange(首次编辑 / 前轮已处理完)
|
||||
*/
|
||||
recordEdit(filePath: string, toolName: string, oldString: string, newString: string, _content: string): void {
|
||||
if (!this.currentSet) {
|
||||
this.startNewSet();
|
||||
}
|
||||
if (!this.currentSet) this.startNewSet();
|
||||
|
||||
filePath = norm(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: 查找该文件是否已有变更记录
|
||||
const existingChange = this.currentSet!.changes.find(c => norm(c.filePath) === filePath);
|
||||
console.log(`[AI Diff] recordEdit: ${toolName} → ${filePath} (pending=${!!pendingChange}, nonPending=${hasNonPending})`);
|
||||
|
||||
// ★ 如果已有变更记录且已 accepted/rejected,需要重置缓存以重新捕获
|
||||
if (existingChange && existingChange.status !== 'pending') {
|
||||
this.originalContents.delete(filePath);
|
||||
}
|
||||
// 没有 pending → 清除原始缓存重新捕获
|
||||
if (!pendingChange) this.originalContents.delete(filePath);
|
||||
|
||||
// Step 2: 确保已捕获原始内容
|
||||
const capturedOriginal = this.captureOriginalContent(filePath, toolName, oldString, newString, currentContent);
|
||||
|
||||
// Step 3: 确定本次编辑的 beforeContent
|
||||
// 确定 beforeContent
|
||||
let beforeContent: string;
|
||||
|
||||
if (existingChange) {
|
||||
const lastEdit = existingChange.edits[existingChange.edits.length - 1];
|
||||
if (existingChange.status === 'pending') {
|
||||
// 还在收集中,before 使用上一次编辑的 afterContent
|
||||
beforeContent = lastEdit.afterContent;
|
||||
} else {
|
||||
// ★ Bug 修复:已 accepted/rejected,需要新的 beforeContent
|
||||
// 重新捕获:当前文件内容 = 本次编辑后的结果
|
||||
beforeContent = this.reverseEdit(currentContent, oldString, newString);
|
||||
}
|
||||
if (pendingChange) {
|
||||
// ★ 合并:before = 上一次编辑后的文件状态
|
||||
beforeContent = pendingChange.latestContent;
|
||||
} else {
|
||||
// 首次编辑该文件
|
||||
// 新追踪:before = 从当前文件反推的原始内容
|
||||
beforeContent = capturedOriginal;
|
||||
}
|
||||
|
||||
// Step 3: 计算本次编辑的独立 Diff
|
||||
const afterContent = currentContent;
|
||||
const editDiffLines = this.computeDiffLines(beforeContent, afterContent);
|
||||
|
||||
// Step 4: 创建 EditRecord
|
||||
const editRecord: EditRecord = {
|
||||
id: uuidv4(),
|
||||
filePath,
|
||||
@@ -112,23 +95,18 @@ export class ChangeSetManager {
|
||||
newString: newString || '',
|
||||
diffLines: editDiffLines,
|
||||
status: 'pending',
|
||||
timestamp: now,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Step 5: 决定 FileChange 策略
|
||||
if (existingChange && existingChange.status === 'pending') {
|
||||
// 追加到已有 FileChange
|
||||
existingChange.edits.push(editRecord);
|
||||
existingChange.latestContent = afterContent;
|
||||
existingChange.diffLines = this.computeDiffLines(existingChange.originalContent, afterContent);
|
||||
console.log(`[AI Diff] 追加 EditRecord 到已有 FileChange: ${filePath}, edits=${existingChange.edits.length}`);
|
||||
if (pendingChange) {
|
||||
// ★ 追加到已有 pending FileChange,更新聚合 diff
|
||||
pendingChange.edits.push(editRecord);
|
||||
pendingChange.latestContent = afterContent;
|
||||
pendingChange.diffLines = this.computeDiffLines(pendingChange.originalContent, afterContent);
|
||||
} else {
|
||||
// 创建新 FileChange(首次编辑 或 之前的已 accepted/rejected)
|
||||
const changeType: FileChangeType = (toolName === 'Write' && !capturedOriginal)
|
||||
? 'create'
|
||||
: 'modify';
|
||||
|
||||
const fileChange: FileChange = {
|
||||
// ★ 新建 FileChange
|
||||
const changeType: FileChangeType = (toolName === 'Write' && !capturedOriginal) ? 'create' : 'modify';
|
||||
const fc: FileChange = {
|
||||
id: uuidv4(),
|
||||
filePath,
|
||||
type: changeType,
|
||||
@@ -138,85 +116,57 @@ export class ChangeSetManager {
|
||||
diffLines: editDiffLines,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
this.currentSet!.changes.push(fc);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获文件的原始内容
|
||||
*/
|
||||
private captureOriginalContent(
|
||||
filePath: string,
|
||||
toolName: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
currentContent: string
|
||||
filePath: string, toolName: string, oldString: string, newString: string, currentContent: string
|
||||
): string {
|
||||
if (this.originalContents.has(filePath)) {
|
||||
return this.originalContents.get(filePath)!;
|
||||
}
|
||||
|
||||
let original: string;
|
||||
|
||||
if (toolName === 'Write') {
|
||||
// Write: 无法获取旧内容
|
||||
original = '';
|
||||
} else if (toolName === 'Edit' && oldString) {
|
||||
// Edit: 从当前文件中反推原始内容
|
||||
const idx = currentContent.indexOf(newString);
|
||||
if (idx !== -1) {
|
||||
original = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length);
|
||||
} else {
|
||||
// newString 不在文件中,可能已被之前的编辑影响
|
||||
original = oldString;
|
||||
}
|
||||
} else {
|
||||
original = currentContent;
|
||||
}
|
||||
|
||||
this.originalContents.set(filePath, original);
|
||||
console.log(`[AI Diff] 捕获原始内容: ${filePath} (${original.length} 字符)`);
|
||||
return original;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反推编辑前的内容(用于已 accepted/rejected 后新编辑的场景)
|
||||
* currentContent = 编辑后内容,reverseEdit = 从后往前还原
|
||||
*/
|
||||
private reverseEdit(currentContent: string, oldString: string, newString: string): string {
|
||||
if (!newString) return currentContent;
|
||||
const idx = currentContent.indexOf(newString);
|
||||
if (idx !== -1) {
|
||||
const before = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length);
|
||||
return before;
|
||||
return currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length);
|
||||
}
|
||||
return currentContent;
|
||||
}
|
||||
|
||||
// ---- 状态更新 ----
|
||||
|
||||
/** 更新单个编辑记录的状态 */
|
||||
updateEditStatus(fileId: string, editId: string, status: ChangeStatus): void {
|
||||
if (!this.currentSet) return;
|
||||
const change = this.currentSet.changes.find(c => c.id === fileId);
|
||||
if (!change) return;
|
||||
const edit = change.edits.find(e => e.id === editId);
|
||||
if (edit) edit.status = status;
|
||||
|
||||
// 如果该文件所有编辑都已处理,更新文件状态
|
||||
if (change.edits.every(e => e.status !== 'pending')) {
|
||||
const allAccepted = change.edits.every(e => e.status === 'accepted');
|
||||
change.status = allAccepted ? 'accepted' : 'rejected';
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新整个文件的状态(同时更新所有编辑) */
|
||||
updateFileStatus(fileId: string, status: ChangeStatus): void {
|
||||
if (!this.currentSet) return;
|
||||
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 {
|
||||
this.updateEditStatus(fileId, editId, 'accepted');
|
||||
const change = this.findChange(fileId);
|
||||
if (!change) return false;
|
||||
|
||||
// 如果整个文件所有编辑都已接受,写入最终的 latestContent
|
||||
const edit = change.edits.find(e => e.id === editId);
|
||||
if (!edit) return false;
|
||||
// 此 edit 变更已在磁盘上,只需标记状态
|
||||
this.updateEditStatus(fileId, editId, 'accepted');
|
||||
if (change.edits.every(e => e.status === 'accepted')) {
|
||||
return this.writeFile(change.filePath, change.latestContent);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受整个文件的所有编辑
|
||||
*/
|
||||
/** ★ 接受整个文件:先写入再标记 */
|
||||
acceptFile(fileId: string): boolean {
|
||||
this.updateFileStatus(fileId, 'accepted');
|
||||
const change = this.findChange(fileId);
|
||||
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 {
|
||||
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;
|
||||
for (const change of this.currentSet.changes) {
|
||||
if (change.status === 'pending' && this.acceptFile(change.id)) {
|
||||
count++;
|
||||
}
|
||||
for (const id of pendingIds) {
|
||||
if (this.acceptFile(id)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---- Reject(恢复原始内容) ----
|
||||
// ---- Reject ----
|
||||
|
||||
/**
|
||||
* 拒绝单个编辑 — 从磁盘文件中还原该编辑的变更
|
||||
*/
|
||||
rejectEditRecord(fileId: string, editId: string): boolean {
|
||||
const change = this.findChange(fileId);
|
||||
if (!change) return false;
|
||||
|
||||
const edit = change.edits.find(e => e.id === editId);
|
||||
if (!edit) return false;
|
||||
|
||||
// 从当前文件中还原该编辑:将 newString 替换回 oldString
|
||||
let reverted: string;
|
||||
try {
|
||||
const currentContent = this.readFile(change.filePath);
|
||||
if (currentContent !== null) {
|
||||
const revertedContent = this.revertEditInContent(currentContent, edit);
|
||||
this.writeFile(change.filePath, revertedContent);
|
||||
}
|
||||
if (currentContent === null) return false;
|
||||
reverted = this.revertEditInContent(currentContent, edit);
|
||||
} catch (e) {
|
||||
console.error(`[AI Diff] 还原编辑失败: ${change.filePath}`, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
const ok = this.writeFile(change.filePath, reverted);
|
||||
if (!ok) return false;
|
||||
this.updateEditStatus(fileId, editId, 'rejected');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝整个文件的所有编辑 — 恢复 originalContent
|
||||
*/
|
||||
/** ★ 拒绝整个文件:先恢复再标记 */
|
||||
rejectFile(fileId: string): boolean {
|
||||
const change = this.findChange(fileId);
|
||||
if (!change) return false;
|
||||
|
||||
this.updateFileStatus(fileId, 'rejected');
|
||||
|
||||
try {
|
||||
if (change.type === 'create') {
|
||||
// 新建的文件 → 删除
|
||||
if (fs.existsSync(change.filePath)) {
|
||||
fs.unlinkSync(change.filePath);
|
||||
}
|
||||
if (fs.existsSync(change.filePath)) fs.unlinkSync(change.filePath);
|
||||
} else {
|
||||
// 修改的文件 → 恢复原始内容
|
||||
this.writeFile(change.filePath, change.originalContent);
|
||||
const ok = this.writeFile(change.filePath, change.originalContent);
|
||||
if (!ok) return false;
|
||||
}
|
||||
console.log(`[AI Diff] 已拒绝: ${change.filePath}`);
|
||||
return true;
|
||||
console.log(`[AI Diff] 已拒绝文件: ${change.filePath}`);
|
||||
} catch (e) {
|
||||
console.error(`[AI Diff] 恢复文件失败: ${change.filePath}`, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.updateFileStatus(fileId, 'rejected');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝所有变更
|
||||
*/
|
||||
/** ★ 拒绝所有 pending 的文件 */
|
||||
rejectAll(): number {
|
||||
if (!this.currentSet) return 0;
|
||||
const pendingIds = this.currentSet.changes.filter(c => c.status === 'pending').map(c => c.id);
|
||||
let count = 0;
|
||||
for (const change of this.currentSet.changes) {
|
||||
if (change.status === 'pending' && this.rejectFile(change.id)) {
|
||||
count++;
|
||||
}
|
||||
for (const id of pendingIds) {
|
||||
if (this.rejectFile(id)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---- 工具方法 ----
|
||||
// ---- 工具 ----
|
||||
|
||||
/**
|
||||
* 在内容中还原单个编辑
|
||||
*/
|
||||
private revertEditInContent(content: string, edit: EditRecord): string {
|
||||
// 找到 newString 并替换回 oldString
|
||||
const idx = content.indexOf(edit.newString);
|
||||
if (idx !== -1) {
|
||||
return content.substring(0, idx) + edit.oldString + content.substring(idx + edit.newString.length);
|
||||
}
|
||||
// 找不到精确匹配,回退:使用 beforeContent
|
||||
console.warn(`[AI Diff] 无法精确定位编辑替换位置,回退到 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 {
|
||||
this.currentSet = null;
|
||||
this.originalContents.clear();
|
||||
@@ -379,7 +329,7 @@ export class ChangeSetManager {
|
||||
private writeFile(filePath: string, content: string): boolean {
|
||||
try {
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
console.log(`[AI Diff] 写入文件: ${filePath}`);
|
||||
console.log(`[AI Diff] 写入文件: ${filePath} (${content.length} 字符)`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[AI Diff] 写入文件失败: ${filePath}`, e);
|
||||
@@ -395,10 +345,7 @@ export class ChangeSetManager {
|
||||
|
||||
for (const change of changes) {
|
||||
const changeLines = change.value.split('\n');
|
||||
if (changeLines[changeLines.length - 1] === '') {
|
||||
changeLines.pop();
|
||||
}
|
||||
|
||||
if (changeLines[changeLines.length - 1] === '') changeLines.pop();
|
||||
for (const line of changeLines) {
|
||||
if (change.added) {
|
||||
lines.push({ type: 'add', oldLineNum: 0, newLineNum: newLine, content: line });
|
||||
@@ -413,18 +360,13 @@ export class ChangeSetManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private readFile(filePath: string): string | null {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
/** @legacy 向后兼容别名,供未迁移的模块使用 */
|
||||
/** @legacy 向后兼容别名 */
|
||||
export { ChangeSetManager as SnapshotManager };
|
||||
|
||||
+77
-77
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* Hook 处理器
|
||||
*
|
||||
* PostToolUse (Edit/Write) → 收集变更
|
||||
* + autoShowDiffPerEdit=true → 立即弹出 VSCode 内置 Diff
|
||||
* + autoShowDiffPerEdit=false → 静默收集
|
||||
*
|
||||
* Stop → 对话结束
|
||||
* + showAllDiffsOnStop=true → 弹出 QuickPick 文件列表
|
||||
* + showAllDiffsOnStop=false → 仅状态栏通知
|
||||
* Edit/Write → recordEdit()
|
||||
* Stop → QuickPick 总览 (含 Accept All / Reject All)
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
@@ -17,131 +12,136 @@ import { DiffViewer } from '../render/diffViewer';
|
||||
import { ClaudeHookEvent, FileChange } from '../models/types';
|
||||
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 {
|
||||
private diffViewer: DiffViewer;
|
||||
|
||||
constructor(
|
||||
private changeSetManager: ChangeSetManager
|
||||
) {
|
||||
this.diffViewer = new DiffViewer(changeSetManager);
|
||||
}
|
||||
private changeSetManager: ChangeSetManager,
|
||||
private diffViewer: DiffViewer
|
||||
) {}
|
||||
|
||||
getDiffViewer(): DiffViewer {
|
||||
return this.diffViewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 PostToolUse 事件(Edit/Write)
|
||||
* 收集变更,根据配置决定是否自动弹 Diff
|
||||
*/
|
||||
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 || ''
|
||||
event.filePath, event.toolName,
|
||||
event.oldString || '', event.newString || '', event.content || ''
|
||||
);
|
||||
|
||||
// 根据配置决定是否自动弹 Diff
|
||||
const autoShow = vscode.workspace.getConfiguration(CONFIG_SECTION).get<boolean>(CONFIG_KEYS.AUTO_SHOW_DIFF_PER_EDIT, false);
|
||||
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 normalizedPath = path.normalize(event.filePath).replace(/\\/g, '/');
|
||||
const fileChange = set.changes.find(
|
||||
c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath
|
||||
);
|
||||
if (fileChange) {
|
||||
const lastEdit = fileChange.edits[fileChange.edits.length - 1];
|
||||
this.diffViewer.showEditDiff(lastEdit);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Stop 事件(对话结束)
|
||||
*/
|
||||
handleStop(): void {
|
||||
const changeSet = this.changeSetManager.markReady();
|
||||
if (!changeSet) {
|
||||
console.log('[AI Diff] 对话结束,无文件变更');
|
||||
return;
|
||||
}
|
||||
if (!changeSet) { console.log('[AI Diff] 对话结束,无文件变更'); return; }
|
||||
|
||||
const count = changeSet.changes.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) {
|
||||
// 弹出 QuickPick 文件列表
|
||||
this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending'));
|
||||
} else {
|
||||
// 仅状态栏通知
|
||||
const msg = pendingCount > 0
|
||||
? `AI Diff: ${pendingCount} 个文件有待处理变更`
|
||||
: `AI Diff: ${count} 个文件变更已完成`;
|
||||
vscode.window.showInformationMessage(msg, '查看变更').then(selection => {
|
||||
if (selection === '查看变更') {
|
||||
this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending'));
|
||||
}
|
||||
? `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 文件列表
|
||||
*/
|
||||
async showFileListPicker(fileChanges?: FileChange[]): Promise<void> {
|
||||
let changes = fileChanges;
|
||||
if (!changes) {
|
||||
const set = this.changeSetManager.getCurrentSet();
|
||||
if (!set) return;
|
||||
changes = set.changes.filter(c => c.status === 'pending');
|
||||
changes = this.changeSetManager.getCurrentSet()?.changes.filter(c => c.status === 'pending') || [];
|
||||
}
|
||||
|
||||
if (changes.length === 0) {
|
||||
vscode.window.showInformationMessage('AI Diff: 无待处理变更');
|
||||
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 adds = c.diffLines.filter(d => d.type === 'add').length;
|
||||
const dels = c.diffLines.filter(d => d.type === 'delete').length;
|
||||
const typeMap: Record<string, string> = { modify: '$(edit)', create: '$(new-file)', delete: '$(trash)' };
|
||||
|
||||
return {
|
||||
items.push({
|
||||
itemKind: 'file',
|
||||
label: `${typeMap[c.type] || '$(file)'} ${name}`,
|
||||
description: `+${adds} -${dels} · ${c.edits.length} 次编辑`,
|
||||
detail: c.filePath,
|
||||
// 存储 fileChange id 以便查找
|
||||
id: c.id,
|
||||
};
|
||||
fileChange: c,
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
itemKind: 'separator',
|
||||
label: '──────────────────────────────',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `AI Diff Review — ${changes.length} 个文件有变更`,
|
||||
matchOnDescription: true,
|
||||
matchOnDetail: true,
|
||||
items.push({
|
||||
itemKind: 'action',
|
||||
label: '$(check-all) ✅ Accept All — 接受所有变更',
|
||||
description: `${changes.length} 文件 · +${totalAdds} -${totalDels} · ${totalEdits} 编辑`,
|
||||
action: 'acceptAll',
|
||||
});
|
||||
|
||||
if (selected && selected.id) {
|
||||
const fileChange = changes.find(c => c.id === selected.id);
|
||||
if (fileChange) {
|
||||
await this.diffViewer.showFileDiff(fileChange);
|
||||
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} 个文件 ✗`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} 个文件 ✗`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@
|
||||
## 第五轮测试 - 内嵌悬浮窗验证
|
||||
|
||||
这次修改用于验证:
|
||||
|
||||
1. Webview 面板能否正常弹出
|
||||
2. 双栏 Diff 是否正确显示新增/删除行
|
||||
3. Accept 按钮是否生效
|
||||
@@ -64,3 +65,50 @@
|
||||
- 修复了所有已知问题
|
||||
- 现在应该能正常工作
|
||||
- 验证: 面板弹出 + 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 按钮正常生效
|
||||
|
||||
Reference in New Issue
Block a user