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