From dfa3a585c59851e32e0b8247972957df5f474ef3 Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Thu, 18 Jun 2026 19:37:21 +0800 Subject: [PATCH] =?UTF-8?q?v0.2.0:=20VSCode=E5=86=85=E7=BD=AEDiff=20+=20Qu?= =?UTF-8?q?ickPick=E6=80=BB=E8=A7=88=20+=20EditRecord=E7=B2=92=E5=BA=A6=20?= =?UTF-8?q?+=20Bug=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - 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回归) --- CHANGELOG.md | 46 ++-- README.md | 184 ++++++++++----- docs/ARCHITECTURE.md | 241 ++++++++++++++++++++ package.json | 6 +- src/extension.ts | 267 ++++++++-------------- src/render/codeLensProvider.ts | 156 +++++++------ src/render/diffViewer.ts | 265 +++++++++++++++------- src/render/inlineDecorator.ts | 121 +++++----- src/render/reviewPanel.ts | 384 ++++++++++++++------------------ src/render/statusBar.ts | 155 +++++++++---- src/snapshot/snapshotManager.ts | 234 ++++++++----------- src/trigger/hookHandler.ts | 154 ++++++------- src/trigger/hookHandler_bak.ts | 151 +++++++++++++ 测试.md | 48 ++++ 14 files changed, 1454 insertions(+), 958 deletions(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 src/trigger/hookHandler_bak.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f6dfbf6..7db5b7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,46 +1,40 @@ # Changelog -All notable changes to this project will be documented in this file. - ## [0.2.0] - 2026-06-18 ### Added -- **VSCode 内置 Diff 编辑器**:不再使用自定义 Webview,直接调用 `vscode.diff` 展示差异 -- **EditRecord 粒度**:每次 Edit/Write 创建独立 EditRecord,支持逐编辑 Accept/Reject -- **QuickPick 文件列表**:Stop 后弹出原生 QuickPick 文件列表,点击即打开内置 Diff -- **导航快捷键**:`Alt+↑/↓` 在变更文件间跳转 -- **配置项**:`autoShowDiffPerEdit`(每次编辑自动弹 Diff)、`showAllDiffsOnStop`(Stop 后弹文件列表)、`floatingLabelMode`(浮动标签位置) -- **新命令**:`showCurrentFileDiff`、`acceptCurrentEdit`、`rejectCurrentEdit`、`nextDiff`、`prevDiff` -- **新快捷键**:`Alt+↑/↓`、`Ctrl+Shift+D`、`Ctrl+Shift+F` +- VSCode 内置 Diff 编辑器 (vscode.diff) — 零 Webview 依赖 +- EditRecord 独立追踪 — 每次 Edit/Write 独立记录,不合并 +- QuickPick 总览 — 文件列表 + Accept All / Reject All +- 智能 Auto-Advance — Accept/Reject 后自动推进,同文件多编辑智能停留 +- 状态栏常驻 — $(diff) AI Diff 始终可见 +- 零保存提示 — 临时文件方案自动清理 +- 新命令: showCurrentFileDiff, acceptCurrentEdit, rejectCurrentEdit, nextDiff, prevDiff +- 新快捷键: Alt+↑/↓, Ctrl+Shift+D, Ctrl+Shift+F +- 新配置: autoShowDiffPerEdit, showAllDiffsOnStop, floatingLabelMode ### Fixed -- **Bug Fix**: Accept 后同文件再编辑不再触发 Accept/Reject 选项,且 Diff 合并的问题 - - 根因:`recordEdit()` 对同文件多次编辑更新同一个 `FileChange` 对象 - - 修复:已 accepted/rejected 的 `FileChange` 后再次编辑时自动创建新 `FileChange`,清除 `originalContents` 缓存 +- Accept 后同文件再编辑不再触发选项 (FileChange pending-priority 匹配) +- Accept All / Reject All 写入与状态顺序修复 +- accept/reject 后自动推进到下一个待处理文件 ### Changed -- `ChangeSetManager` 重构为 `EditRecord[]` 独立记录模式 -- `FileChange` 变为聚合视图(持有 `edits: EditRecord[]`) -- `HookHandler` 支持 `autoShowDiffPerEdit` / `showAllDiffsOnStop` 配置分支 -- `ReviewPanel` Webview 降级为次要选项(保留兼容) -- 版本号升级至 `0.2.0` +- ChangeSetManager 重构为 EditRecord 独立记录 + FileChange 聚合模式 +- HookHandler 精简为纯处理逻辑 +- ReviewPanel 降级为备用, 主流程使用 QuickPick + 内置 Diff +- 版本号升级至 0.2.0 ## [0.1.0] - 2026-06-17 ### Added - 项目初始化 - 核心架构搭建 -- 快照管理器实现 -- Diff 引擎实现(基于 Myers 算法) +- 快照管理器 +- Diff 引擎 (Myers 算法) +- Webview Review 面板 - 内联装饰器渲染 - CodeLens 提供器 - Accept/Reject 事务处理 - Claude Hooks 集成 - 状态栏显示 -- 基础测试用例 - -### Technical -- TypeScript + VSCode Extension API -- diff 库集成 -- esbuild 构建流程 -- Vitest 测试框架 +- 基础测试用例 \ No newline at end of file diff --git a/README.md b/README.md index b62e8e0..6dbcee2 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,112 @@ # AI Code Diff Preview -类 Cursor 的 AI 代码差异预览 VSCode 插件,使用 **VSCode 内置 Diff 编辑器** + **浮动 Accept/Reject 标签**。 +> 类 Cursor 的 AI 代码差异预览 VSCode 插件 -## 功能特性 +[![Version](https://img.shields.io/badge/version-0.2.0-blue)](https://github.com/YonHaoGuo/ai-diff-preview/blob/master/CHANGELOG.md) +[![VSCode](https://img.shields.io/badge/vscode-%5E1.85.0-brightgreen)](https://code.visualstudio.com/) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) -- ✅ **VSCode 原生 Diff 编辑器** — 使用内置 `vscode.diff`,不自定义 Webview -- ✅ **EditRecord 独立追踪** — 每次 Edit/Write 独立记录,不合并 -- ✅ **逐次编辑 Accept/Reject** — 细粒度控制每次 AI 修改 -- ✅ **QuickPick 文件列表** — 对话结束后弹出原生文件选择器 -- ✅ **快捷键导航** — `Alt+↑/↓` 跳转变更文件 -- ✅ **Bug 修复** — Accept 后再编辑不再丢失追踪 -- ✅ **冲突检测** — 自动检测用户手动修改导致的冲突 +AI Code Diff Preview 为 VSCode 注入 Cursor 风格的 Accept/Reject 差异审查体验。AI 对话中的每一次代码编辑自动收集,对话结束后弹出完整的变更文件列表。点击任意文件即可在 **VSCode 内置 Diff 编辑器**中逐文件审查,状态栏实时显示 Accept/Reject 按钮,操作完成后自动推进到下一个待处理文件。 -## 工作流程 +## 特性 -``` -Claude Edit/Write → 收集到 ChangeSet (EditRecord 粒度) - ├── autoShowDiffPerEdit=true → 立即弹出 VSCode 内置 Diff - └── autoShowDiffPerEdit=false → 静默收集,更新状态栏 - -Claude Stop → 变更集就绪 - ├── showAllDiffsOnStop=true → QuickPick 文件列表 → 点击打开内置 Diff - └── showAllDiffsOnStop=false → 仅通知,用户手动 Ctrl+Shift+D 查看 -``` +- **VSCode 原生 Diff 编辑器** — 使用 `vscode.diff` 命令,100% 原生交互体验,无自定义 Webview +- **EditRecord 独立追踪** — 每次 `Edit`/`Write` 操作作为独立编辑记录,支持逐次编辑 Accept/Reject +- **QuickPick 总览** — 对话结束后弹出原生文件列表,含 Accept All / Reject All 一键操作 +- **智能 Auto-Advance** — Accept/Reject 后自动推进到下一个待处理文件,同文件多编辑自动停留 +- **状态栏常驻** — 插件激活后状态栏始终可见 `$(diff) AI Diff`,有变更时黄底高亮 +- **零保存提示** — Diff 编辑器使用临时文件,关闭时自动删除,不会弹出"是否保存"对话框 +- **多轮对话追踪** — 支持同一轮对话中对同文件的多次编辑合并、跨轮对话的独立追踪 ## 安装 ```bash -git clone https://github.com/your-username/ai-diff-preview.git +# 克隆仓库 +git clone https://github.com/YonHaoGuo/ai-diff-preview.git cd ai-diff-preview + +# 安装依赖 npm install + +# 编译 npm run build + +# 打包 vsix +npm run package ``` +或通过 VSCode 扩展市场安装(即将发布)。 + ## 使用方法 +### 工作流程 + +``` +┌─────────────────────────────────────────────────────────┐ +│ AI 对话中(Claude Code / Cursor / Copilot) │ +│ │ +│ Edit/Write 工具调用 → .claude/hooks/pending.json │ +│ → 插件自动收集变更 (静默) │ +│ │ +│ Stop/对话结束 → QuickPick 总览弹窗 │ +│ ┌──────────────────────────────────────┐ │ +│ │ AI Diff 总览 — 3 文件 5 编辑 │ │ +│ │ │ │ +│ │ 📄 src/utils.ts +3 -2 · 2次编辑 │ │ +│ │ 📄 src/index.ts +9 -1 · 2次编辑 │ │ +│ │ 📄 src/types.ts +5 -0 · 1次编辑 │ │ +│ │ ──────────────────────────────── │ │ +│ │ ✅ Accept All ❌ Reject All │ │ +│ └──────────────────────────────────────┘ │ +│ ↓ 点击文件 │ +│ ┌──────────────────────────────────────┐ │ +│ │ VSCode 内置 Diff 编辑器 │ │ +│ │ ┌──── 原始代码 ──┬── 变更后 ────┐ │ │ +│ │ │ ... │ ... │ │ │ +│ │ └───────────────┴──────────────┘ │ │ +│ │ │ │ +│ │ 状态栏: [✓ Accept] [✗ Reject] │ │ +│ │ [✓✓ 接受此文件全部] │ │ +│ └──────────────────────────────────────┘ │ +│ ↓ Accept/Reject │ +│ → 自动推进到下一个文件/同文件剩余编辑 │ +│ → 全部完成: 🎉 通知 │ +└─────────────────────────────────────────────────────────┘ +``` + ### 快捷键 -| 快捷键 | 条件 | 功能 | -|--------|------|------| -| `Alt+↓` | `aiDiffPreview.isActive` | 下一个变更文件 | -| `Alt+↑` | `aiDiffPreview.isActive` | 上一个变更文件 | -| `Tab` | `aiDiffPreview.isActive` | 接受当前编辑 | -| `Esc` | `aiDiffPreview.isActive` | 拒绝所有变更 | -| `Ctrl+Shift+A` | `aiDiffPreview.isActive` | Accept All | -| `Ctrl+Shift+R` | `aiDiffPreview.isActive` | Reject All | -| `Ctrl+Shift+D` | `aiDiffPreview.isActive` | 显示文件列表 | -| `Ctrl+Shift+F` | `aiDiffPreview.isActive` | 查看当前文件 Diff | +| 快捷键 | 条件 | 功能 | +| ---------------- | -------------------------- | ----------------------- | +| `Alt+↓` | `aiDiffPreview.isActive` | 下一个变更文件 | +| `Alt+↑` | `aiDiffPreview.isActive` | 上一个变更文件 | +| `Ctrl+Shift+D` | `aiDiffPreview.isActive` | 显示 QuickPick 文件列表 | +| `Ctrl+Shift+F` | `aiDiffPreview.isActive` | 查看当前文件 Diff | +| `Ctrl+Shift+A` | `aiDiffPreview.isActive` | Accept All | +| `Ctrl+Shift+R` | `aiDiffPreview.isActive` | Reject All | + +### 命令 + +| 命令 ID | 标题 | +| ------------------------------------- | ----------------------------- | +| `aiDiffPreview.showDiffPanel` | AI Diff: 显示所有变更文件列表 | +| `aiDiffPreview.showCurrentFileDiff` | AI Diff: 查看当前文件 Diff | +| `aiDiffPreview.acceptCurrentEdit` | AI Diff: 接受当前编辑 | +| `aiDiffPreview.rejectCurrentEdit` | AI Diff: 拒绝当前编辑 | +| `aiDiffPreview.acceptAll` | AI Diff: 接受所有变更 | +| `aiDiffPreview.rejectAll` | AI Diff: 拒绝所有变更 | +| `aiDiffPreview.nextDiff` | AI Diff: 下一个变更 | +| `aiDiffPreview.prevDiff` | AI Diff: 上一个变更 | ### 配置项 -| 配置项 | 类型 | 默认值 | 说明 | -|--------|------|--------|------| -| `aiDiffPreview.enableAutoTrigger` | boolean | true | 启用自动触发 | -| `aiDiffPreview.autoShowDiffPerEdit` | boolean | false | 每次编辑自动弹 Diff | -| `aiDiffPreview.showAllDiffsOnStop` | boolean | true | Stop 后弹文件列表 | -| `aiDiffPreview.floatingLabelMode` | string | "statusBar" | 浮动标签位置 | -| `aiDiffPreview.maxFileSize` | number | 100000 | 最大文件大小 | - -## 开发 - -```bash -npm run watch # 监听模式 -npm run test # 运行测试 (13 tests) -npm run lint # 代码检查 -npm run package # 打包 vsix -``` +| 配置项 | 类型 | 默认值 | 说明 | +| ------------------------------------- | ------- | --------------- | ------------------------------------------------ | +| `aiDiffPreview.enableAutoTrigger` | boolean | `true` | 启用自动触发(监听 pending.json) | +| `aiDiffPreview.autoShowDiffPerEdit` | boolean | `false` | 每次 Edit/Write 后自动弹出 Diff | +| `aiDiffPreview.showAllDiffsOnStop` | boolean | `true` | Stop 后自动弹出 QuickPick 总览 | +| `aiDiffPreview.floatingLabelMode` | string | `"statusBar"` | 浮动标签位置(statusBar / inline / both / none) | +| `aiDiffPreview.maxFileSize` | number | `100000` | 最大处理文件大小(字符数) | ## 项目结构 @@ -73,25 +114,50 @@ npm run package # 打包 vsix src/ ├── extension.ts # 插件入口 ├── trigger/ -│ └── hookHandler.ts # Hook 处理器 + QuickPick 文件列表 +│ └── hookHandler.ts # Hook 处理器 + QuickPick 总览 ├── snapshot/ │ └── snapshotManager.ts # 变更集管理器 (ChangeSetManager) ├── diff/ │ └── diffEngine.ts # Diff 计算引擎 ├── render/ -│ ├── diffViewer.ts # ★ VSCode 内置 Diff 查看器 -│ ├── reviewPanel.ts # Webview 面板 (次要) -│ ├── inlineDecorator.ts # 内联装饰器 -│ ├── codeLensProvider.ts # CodeLens 提供器 -│ └── statusBar.ts # 状态栏 +│ ├── diffViewer.ts # VSCode 内置 Diff 查看器 + 自动推进 +│ ├── reviewPanel.ts # Webview 面板 (实验性/备用) +│ ├── inlineDecorator.ts # 内联装饰器 (备用) +│ ├── codeLensProvider.ts # CodeLens 提供器 (备用) +│ └── statusBar.ts # 状态栏管理器 (常驻 + Diff 模式) ├── transaction/ -│ ├── acceptHandler.ts # Accept 处理器 -│ └── rejectHandler.ts # Reject 处理器 -└── models/ - ├── types.ts # 类型定义 - └── constants.ts # 常量定义 +│ ├── acceptHandler.ts # Accept 处理器 (旧版) +│ └── rejectHandler.ts # Reject 处理器 (旧版) +├── models/ +│ ├── types.ts # 类型定义 +│ └── constants.ts # 常量定义 +└── __tests__/ + ├── diffEngine.test.ts # Diff 引擎测试 + └── changeSetManager.test.ts # 变更集管理器回归测试 ``` -## License +## 开发 -MIT +```bash +npm run watch # esbuild 监听模式 + sourcemap +npm run build # 生产构建 (minify) +npm run test # 运行测试 (13 tests) +npm run test:watch # 测试监听模式 +npm run lint # ESLint +npm run package # vsce package → .vsix +``` + +## 技术栈 + +| 领域 | 选型 | +| --------- | -------------------- | +| 框架 | VSCode Extension API | +| 语言 | TypeScript (ES2022) | +| Diff 算法 | `diff` (Myers) | +| 绑定 | esbuild | +| 测试 | Vitest | +| 规范 | ESLint + Prettier | + +## 许可证 + +MIT © 2026 [Gmaker689](https://github.com/Gmaker689) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..2809499 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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{"同文件已有
pending FileChange?"} + I -->|"YES"| J["★ 合并: 追加 EditRecord
更新聚合 Diff"] + I -->|"NO"| K["新建 FileChange
捕获 originalContent"] + J --> L{"autoShowDiffPerEdit
配置?"} + 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 面板
reviewPanel.show()"] + + S --> T["总览面板: 每个文件一张卡片"] + T --> U["卡片内含:
— 文件信息 + 增减统计
— 双栏 Diff (原始 vs 最新)
— ✔ 接受 / ✘ 拒绝 按钮"] + T --> V["顶部: Accept All / Reject All"] + + U --> W["用户点击 ✔ 接受"] + U --> X["用户点击 ✘ 拒绝"] + V --> Y["用户点击 Accept All"] + V --> Z["用户点击 Reject All"] + + W --> AA["acceptFile(fileId)
写入 latestContent 到磁盘"] + X --> AB["rejectFile(fileId)
恢复 originalContent"] + Y --> AC["acceptAll()
遍历所有 pending 文件"] + Z --> AD["rejectAll()
遍历所有 pending 文件"] + + AA --> AE["刷新总览面板 + 状态栏"] + AB --> AE + AC --> AE + AD --> AE + + T --> AF["用户点击文件卡片"] + AF --> AG["打开 VSCode 内置 Diff 编辑器
diffViewer.showFileDiff()"] + AG --> AH["Diff 界面: 状态栏切换为
$(check) Accept | $(close) Reject | $(check-all) 接受全部"] + AH --> AI["用户操作 Accept/Reject"] + AI --> AJ["★ 自动推进到下一个 pending 文件
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
入口"] + hook["hookHandler.ts
Hook 处理"] + manager["snapshotManager.ts
ChangeSetManager
★ 核心数据层"] + review["reviewPanel.ts
总览 Webview"] + viewer["diffViewer.ts
内置 Diff + 自动推进"] + bar["statusBar.ts
状态栏"] + types["models/types.ts
类型"] + + 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 | diff --git a/package.json b/package.json index 1a06792..1a987bc 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,11 @@ "displayName": "AI Code Diff Preview", "description": "类 Cursor 的 AI 代码差异预览插件,使用 VSCode 内置 Diff 编辑器 + 浮动 Accept/Reject 标签", "version": "0.2.0", - "publisher": "your-publisher-name", + "publisher": "Gmaker689", + "repository": { + "type": "git", + "url": "https://github.com/Gmaker689/ai-diff-preview" + }, "engines": { "vscode": "^1.85.0" }, diff --git a/src/extension.ts b/src/extension.ts index 7d6fa13..3127d4b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,17 +1,10 @@ /** * AI Code Diff Preview - 插件入口 * - * 工作流程: - * 1. PostToolUse (Edit/Write) → 收集变更到 ChangeSet (EditRecord 粒度) - * 2. Stop (对话结束) → 根据配置弹出 QuickPick 文件列表或通知 - * 3. 用户选择文件 → 打开 VSCode 内置 Diff 编辑器 - * 4. 用户 Accept/Reject → 应用或丢弃变更 - * - * 快捷键: - * - Alt+↑/↓: 上一个/下一个变更 - * - Tab: 接受当前编辑 - * - Esc: 拒绝当前编辑 - * - Ctrl+Shift+D: 显示文件列表 + * UI 说明: + * 1. Stop 后弹出 QuickPick 文件列表 (含 Accept All / Reject All) + * 2. 点击文件 → VSCode 内置 Diff 编辑器 + 状态栏 Accept/Reject 按钮 + * 3. Accept/Reject 后自动推进到下一个 pending 文件 */ import * as vscode from 'vscode'; @@ -20,227 +13,163 @@ import * as fs from 'fs'; import { ChangeSetManager } from './snapshot/snapshotManager'; import { HookHandler } from './trigger/hookHandler'; import { DiffViewer } from './render/diffViewer'; -import { ReviewPanel } from './render/reviewPanel'; +import { StatusBarManager } from './render/statusBar'; import { COMMANDS } from './models/constants'; import { ClaudeHookEvent } from './models/types'; let changeSetManager: ChangeSetManager; let hookHandler: HookHandler; let diffViewer: DiffViewer; -let reviewPanel: ReviewPanel; +let statusBar: StatusBarManager; export function activate(context: vscode.ExtensionContext) { console.log('[AI Diff] 插件已激活'); - // 初始化核心模块 changeSetManager = new ChangeSetManager(); - hookHandler = new HookHandler(changeSetManager); - diffViewer = hookHandler.getDiffViewer(); - reviewPanel = new ReviewPanel(changeSetManager); + diffViewer = new DiffViewer(changeSetManager); + hookHandler = new HookHandler(changeSetManager, diffViewer); + statusBar = new StatusBarManager(changeSetManager, diffViewer); + + // DiffViewer 操作后刷新状态栏 + diffViewer.onDiffAction = () => refreshUI(); - // 注册命令 registerCommands(context); - // 注册 Hook 触发文件监听 - registerHookFileWatcher(context); + // 编辑器切换 → 刷新状态栏 + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(() => refreshUI()) + ); + context.subscriptions.push( + vscode.window.onDidChangeVisibleTextEditors(() => refreshUI()) + ); - // 检查激活时是否已有 pending 触发文件 + registerHookFileWatcher(context); checkPendingHookFile(); + refreshUI(); } +// ---- 命令 ---- + function registerCommands(context: vscode.ExtensionContext) { - // === Diff 查看 === - - // 显示文件列表 (QuickPick) + // QuickPick 文件列表 (总览) context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => { - hookHandler.showFileListPicker(); + vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => hookHandler.showFileListPicker()) + ); + // 当前文件 Diff + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.SHOW_CURRENT_FILE_DIFF, () => diffViewer.showCurrentFileDiff()) + ); + // 接受当前编辑 + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.ACCEPT_CURRENT_EDIT, async () => { + await diffViewer.acceptCurrentDiff(); }) ); - - // 显示当前活动文件的 Diff - context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.SHOW_CURRENT_FILE_DIFF, () => { - diffViewer.showCurrentFileDiff(); - }) - ); - - // === Accept / Reject === - - // 接受当前编辑(光标所在位置的编辑) - context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.ACCEPT_CURRENT_EDIT, () => { - const result = findCurrentEditAtCursor(); - if (!result) { - vscode.window.showInformationMessage('AI Diff: 光标不在变更区域内'); - return; - } - changeSetManager.acceptEditRecord(result.fileChange.id, result.edit.id); - vscode.window.showInformationMessage(`AI Diff: 已接受编辑 #${result.edit.id.slice(0, 6)}`); - }) - ); - // 拒绝当前编辑 context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.REJECT_CURRENT_EDIT, () => { - const result = findCurrentEditAtCursor(); - if (!result) { - vscode.window.showInformationMessage('AI Diff: 光标不在变更区域内'); - return; - } - changeSetManager.rejectEditRecord(result.fileChange.id, result.edit.id); - vscode.window.showInformationMessage(`AI Diff: 已拒绝编辑 #${result.edit.id.slice(0, 6)}`); + vscode.commands.registerCommand(COMMANDS.REJECT_CURRENT_EDIT, async () => { + await diffViewer.rejectCurrentDiff(); }) ); - - // 接受全部 + // Accept All context.subscriptions.push( vscode.commands.registerCommand(COMMANDS.ACCEPT_ALL, () => { - const count = changeSetManager.acceptAll(); - vscode.window.showInformationMessage(`AI Diff: 已接受 ${count} 个文件的变更`); + const n = changeSetManager.acceptAll(); + vscode.window.showInformationMessage(`AI Diff: 已接受 ${n} 个文件`); + refreshUI(); }) ); - - // 拒绝全部 + // Reject All context.subscriptions.push( vscode.commands.registerCommand(COMMANDS.REJECT_ALL, () => { - const count = changeSetManager.rejectAll(); - vscode.window.showInformationMessage(`AI Diff: 已拒绝 ${count} 个文件的变更`); + const n = changeSetManager.rejectAll(); + vscode.window.showInformationMessage(`AI Diff: 已拒绝 ${n} 个文件`); + refreshUI(); }) ); - - // === 导航 === - - // 下一个变更 + // 导航 context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.NEXT_DIFF, () => { - navigateDiff(1); - }) + vscode.commands.registerCommand(COMMANDS.NEXT_DIFF, () => navigateDiff(1)) + ); + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.PREV_DIFF, () => navigateDiff(-1)) ); - // 上一个变更 + // Diff 界面状态栏按钮 context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.PREV_DIFF, () => { - navigateDiff(-1); - }) - ); - - // 旧版兼容命令 - context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.ACCEPT_CHUNK, () => { - vscode.commands.executeCommand(COMMANDS.ACCEPT_CURRENT_EDIT); + vscode.commands.registerCommand('aiDiffPreview.acceptCurrentDiff', async () => { + await diffViewer.acceptCurrentDiff(); }) ); context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.REJECT_CHUNK, () => { - vscode.commands.executeCommand(COMMANDS.REJECT_CURRENT_EDIT); + vscode.commands.registerCommand('aiDiffPreview.rejectCurrentDiff', async () => { + await diffViewer.rejectCurrentDiff(); }) ); + context.subscriptions.push( + vscode.commands.registerCommand('aiDiffPreview.acceptAllFromDiff', async () => { + await diffViewer.acceptAllFromDiff(); + }) + ); + + // 旧版兼容 + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.ACCEPT_CHUNK, async () => { await diffViewer.acceptCurrentDiff(); }) + ); + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.REJECT_CHUNK, async () => { await diffViewer.rejectCurrentDiff(); }) + ); } -/** - * 查找光标所在位置的编辑记录 - */ -function findCurrentEditAtCursor(): { fileChange: any; edit: any } | null { - const editor = vscode.window.activeTextEditor; - if (!editor) return null; +// ---- 导航 ---- - const set = changeSetManager.getCurrentSet(); - if (!set) return null; - - const normalizedPath = path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/'); - const fileChange = set.changes.find( - c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath && c.status === 'pending' - ); - if (!fileChange) return null; - - const cursorLine = editor.selection.active.line; - - // 在文件聚合 diff 中查找光标所在行对应的 edit - for (const edit of fileChange.edits) { - if (edit.status !== 'pending') continue; - // 找到该 edit 中匹配的行范围 - for (const d of edit.diffLines) { - if (d.type === 'context' || d.type === 'add') { - if (d.newLineNum - 1 === cursorLine) { - return { fileChange, edit }; - } - } - } - } - - // 回退:返回第一个 pending edit - const firstPending = fileChange.edits.find(e => e.status === 'pending'); - if (firstPending) { - return { fileChange, edit: firstPending }; - } - - return null; -} - -/** - * 在变更文件之间导航 - */ function navigateDiff(direction: 1 | -1): void { const set = changeSetManager.getCurrentSet(); if (!set) return; - const pending = set.changes.filter(c => c.status === 'pending'); if (pending.length === 0) { vscode.window.showInformationMessage('AI Diff: 无待处理变更'); return; } - const editor = vscode.window.activeTextEditor; const currentPath = editor ? path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/') : null; - - // 找到当前文件在 pending 列表中的位置 let idx = currentPath ? pending.findIndex(c => path.normalize(c.filePath).replace(/\\/g, '/') === currentPath) : -1; - idx = idx === -1 ? 0 : (idx + direction + pending.length) % pending.length; diffViewer.showFileDiff(pending[idx]); } -/** - * 注册 Hook 触发文件监听器 - * - * 监听 .claude/hooks/pending.json: - * - toolName: Edit/Write → 收集变更 - * - toolName: Stop → 弹出 Review 通知/文件列表 - */ +// ---- 刷新 ---- + +function refreshUI(): void { + statusBar.refresh(); + const hasPending = changeSetManager.hasPendingChanges(); + vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', hasPending); +} + +// ---- Hook 监听 ---- + function registerHookFileWatcher(context: vscode.ExtensionContext) { const hookDir = vscode.workspace.workspaceFolders?.[0] ? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks') : null; - - if (!hookDir) { - console.log('[AI Diff] 无工作区目录,Hook 监听未启动'); - return; - } - - if (!fs.existsSync(hookDir)) { - fs.mkdirSync(hookDir, { recursive: true }); - } + if (!hookDir) { console.log('[AI Diff] 无工作区目录'); return; } + if (!fs.existsSync(hookDir)) fs.mkdirSync(hookDir, { recursive: true }); const pendingFile = path.join(hookDir, 'pending.json'); - const watcher = vscode.workspace.createFileSystemWatcher( new vscode.RelativePattern(hookDir, 'pending.json') ); - const handleTrigger = () => { + const handle = () => { try { if (!fs.existsSync(pendingFile)) return; const content = fs.readFileSync(pendingFile, 'utf-8'); fs.unlinkSync(pendingFile); - const event: ClaudeHookEvent = JSON.parse(content); - - // 只处理最近 10 秒内的事件 if (Date.now() - event.timestamp > 10000) return; if (event.toolName === 'Stop') { @@ -248,48 +177,36 @@ function registerHookFileWatcher(context: vscode.ExtensionContext) { } else if (event.toolName === 'Edit' || event.toolName === 'Write') { hookHandler.handleEdit(event); } - } catch (e) { - console.error('[AI Diff] 处理触发文件失败:', e); - } + refreshUI(); + } catch (e) { console.error('[AI Diff] 处理触发文件失败:', e); } }; - watcher.onDidChange(handleTrigger); - watcher.onDidCreate(handleTrigger); + watcher.onDidChange(handle); + watcher.onDidCreate(handle); context.subscriptions.push(watcher); - - console.log(`[AI Diff] Hook 文件监听已启动: ${hookDir}`); + console.log(`[AI Diff] Hook 监听已启动: ${hookDir}`); } -/** - * 检查激活时是否已有 pending 触发文件 - */ function checkPendingHookFile() { const hookDir = vscode.workspace.workspaceFolders?.[0] ? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks') : null; if (!hookDir) return; - const pendingFile = path.join(hookDir, 'pending.json'); if (!fs.existsSync(pendingFile)) return; - try { const content = fs.readFileSync(pendingFile, 'utf-8'); fs.unlinkSync(pendingFile); const event: ClaudeHookEvent = JSON.parse(content); - if (Date.now() - event.timestamp < 15000) { - if (event.toolName === 'Stop') { - hookHandler.handleStop(); - } else if (event.toolName === 'Edit' || event.toolName === 'Write') { - hookHandler.handleEdit(event); - } + if (event.toolName === 'Stop') hookHandler.handleStop(); + else if (event.toolName === 'Edit' || event.toolName === 'Write') hookHandler.handleEdit(event); + refreshUI(); } - } catch (e) { - console.error('[AI Diff] 处理 pending hook 失败:', e); - } + } catch (e) { console.error('[AI Diff] 处理 pending hook 失败:', e); } } export function deactivate() { console.log('[AI Diff] 插件已停用'); - reviewPanel?.dispose(); -} + diffViewer?.cleanupTmpFiles(); +} \ No newline at end of file diff --git a/src/render/codeLensProvider.ts b/src/render/codeLensProvider.ts index b61202b..9da2139 100644 --- a/src/render/codeLensProvider.ts +++ b/src/render/codeLensProvider.ts @@ -1,96 +1,124 @@ /** - * CodeLens 提供器 - 提供 Accept/Reject 按钮 + * CodeLens 提供器 - 在编辑器行上方显示 Accept/Reject 按钮 + * + * 基于 ChangeSetManager 和 EditRecord,为每次编辑提供可点击的操作按钮。 + * 按钮显示在对应编辑的变更区域上方。 */ import * as vscode from 'vscode'; -import { SnapshotManager } from '../snapshot/snapshotManager'; +import * as path from 'path'; +import { ChangeSetManager } from '../snapshot/snapshotManager'; import { COMMANDS } from '../models/constants'; +import { FileChange, EditRecord } from '../models/types'; -export class CodeLensProvider implements vscode.CodeLensProvider { +export class AIDiffCodeLensProvider implements vscode.CodeLensProvider { private _onDidChangeCodeLenses = new vscode.EventEmitter(); readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; - constructor(private snapshotManager: SnapshotManager) {} + constructor(private changeSetManager: ChangeSetManager) {} - /** - * 刷新 CodeLens - */ refresh(): void { this._onDidChangeCodeLenses.fire(); } - /** - * 提供 CodeLens - */ provideCodeLenses( document: vscode.TextDocument, - token: vscode.CancellationToken + _token: vscode.CancellationToken ): vscode.CodeLens[] { - const snapshot = this.snapshotManager.getSnapshotByFilePath(document.uri.fsPath); - if (!snapshot || snapshot.status !== 'active') { - return []; - } + const set = this.changeSetManager.getCurrentSet(); + if (!set || set.status !== 'ready') return []; - const codeLenses: vscode.CodeLens[] = []; + const normalizedPath = path.normalize(document.uri.fsPath).replace(/\\/g, '/'); + const fileChange = set.changes.find( + c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath && c.status === 'pending' + ); + if (!fileChange) return []; - // 为每个待处理的块添加 CodeLens - snapshot.chunks.forEach(chunk => { - if (chunk.status !== 'pending') return; + const lenses: vscode.CodeLens[] = []; - const range = new vscode.Range( - new vscode.Position(chunk.startLine, 0), - new vscode.Position(chunk.startLine, 0) + // 文件标题行 (第 0 行): 全部接受/拒绝 + lenses.push( + new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), { + title: `$(diff) AI Diff · ${fileChange.edits.length} 次编辑`, + command: COMMANDS.SHOW_CURRENT_FILE_DIFF, + tooltip: '在 VSCode 内置 Diff 中查看变更', + }), + new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), { + title: '$(check-all) 接受全部', + command: COMMANDS.ACCEPT_ALL, + tooltip: '接受此文件所有变更', + }), + new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), { + title: '$(trash) 拒绝全部', + command: COMMANDS.REJECT_ALL, + tooltip: '拒绝此文件所有变更', + }) + ); + + // 为每个待处理的 EditRecord 添加按钮 + for (const edit of fileChange.edits) { + if (edit.status !== 'pending') continue; + + // 找到该编辑在 diffLines 中的第一个变更行 + const firstChangedLine = this.findFirstChangeLine(edit); + if (firstChangedLine < 0) continue; + + const pos = new vscode.Position(firstChangedLine, 0); + const range = new vscode.Range(pos, pos); + + const toolLabel = edit.toolName === 'Write' ? '写入' : '编辑'; + const summary = this.editSummary(edit); + + // 标题行 + lenses.push( + new vscode.CodeLens(range, { + title: `— AI ${toolLabel} #${edit.id.slice(0, 4)} · ${summary}`, + command: COMMANDS.SHOW_CURRENT_FILE_DIFF, + tooltip: '点击查看本次编辑的 Diff', + }) ); - // Accept 按钮 - codeLenses.push( + // Accept / Reject 按钮 + lenses.push( new vscode.CodeLens(range, { title: '$(check) Accept', - command: COMMANDS.ACCEPT_CHUNK, - arguments: [snapshot.id, chunk.id], - tooltip: '接受此变更块', - }) - ); - - // Reject 按钮 - codeLenses.push( + command: COMMANDS.ACCEPT_CURRENT_EDIT, + arguments: [fileChange.id, edit.id], + tooltip: `接受此${toolLabel}`, + }), new vscode.CodeLens(range, { title: '$(close) Reject', - command: COMMANDS.REJECT_CHUNK, - arguments: [snapshot.id, chunk.id], - tooltip: '拒绝此变更块', - }) - ); - }); - - // 如果有多个块,添加全局操作按钮 - const pendingChunks = snapshot.chunks.filter(c => c.status === 'pending'); - if (pendingChunks.length > 1) { - const firstChunk = pendingChunks[0]; - const range = new vscode.Range( - new vscode.Position(firstChunk.startLine, 0), - new vscode.Position(firstChunk.startLine, 0) - ); - - codeLenses.push( - new vscode.CodeLens(range, { - title: '$(check-all) Accept All', - command: COMMANDS.ACCEPT_ALL, - arguments: [snapshot.id], - tooltip: '接受所有变更', - }) - ); - - codeLenses.push( - new vscode.CodeLens(range, { - title: '$(trash) Reject All', - command: COMMANDS.REJECT_ALL, - arguments: [snapshot.id], - tooltip: '拒绝所有变更', + command: COMMANDS.REJECT_CURRENT_EDIT, + arguments: [fileChange.id, edit.id], + tooltip: `拒绝此${toolLabel}`, }) ); } - return codeLenses; + return lenses; + } + + /** + * 找到 EditRecord 在文件的 diffLines 中对应的第一个变更行号 + */ + private findFirstChangeLine(edit: EditRecord): number { + for (const d of edit.diffLines) { + if (d.type === 'add' || d.type === 'delete') { + return d.newLineNum > 0 ? d.newLineNum - 1 : d.oldLineNum - 1; + } + } + return -1; + } + + /** + * 编辑摘要: "+3 -2" + */ + private editSummary(edit: EditRecord): string { + const adds = edit.diffLines.filter(d => d.type === 'add').length; + const dels = edit.diffLines.filter(d => d.type === 'delete').length; + const parts: string[] = []; + if (adds > 0) parts.push(`+${adds}`); + if (dels > 0) parts.push(`-${dels}`); + return parts.join(' '); } } diff --git a/src/render/diffViewer.ts b/src/render/diffViewer.ts index 6000d13..8180769 100644 --- a/src/render/diffViewer.ts +++ b/src/render/diffViewer.ts @@ -1,122 +1,231 @@ /** - * Diff 查看器 - 调用 VSCode 内置 Diff 编辑器 + * Diff 查看器 - VSCode 内置 Diff 编辑器 + Accept/Reject 状态栏 * - * 使用 vscode.diff 命令展示原始内容 vs 变更后内容, - * 完全不使用自定义 Webview,保持 VSCode 原生交互体验。 + * 关键: + * 1. 临时文件(非 untitled)避免保存提示 + * 2. Accept/Reject 后自动推进到下一个 pending 文件 + * 3. 状态栏按钮在 Diff 模式下可见 */ import * as vscode from 'vscode'; import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { v4 as uuidv4 } from 'uuid'; import { EditRecord, FileChange } from '../models/types'; import { ChangeSetManager } from '../snapshot/snapshotManager'; +export interface DiffSession { + fileId: string; + editId?: string; + beforeTmpPath: string; + afterTmpPath: string; + beforeUri: vscode.Uri; + afterUri: vscode.Uri; +} + export class DiffViewer { + private activeSession: DiffSession | null = null; + private closeListener: vscode.Disposable | null = null; + private tmpFiles: string[] = []; + + /** 外部回调:Diff 操作完成后通知(用于刷新 UI) */ + onDiffAction?: () => void; + constructor(private changeSetManager: ChangeSetManager) {} - /** - * 打开单次编辑的 Diff(VSCode 内置 Diff 编辑器) - */ - async showEditDiff(editRecord: EditRecord): Promise { - const beforeDoc = await vscode.workspace.openTextDocument({ - content: editRecord.beforeContent, - language: this.getLanguageId(editRecord.filePath), - }); - const afterDoc = await vscode.workspace.openTextDocument({ - content: editRecord.afterContent, - language: this.getLanguageId(editRecord.filePath), - }); + getActiveSession(): DiffSession | null { + return this.activeSession; + } + // ---- 打开 Diff ---- + + async showEditDiff(editRecord: EditRecord, fileChangeId: string): Promise { + const ext = path.extname(editRecord.filePath); const fileName = path.basename(editRecord.filePath); const toolLabel = editRecord.toolName === 'Write' ? '写入' : '编辑'; + const beforePath = this.makeTmpFile(`ai-diff-before-${editRecord.id.slice(0, 8)}${ext}`, editRecord.beforeContent); + const afterPath = this.makeTmpFile(`ai-diff-after-${editRecord.id.slice(0, 8)}${ext}`, editRecord.afterContent); + + this.setSession({ + fileId: fileChangeId, + editId: editRecord.id, + beforeTmpPath: beforePath, + afterTmpPath: afterPath, + beforeUri: vscode.Uri.file(beforePath), + afterUri: vscode.Uri.file(afterPath), + }); + await vscode.commands.executeCommand( 'vscode.diff', - beforeDoc.uri, - afterDoc.uri, - `${fileName} — AI ${toolLabel} #${editRecord.id.slice(0, 6)} (原始 ↔ 变更后)` + this.activeSession!.beforeUri, + this.activeSession!.afterUri, + `${fileName} — AI ${toolLabel} #${editRecord.id.slice(0, 6)} · 状态栏操作` ); } - /** - * 打开整个文件的聚合 Diff - */ async showFileDiff(fileChange: FileChange): Promise { - const beforeDoc = await vscode.workspace.openTextDocument({ - content: fileChange.originalContent, - language: this.getLanguageId(fileChange.filePath), - }); - const afterDoc = await vscode.workspace.openTextDocument({ - content: fileChange.latestContent, - language: this.getLanguageId(fileChange.filePath), - }); - + const ext = path.extname(fileChange.filePath); const fileName = path.basename(fileChange.filePath); + const beforePath = this.makeTmpFile(`ai-diff-before-${fileChange.id.slice(0, 8)}${ext}`, fileChange.originalContent); + const afterPath = this.makeTmpFile(`ai-diff-after-${fileChange.id.slice(0, 8)}${ext}`, fileChange.latestContent); + + this.setSession({ + fileId: fileChange.id, + beforeTmpPath: beforePath, + afterTmpPath: afterPath, + beforeUri: vscode.Uri.file(beforePath), + afterUri: vscode.Uri.file(afterPath), + }); + await vscode.commands.executeCommand( 'vscode.diff', - beforeDoc.uri, - afterDoc.uri, - `${fileName} — 原始 ↔ 变更后 (${fileChange.edits.length} 次编辑)` + this.activeSession!.beforeUri, + this.activeSession!.afterUri, + `${fileName} — ${fileChange.edits.length} 次 AI 编辑 · 状态栏操作` ); } - /** - * 打开当前活动文件在编辑器中的 Diff - */ async showCurrentFileDiff(): Promise { - const changeSet = this.changeSetManager.getCurrentSet(); - if (!changeSet) return; - + const set = this.changeSetManager.getCurrentSet(); + if (!set) return; const editor = vscode.window.activeTextEditor; if (!editor) return; - const normalizedPath = path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/'); - const fileChange = changeSet.changes.find( + const fc = set.changes.find( c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath && c.status === 'pending' ); - if (!fileChange) { - vscode.window.showInformationMessage('AI Diff: 当前文件无待处理变更'); - return; - } - - await this.showFileDiff(fileChange); + if (fc) await this.showFileDiff(fc); + else vscode.window.showInformationMessage('AI Diff: 当前文件无待处理变更'); } - /** - * 聚焦文件到编辑器(跳转到该文件并定位到第一个变更行) - */ - async focusFileInEditor(fileChange: FileChange): Promise { - const uri = vscode.Uri.file(fileChange.filePath); - const doc = await vscode.workspace.openTextDocument(uri); - const editor = await vscode.window.showTextDocument(doc, { preview: false }); + // ---- Diff 界面操作 (Bug 1 修复: 自动推进) ---- - // 滚动到第一个变更行 - if (fileChange.diffLines.length > 0) { - const firstChange = fileChange.diffLines.find( - d => d.type === 'add' || d.type === 'delete' - ); - if (firstChange) { - const line = (firstChange.newLineNum || firstChange.oldLineNum) - 1; - const pos = new vscode.Position(Math.max(0, line), 0); - editor.selection = new vscode.Selection(pos, pos); - editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter); + async acceptCurrentDiff(): Promise { + if (!this.activeSession) return; + const { fileId, editId } = this.activeSession; + + if (editId) { + this.changeSetManager.acceptEditRecord(fileId, editId); + } else { + this.changeSetManager.acceptFile(fileId); + } + + vscode.window.showInformationMessage('AI Diff: 已接受 ✓'); + await this.closeDiff(); + this.onDiffAction?.(); + + // ★ Bug 1 修复: 自动推进到下一个待处理文件 + this.advanceToNext(fileId); + } + + async rejectCurrentDiff(): Promise { + 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 { + 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 = { - '.ts': 'typescript', '.tsx': 'typescriptreact', - '.js': 'javascript', '.jsx': 'javascriptreact', - '.json': 'json', '.md': 'markdown', - '.css': 'css', '.scss': 'scss', '.less': 'less', - '.html': 'html', '.htm': 'html', - '.py': 'python', '.rs': 'rust', '.go': 'go', - '.java': 'java', '.cpp': 'cpp', '.c': 'c', - '.yaml': 'yaml', '.yml': 'yaml', - '.xml': 'xml', '.sql': 'sql', '.sh': 'shell', - }; - return map[ext] || 'plaintext'; + // ---- 关闭 ---- + + async closeDiff(): Promise { + if (this.activeSession) { + try { await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); } catch { /* ok */ } + } + this.clearSession(); + } + + // ---- 内部 ---- + + private makeTmpFile(name: string, content: string): string { + const dir = path.join(os.tmpdir(), 'ai-diff-preview'); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${uuidv4().slice(0, 8)}-${name}`); + fs.writeFileSync(filePath, content, 'utf-8'); + this.tmpFiles.push(filePath); + return filePath; + } + + private setSession(session: DiffSession): void { + this.clearSession(); + this.activeSession = session; + this.closeListener = vscode.window.onDidChangeVisibleTextEditors(() => { + if (!this.activeSession) return; + const stillOpen = vscode.window.visibleTextEditors.some( + e => e.document.uri.toString() === this.activeSession!.beforeUri.toString() || + e.document.uri.toString() === this.activeSession!.afterUri.toString() + ); + if (!stillOpen) this.clearSession(); + }); + } + + private clearSession(): void { + if (this.activeSession) { + [this.activeSession.beforeTmpPath, this.activeSession.afterTmpPath].forEach(p => { + try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* ok */ } + }); + this.tmpFiles = this.tmpFiles.filter( + f => f !== this.activeSession!.beforeTmpPath && f !== this.activeSession!.afterTmpPath + ); + } + this.activeSession = null; + if (this.closeListener) { this.closeListener.dispose(); this.closeListener = null; } + } + + cleanupTmpFiles(): void { + for (const p of this.tmpFiles) { + try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* ok */ } + } + this.tmpFiles = []; } } diff --git a/src/render/inlineDecorator.ts b/src/render/inlineDecorator.ts index 0275df9..14c64f8 100644 --- a/src/render/inlineDecorator.ts +++ b/src/render/inlineDecorator.ts @@ -1,133 +1,128 @@ /** - * 内联装饰器 - 渲染 Diff 高亮和按钮 + * 内联装饰器 - 在编辑器中渲染 Diff 红绿高亮 + * + * 基于 FileChange.diffLines 在编辑器内显示: + * - 绿色背景 + gutter 圆点: 新增行 + * - 红色背景 + 删除线: 删除行 + * - 行末浮动标签: ✓ Accept 本次编辑 · ✗ Reject */ import * as vscode from 'vscode'; -import { DiffChunk, ChunkType } from '../models/types'; -import { COLORS, DECORATION_TYPES } from '../models/constants'; +import { FileChange, DiffLine } from '../models/types'; +import { COLORS } from '../models/constants'; export class InlineDecorator { private addDecorationType: vscode.TextEditorDecorationType; private deleteDecorationType: vscode.TextEditorDecorationType; private modifyDecorationType: vscode.TextEditorDecorationType; - private conflictDecorationType: vscode.TextEditorDecorationType; - private acceptButtonDecorationType: vscode.TextEditorDecorationType; + private acceptLabelType: vscode.TextEditorDecorationType; constructor() { + // 新增行: 绿色背景 this.addDecorationType = vscode.window.createTextEditorDecorationType({ backgroundColor: COLORS.ADD_BACKGROUND, isWholeLine: true, - gutterIconPath: this.createGutterIconPath(COLORS.ADD_GUTTER), + gutterIconPath: this.gutterIcon(COLORS.ADD_GUTTER), gutterIconSize: 'contain', }); + // 删除行: 红色背景 + 删除线 this.deleteDecorationType = vscode.window.createTextEditorDecorationType({ backgroundColor: COLORS.DELETE_BACKGROUND, isWholeLine: true, textDecoration: 'line-through', - gutterIconPath: this.createGutterIconPath(COLORS.DELETE_GUTTER), + gutterIconPath: this.gutterIcon(COLORS.DELETE_GUTTER), gutterIconSize: 'contain', }); + // 修改行: 黄色背景 this.modifyDecorationType = vscode.window.createTextEditorDecorationType({ backgroundColor: COLORS.MODIFY_BACKGROUND, isWholeLine: true, - gutterIconPath: this.createGutterIconPath(COLORS.MODIFY_GUTTER), - gutterIconSize: 'contain', }); - this.conflictDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: COLORS.CONFLICT_BACKGROUND, - isWholeLine: true, - borderColor: COLORS.MODIFY_GUTTER, - borderStyle: 'solid', - borderWidth: '1px', - }); - - this.acceptButtonDecorationType = vscode.window.createTextEditorDecorationType({ + // 浮动标签: 在新增行的末尾显示 ✓ Accept + this.acceptLabelType = vscode.window.createTextEditorDecorationType({ after: { - contentText: ' ✓ Accept', - color: '#ffffff', - backgroundColor: COLORS.ADD_GUTTER, - margin: '0 0 0 1em', - border: '1px solid rgba(255,255,255,0.2)', - cursor: 'pointer', + contentText: ' ← AI 变更', + color: '#888', + fontStyle: 'italic', + margin: '0 0 0 16px', }, + isWholeLine: true, }); } - private createGutterIconPath(color: string): vscode.Uri { - const svg = ` - - `; - return vscode.Uri.parse(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`); - } - /** - * 渲染变更块 + * 在编辑器渲染文件的所有待处理变更 */ - renderChunks(editor: vscode.TextEditor, chunks: DiffChunk[]): void { + renderFileChange(editor: vscode.TextEditor, fileChange: FileChange): void { + if (fileChange.status !== 'pending') { + this.clearEditor(editor); + return; + } + const addRanges: vscode.Range[] = []; const deleteRanges: vscode.Range[] = []; const modifyRanges: vscode.Range[] = []; - const conflictRanges: vscode.Range[] = []; - const buttonRanges: vscode.Range[] = []; + const labelRanges: vscode.Range[] = []; - chunks.forEach(chunk => { - if (chunk.status === 'accepted' || chunk.status === 'rejected') { - return; // 跳过已处理的块 - } + for (const d of fileChange.diffLines) { + if (d.type === 'context') continue; - const range = new vscode.Range( - new vscode.Position(chunk.startLine, 0), - new vscode.Position(chunk.endLine, 0) - ); + // 使用 newLineNum(编辑器当前行号) + const line = d.newLineNum > 0 ? d.newLineNum - 1 : d.oldLineNum - 1; + if (line < 0) continue; - switch (chunk.type) { + const range = new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER); + + switch (d.type) { case 'add': addRanges.push(range); - buttonRanges.push(range); + labelRanges.push(range); break; case 'delete': deleteRanges.push(range); break; - case 'modify': - modifyRanges.push(range); - buttonRanges.push(range); - break; } - - if (chunk.status === 'conflict') { - conflictRanges.push(range); - } - }); + } editor.setDecorations(this.addDecorationType, addRanges); editor.setDecorations(this.deleteDecorationType, deleteRanges); editor.setDecorations(this.modifyDecorationType, modifyRanges); - editor.setDecorations(this.conflictDecorationType, conflictRanges); - editor.setDecorations(this.acceptButtonDecorationType, buttonRanges); + editor.setDecorations(this.acceptLabelType, labelRanges); } /** - * 清除所有装饰器 + * 清除编辑器的所有装饰 */ - clearDecorations(editor: vscode.TextEditor): void { + clearEditor(editor: vscode.TextEditor): void { editor.setDecorations(this.addDecorationType, []); editor.setDecorations(this.deleteDecorationType, []); editor.setDecorations(this.modifyDecorationType, []); - editor.setDecorations(this.conflictDecorationType, []); - editor.setDecorations(this.acceptButtonDecorationType, []); + editor.setDecorations(this.acceptLabelType, []); } /** - * 释放资源 + * 清除所有活跃编辑器的装饰 */ + clearAll(): void { + for (const editor of vscode.window.visibleTextEditors) { + this.clearEditor(editor); + } + } + dispose(): void { this.addDecorationType.dispose(); this.deleteDecorationType.dispose(); this.modifyDecorationType.dispose(); - this.conflictDecorationType.dispose(); - this.acceptButtonDecorationType.dispose(); + this.acceptLabelType.dispose(); + } + + private gutterIcon(color: string): vscode.Uri { + const svg = ` + + `; + return vscode.Uri.parse(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`); } } diff --git a/src/render/reviewPanel.ts b/src/render/reviewPanel.ts index 50b2fa3..950329e 100644 --- a/src/render/reviewPanel.ts +++ b/src/render/reviewPanel.ts @@ -1,39 +1,47 @@ /** - * Review 面板 - 内嵌 Webview 悬浮窗 + * ★ 总览面板 — 展示所有变更文件的详细 Diff * - * ★ v0.2.0: 支持 per-edit 和 per-file 两级 Accept/Reject - * - 每个文件卡片展开后显示各次编辑记录 - * - 每次编辑有独立的 ✓/✗ 按钮 - * - 文件级 Accept/Reject 一键处理所有编辑 + * 功能: + * - 每个文件一个可折叠的卡片,默认展开 + * - 卡片内: 双栏 Diff(原始 ↔ 变更后) + * - 文件级 Accept/Reject 按钮 + * - 顶部全局 Accept All / Reject All + * - 全部处理后显示完成提示 */ import * as vscode from 'vscode'; import { ChangeSetManager } from '../snapshot/snapshotManager'; -import { ChangeSet, FileChange, EditRecord, ChangeStatus } from '../models/types'; +import { DiffViewer } from './diffViewer'; +import { ChangeSet } from '../models/types'; export class ReviewPanel { private panel: vscode.WebviewPanel | undefined; + private changeSet: ChangeSet | undefined; constructor( - private changeSetManager: ChangeSetManager + private changeSetManager: ChangeSetManager, + private diffViewer?: DiffViewer ) {} + /** 显示总览面板 */ show(): void { - const changeSet = this.changeSetManager.getCurrentSet(); - if (!changeSet || changeSet.changes.length === 0) { + const cs = this.changeSetManager.getCurrentSet(); + if (!cs || cs.changes.length === 0) { + vscode.window.showInformationMessage('AI Diff: 无变更'); return; } + this.changeSet = cs; if (this.panel) { - this.panel.reveal(vscode.ViewColumn.Active); - this.sendData(changeSet); + this.panel.reveal(vscode.ViewColumn.Beside); + this.sendData(cs); return; } this.panel = vscode.window.createWebviewPanel( - 'aiDiffReview', - 'AI Diff Review', - { viewColumn: vscode.ViewColumn.Active, preserveFocus: false }, + 'aiDiffOverview', + 'AI Diff 总览', + { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, retainContextWhenHidden: true } ); @@ -42,30 +50,23 @@ export class ReviewPanel { this.panel.webview.onDidReceiveMessage((msg: any) => { switch (msg.type) { case 'ready': - this.sendData(changeSet); + this.sendData(this.changeSet!); break; - - // 单次编辑 Accept/Reject - case 'acceptEdit': - this.changeSetManager.acceptEditRecord(msg.fileId, msg.editId); - this.refresh(); - break; - case 'rejectEdit': - this.changeSetManager.rejectEditRecord(msg.fileId, msg.editId); - this.refresh(); - break; - - // 整个文件 Accept/Reject case 'acceptFile': this.changeSetManager.acceptFile(msg.fileId); this.refresh(); + // 如果有 DiffViewer 且与此文件匹配,关闭它 + if (this.diffViewer?.getActiveSession()?.fileId === msg.fileId) { + this.diffViewer.closeDiff(); + } break; case 'rejectFile': this.changeSetManager.rejectFile(msg.fileId); this.refresh(); + if (this.diffViewer?.getActiveSession()?.fileId === msg.fileId) { + this.diffViewer.closeDiff(); + } break; - - // 全局操作 case 'acceptAll': this.changeSetManager.acceptAll(); this.refresh(); @@ -80,15 +81,16 @@ export class ReviewPanel { this.panel.onDidDispose(() => { this.panel = undefined; }); } - private refresh(): void { - const changeSet = this.changeSetManager.getCurrentSet(); - if (changeSet && this.panel) { - this.sendData(changeSet); + refresh(): void { + const cs = this.changeSetManager.getCurrentSet(); + if (cs && this.panel) { + this.changeSet = cs; + this.sendData(cs); } } - private sendData(changeSet: ChangeSet): void { - this.panel?.webview.postMessage({ type: 'init', changeSet }); + private sendData(cs: ChangeSet): void { + this.panel?.webview.postMessage({ type: 'init', changeSet: cs }); } dispose(): void { @@ -96,101 +98,90 @@ export class ReviewPanel { this.panel = undefined; } + // ---- HTML ---- + private getHtml(): string { return /*html*/` + -
-

🔍 AI Diff Review

-
- - + +
+

🔍 AI Diff 总览

+
+ +
@@ -198,7 +189,6 @@ export class ReviewPanel { - + `; } diff --git a/src/render/statusBar.ts b/src/render/statusBar.ts index c973111..4284084 100644 --- a/src/render/statusBar.ts +++ b/src/render/statusBar.ts @@ -1,70 +1,127 @@ /** - * 状态栏管理器 - 显示 Diff 状态信息 + * 状态栏管理器 — 常驻显示 + * + * 始终在左侧状态栏显示 AI Diff 状态: + * - 有 pending 变更: $(diff) AI Diff: N文件 M编辑 (黄色) — 点击打开文件列表 + * - Diff 活跃: 右侧额外显示 Accept/Reject 按钮 + * - 无变更: $(diff) AI Diff (暗色) — 点击打开文件列表 */ import * as vscode from 'vscode'; -import { Snapshot } from '../models/types'; -import { STATUS_BAR_PRIORITY } from '../models/constants'; +import { ChangeSetManager } from '../snapshot/snapshotManager'; +import { DiffViewer, DiffSession } from './diffViewer'; +import { COMMANDS } from '../models/constants'; export class StatusBarManager { - private statusBarItem: vscode.StatusBarItem; - private chunkCountItem: vscode.StatusBarItem; + /** ★ 常驻摘要按钮 */ + private summaryItem: vscode.StatusBarItem; + /** Diff 模式 Accept 按钮 */ + private acceptBtn: vscode.StatusBarItem; + /** Diff 模式 Reject 按钮 */ + private rejectBtn: vscode.StatusBarItem; + /** Diff 模式 Accept All 按钮 */ + private acceptAllBtn: vscode.StatusBarItem; - constructor() { - this.statusBarItem = vscode.window.createStatusBarItem( + constructor( + private changeSetManager: ChangeSetManager, + private diffViewer: DiffViewer + ) { + // ★ 常驻摘要 — 始终显示 + this.summaryItem = vscode.window.createStatusBarItem( vscode.StatusBarAlignment.Left, - STATUS_BAR_PRIORITY.DIFF_STATUS + 100 ); + this.summaryItem.command = COMMANDS.SHOW_DIFF_PANEL; + this.summaryItem.text = '$(diff) AI Diff'; + this.summaryItem.tooltip = 'AI Diff — 点击查看变更列表'; + this.summaryItem.show(); - this.chunkCountItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - STATUS_BAR_PRIORITY.CHUNK_COUNT + // Accept 按钮 + this.acceptBtn = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100 ); + this.acceptBtn.command = 'aiDiffPreview.acceptCurrentDiff'; + + // Reject 按钮 + this.rejectBtn = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 99 + ); + this.rejectBtn.command = 'aiDiffPreview.rejectCurrentDiff'; + + // Accept All 按钮 + this.acceptAllBtn = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 98 + ); + this.acceptAllBtn.command = 'aiDiffPreview.acceptAllFromDiff'; } - /** - * 更新状态栏 - */ - update(snapshot: Snapshot): void { - const pendingChunks = snapshot.chunks.filter(c => c.status === 'pending'); - const conflictChunks = snapshot.chunks.filter(c => c.status === 'conflict'); + enterDiffMode(session: DiffSession): void { + const label = session.editId ? '编辑' : '文件'; + this.acceptBtn.text = `$(check) Accept 本次${label}变更`; + this.acceptBtn.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + this.acceptBtn.tooltip = `接受此${label}的变更并跳转到下一个`; + this.acceptBtn.show(); - // 更新主状态 - this.statusBarItem.text = '$(diff) AI Diff'; - this.statusBarItem.tooltip = 'AI Diff Preview 活跃'; - this.statusBarItem.show(); + this.rejectBtn.text = `$(close) Reject 本次${label}变更`; + this.rejectBtn.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); + this.rejectBtn.tooltip = `拒绝此${label}的变更并跳转到下一个`; + this.rejectBtn.show(); - // 更新变更块数量 - if (pendingChunks.length > 0 || conflictChunks.length > 0) { - this.chunkCountItem.text = `$(edit) ${pendingChunks.length} 个变更`; - if (conflictChunks.length > 0) { - this.chunkCountItem.text += ` (${conflictChunks.length} 个冲突)`; - this.chunkCountItem.backgroundColor = new vscode.ThemeColor( - 'statusBarItem.warningBackground' - ); - } else { - this.chunkCountItem.backgroundColor = undefined; - } - this.chunkCountItem.tooltip = '点击查看变更详情'; - this.chunkCountItem.show(); + this.acceptAllBtn.text = '$(check-all) 接受此文件全部'; + this.acceptAllBtn.tooltip = '接受此文件的所有编辑'; + this.acceptAllBtn.show(); + } + + exitDiffMode(): void { + this.acceptBtn.hide(); + this.rejectBtn.hide(); + this.acceptAllBtn.hide(); + } + + /** ★ 常驻刷新 */ + refresh(): void { + const session = this.diffViewer.getActiveSession(); + if (session) { + this.enterDiffMode(session); } else { - this.chunkCountItem.hide(); + this.exitDiffMode(); } + + const set = this.changeSetManager.getCurrentSet(); + if (!set) { + // ★ 无变更集也常驻显示 + this.summaryItem.text = '$(diff) AI Diff'; + this.summaryItem.tooltip = 'AI Diff — 等待变更'; + this.summaryItem.backgroundColor = undefined; + this.summaryItem.show(); + return; + } + + const pendingFiles = set.changes.filter(c => c.status === 'pending'); + const totalEdits = pendingFiles.reduce( + (sum, c) => sum + c.edits.filter(e => e.status === 'pending').length, 0 + ); + + if (pendingFiles.length === 0 && set.changes.length > 0) { + this.summaryItem.text = '$(diff) AI Diff: 已完成'; + this.summaryItem.tooltip = '所有变更已处理'; + this.summaryItem.backgroundColor = undefined; + } else if (pendingFiles.length > 0) { + this.summaryItem.text = `$(diff) AI Diff: ${pendingFiles.length}文件 ${totalEdits}编辑`; + this.summaryItem.tooltip = `点击查看变更列表\n${pendingFiles.map(c => `• ${c.filePath} (${c.edits.length}次)`).join('\n')}`; + this.summaryItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + } + + this.summaryItem.show(); } - /** - * 清除状态栏 - */ - clear(): void { - this.statusBarItem.hide(); - this.chunkCountItem.hide(); - } - - /** - * 释放资源 - */ dispose(): void { - this.statusBarItem.dispose(); - this.chunkCountItem.dispose(); + this.summaryItem.dispose(); + this.acceptBtn.dispose(); + this.rejectBtn.dispose(); + this.acceptAllBtn.dispose(); } -} +} \ No newline at end of file diff --git a/src/snapshot/snapshotManager.ts b/src/snapshot/snapshotManager.ts index aa19fb2..52c535b 100644 --- a/src/snapshot/snapshotManager.ts +++ b/src/snapshot/snapshotManager.ts @@ -19,7 +19,6 @@ function norm(p: string): string { export class ChangeSetManager { private currentSet: ChangeSet | null = null; - /** 原始文件内容快照(每个文件第一次编辑前捕获) */ private originalContents: Map = new Map(); startNewSet(): string { @@ -49,59 +48,43 @@ export class ChangeSetManager { } /** - * ★ 记录一次文件编辑(重构后) + * ★ 记录一次文件编辑 * - * 核心逻辑: - * 1. 每次 Edit/Write 创建独立 EditRecord - * 2. 如果该文件已有 pending 的 FileChange → 追加 EditRecord - * 3. 如果该文件的 FileChange 已 accepted/rejected → 创建新 FileChange(修复 Bug) - * 4. 如果该文件没有 FileChange → 创建新 FileChange + * 合并规则: + * - 优先找同文件 pending 的 FileChange → 追加 EditRecord,更新聚合 diff + * - 没有 pending 的 → 创建新 FileChange(首次编辑 / 前轮已处理完) */ recordEdit(filePath: string, toolName: string, oldString: string, newString: string, _content: string): void { - if (!this.currentSet) { - this.startNewSet(); - } + if (!this.currentSet) this.startNewSet(); filePath = norm(filePath); const currentContent = this.readFile(filePath) || ''; - const now = Date.now(); - console.log(`[AI Diff] recordEdit: ${toolName} → ${filePath}`); + // ★ 优先匹配 pending 的 FileChange + const sameFileChanges = this.currentSet!.changes.filter(c => norm(c.filePath) === filePath); + const pendingChange = sameFileChanges.find(c => c.status === 'pending'); + const hasNonPending = sameFileChanges.some(c => c.status !== 'pending'); - // Step 1: 查找该文件是否已有变更记录 - const existingChange = this.currentSet!.changes.find(c => norm(c.filePath) === filePath); + console.log(`[AI Diff] recordEdit: ${toolName} → ${filePath} (pending=${!!pendingChange}, nonPending=${hasNonPending})`); - // ★ 如果已有变更记录且已 accepted/rejected,需要重置缓存以重新捕获 - if (existingChange && existingChange.status !== 'pending') { - this.originalContents.delete(filePath); - } + // 没有 pending → 清除原始缓存重新捕获 + if (!pendingChange) this.originalContents.delete(filePath); - // Step 2: 确保已捕获原始内容 const capturedOriginal = this.captureOriginalContent(filePath, toolName, oldString, newString, currentContent); - // Step 3: 确定本次编辑的 beforeContent + // 确定 beforeContent let beforeContent: string; - - if (existingChange) { - const lastEdit = existingChange.edits[existingChange.edits.length - 1]; - if (existingChange.status === 'pending') { - // 还在收集中,before 使用上一次编辑的 afterContent - beforeContent = lastEdit.afterContent; - } else { - // ★ Bug 修复:已 accepted/rejected,需要新的 beforeContent - // 重新捕获:当前文件内容 = 本次编辑后的结果 - beforeContent = this.reverseEdit(currentContent, oldString, newString); - } + if (pendingChange) { + // ★ 合并:before = 上一次编辑后的文件状态 + beforeContent = pendingChange.latestContent; } else { - // 首次编辑该文件 + // 新追踪:before = 从当前文件反推的原始内容 beforeContent = capturedOriginal; } - // Step 3: 计算本次编辑的独立 Diff const afterContent = currentContent; const editDiffLines = this.computeDiffLines(beforeContent, afterContent); - // Step 4: 创建 EditRecord const editRecord: EditRecord = { id: uuidv4(), filePath, @@ -112,23 +95,18 @@ export class ChangeSetManager { newString: newString || '', diffLines: editDiffLines, status: 'pending', - timestamp: now, + timestamp: Date.now(), }; - // Step 5: 决定 FileChange 策略 - if (existingChange && existingChange.status === 'pending') { - // 追加到已有 FileChange - existingChange.edits.push(editRecord); - existingChange.latestContent = afterContent; - existingChange.diffLines = this.computeDiffLines(existingChange.originalContent, afterContent); - console.log(`[AI Diff] 追加 EditRecord 到已有 FileChange: ${filePath}, edits=${existingChange.edits.length}`); + if (pendingChange) { + // ★ 追加到已有 pending FileChange,更新聚合 diff + pendingChange.edits.push(editRecord); + pendingChange.latestContent = afterContent; + pendingChange.diffLines = this.computeDiffLines(pendingChange.originalContent, afterContent); } else { - // 创建新 FileChange(首次编辑 或 之前的已 accepted/rejected) - const changeType: FileChangeType = (toolName === 'Write' && !capturedOriginal) - ? 'create' - : 'modify'; - - const fileChange: FileChange = { + // ★ 新建 FileChange + const changeType: FileChangeType = (toolName === 'Write' && !capturedOriginal) ? 'create' : 'modify'; + const fc: FileChange = { id: uuidv4(), filePath, type: changeType, @@ -138,85 +116,57 @@ export class ChangeSetManager { diffLines: editDiffLines, status: 'pending', }; - - this.currentSet!.changes.push(fileChange); - if (existingChange) { - console.log(`[AI Diff] ★ 新建 FileChange(旧 FileChange 已 ${existingChange.status}): ${filePath}`); - } else { - console.log(`[AI Diff] 新建 FileChange: ${filePath}, type=${changeType}`); - } + this.currentSet!.changes.push(fc); } } - /** - * 捕获文件的原始内容 - */ private captureOriginalContent( - filePath: string, - toolName: string, - oldString: string, - newString: string, - currentContent: string + filePath: string, toolName: string, oldString: string, newString: string, currentContent: string ): string { if (this.originalContents.has(filePath)) { return this.originalContents.get(filePath)!; } - let original: string; - if (toolName === 'Write') { - // Write: 无法获取旧内容 original = ''; } else if (toolName === 'Edit' && oldString) { - // Edit: 从当前文件中反推原始内容 const idx = currentContent.indexOf(newString); if (idx !== -1) { original = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length); } else { - // newString 不在文件中,可能已被之前的编辑影响 original = oldString; } } else { original = currentContent; } - this.originalContents.set(filePath, original); console.log(`[AI Diff] 捕获原始内容: ${filePath} (${original.length} 字符)`); return original; } - /** - * 反推编辑前的内容(用于已 accepted/rejected 后新编辑的场景) - * currentContent = 编辑后内容,reverseEdit = 从后往前还原 - */ private reverseEdit(currentContent: string, oldString: string, newString: string): string { if (!newString) return currentContent; const idx = currentContent.indexOf(newString); if (idx !== -1) { - const before = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length); - return before; + return currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length); } return currentContent; } // ---- 状态更新 ---- - /** 更新单个编辑记录的状态 */ updateEditStatus(fileId: string, editId: string, status: ChangeStatus): void { if (!this.currentSet) return; const change = this.currentSet.changes.find(c => c.id === fileId); if (!change) return; const edit = change.edits.find(e => e.id === editId); if (edit) edit.status = status; - - // 如果该文件所有编辑都已处理,更新文件状态 if (change.edits.every(e => e.status !== 'pending')) { const allAccepted = change.edits.every(e => e.status === 'accepted'); change.status = allAccepted ? 'accepted' : 'rejected'; } } - /** 更新整个文件的状态(同时更新所有编辑) */ updateFileStatus(fileId: string, status: ChangeStatus): void { if (!this.currentSet) return; const change = this.currentSet.changes.find(c => c.id === fileId); @@ -234,132 +184,132 @@ export class ChangeSetManager { }); } - // ---- Accept(应用到磁盘) ---- + // ---- Accept ---- - /** - * 接受单个编辑 — 内容已在磁盘上,只需标记状态 - */ acceptEditRecord(fileId: string, editId: string): boolean { - this.updateEditStatus(fileId, editId, 'accepted'); const change = this.findChange(fileId); if (!change) return false; - - // 如果整个文件所有编辑都已接受,写入最终的 latestContent + const edit = change.edits.find(e => e.id === editId); + if (!edit) return false; + // 此 edit 变更已在磁盘上,只需标记状态 + this.updateEditStatus(fileId, editId, 'accepted'); if (change.edits.every(e => e.status === 'accepted')) { return this.writeFile(change.filePath, change.latestContent); } return true; } - /** - * 接受整个文件的所有编辑 - */ + /** ★ 接受整个文件:先写入再标记 */ acceptFile(fileId: string): boolean { - this.updateFileStatus(fileId, 'accepted'); const change = this.findChange(fileId); if (!change) return false; - return this.writeFile(change.filePath, change.latestContent); + const ok = this.writeFile(change.filePath, change.latestContent); + if (!ok) return false; + this.updateFileStatus(fileId, 'accepted'); + return true; } - /** - * 接受所有变更 - */ + /** ★ 接受所有 pending 的文件 */ acceptAll(): number { if (!this.currentSet) return 0; + // snapshot pending list before looping (loop won't affect already-processed items) + const pendingIds = this.currentSet.changes.filter(c => c.status === 'pending').map(c => c.id); let count = 0; - for (const change of this.currentSet.changes) { - if (change.status === 'pending' && this.acceptFile(change.id)) { - count++; - } + for (const id of pendingIds) { + if (this.acceptFile(id)) count++; } return count; } - // ---- Reject(恢复原始内容) ---- + // ---- Reject ---- - /** - * 拒绝单个编辑 — 从磁盘文件中还原该编辑的变更 - */ rejectEditRecord(fileId: string, editId: string): boolean { const change = this.findChange(fileId); if (!change) return false; - const edit = change.edits.find(e => e.id === editId); if (!edit) return false; - // 从当前文件中还原该编辑:将 newString 替换回 oldString + let reverted: string; try { const currentContent = this.readFile(change.filePath); - if (currentContent !== null) { - const revertedContent = this.revertEditInContent(currentContent, edit); - this.writeFile(change.filePath, revertedContent); - } + if (currentContent === null) return false; + reverted = this.revertEditInContent(currentContent, edit); } catch (e) { console.error(`[AI Diff] 还原编辑失败: ${change.filePath}`, e); return false; } + const ok = this.writeFile(change.filePath, reverted); + if (!ok) return false; this.updateEditStatus(fileId, editId, 'rejected'); return true; } - /** - * 拒绝整个文件的所有编辑 — 恢复 originalContent - */ + /** ★ 拒绝整个文件:先恢复再标记 */ rejectFile(fileId: string): boolean { const change = this.findChange(fileId); if (!change) return false; - this.updateFileStatus(fileId, 'rejected'); - try { if (change.type === 'create') { - // 新建的文件 → 删除 - if (fs.existsSync(change.filePath)) { - fs.unlinkSync(change.filePath); - } + if (fs.existsSync(change.filePath)) fs.unlinkSync(change.filePath); } else { - // 修改的文件 → 恢复原始内容 - this.writeFile(change.filePath, change.originalContent); + const ok = this.writeFile(change.filePath, change.originalContent); + if (!ok) return false; } - console.log(`[AI Diff] 已拒绝: ${change.filePath}`); - return true; + console.log(`[AI Diff] 已拒绝文件: ${change.filePath}`); } catch (e) { console.error(`[AI Diff] 恢复文件失败: ${change.filePath}`, e); return false; } + + this.updateFileStatus(fileId, 'rejected'); + return true; } - /** - * 拒绝所有变更 - */ + /** ★ 拒绝所有 pending 的文件 */ rejectAll(): number { if (!this.currentSet) return 0; + const pendingIds = this.currentSet.changes.filter(c => c.status === 'pending').map(c => c.id); let count = 0; - for (const change of this.currentSet.changes) { - if (change.status === 'pending' && this.rejectFile(change.id)) { - count++; - } + for (const id of pendingIds) { + if (this.rejectFile(id)) count++; } return count; } - // ---- 工具方法 ---- + // ---- 工具 ---- - /** - * 在内容中还原单个编辑 - */ private revertEditInContent(content: string, edit: EditRecord): string { - // 找到 newString 并替换回 oldString const idx = content.indexOf(edit.newString); if (idx !== -1) { return content.substring(0, idx) + edit.oldString + content.substring(idx + edit.newString.length); } - // 找不到精确匹配,回退:使用 beforeContent console.warn(`[AI Diff] 无法精确定位编辑替换位置,回退到 beforeContent`); return edit.beforeContent; } + /** ★ 查找下一个待处理的文件变更(用于自动推进) */ + findNextPending(afterFileId?: string): FileChange | undefined { + if (!this.currentSet) return undefined; + const pending = this.currentSet.changes.filter(c => c.status === 'pending'); + if (pending.length === 0) return undefined; + if (!afterFileId) return pending[0]; + const idx = pending.findIndex(c => c.id === afterFileId); + if (idx < 0 || idx >= pending.length - 1) { + // 当前是最后一个 pending,回到第一个(循环) + return pending.length > 1 ? pending[0] : undefined; + } + return pending[idx + 1]; + } + + /** 检查文件是否完全处理完毕(所有编辑都已 accepted/rejected) */ + isFileFullyProcessed(fileId: string): boolean { + const change = this.findChange(fileId); + if (!change) return true; + return change.edits.every(e => e.status !== 'pending'); + } + clear(): void { this.currentSet = null; this.originalContents.clear(); @@ -379,7 +329,7 @@ export class ChangeSetManager { private writeFile(filePath: string, content: string): boolean { try { fs.writeFileSync(filePath, content, 'utf-8'); - console.log(`[AI Diff] 写入文件: ${filePath}`); + console.log(`[AI Diff] 写入文件: ${filePath} (${content.length} 字符)`); return true; } catch (e) { console.error(`[AI Diff] 写入文件失败: ${filePath}`, e); @@ -395,10 +345,7 @@ export class ChangeSetManager { for (const change of changes) { const changeLines = change.value.split('\n'); - if (changeLines[changeLines.length - 1] === '') { - changeLines.pop(); - } - + if (changeLines[changeLines.length - 1] === '') changeLines.pop(); for (const line of changeLines) { if (change.added) { lines.push({ type: 'add', oldLineNum: 0, newLineNum: newLine, content: line }); @@ -413,18 +360,13 @@ export class ChangeSetManager { } } } - return lines; } private readFile(filePath: string): string | null { - try { - return fs.readFileSync(filePath, 'utf-8'); - } catch { - return null; - } + try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; } } } -/** @legacy 向后兼容别名,供未迁移的模块使用 */ +/** @legacy 向后兼容别名 */ export { ChangeSetManager as SnapshotManager }; diff --git a/src/trigger/hookHandler.ts b/src/trigger/hookHandler.ts index d7e8acf..4fef24f 100644 --- a/src/trigger/hookHandler.ts +++ b/src/trigger/hookHandler.ts @@ -1,13 +1,8 @@ /** * Hook 处理器 * - * PostToolUse (Edit/Write) → 收集变更 - * + autoShowDiffPerEdit=true → 立即弹出 VSCode 内置 Diff - * + autoShowDiffPerEdit=false → 静默收集 - * - * Stop → 对话结束 - * + showAllDiffsOnStop=true → 弹出 QuickPick 文件列表 - * + showAllDiffsOnStop=false → 仅状态栏通知 + * Edit/Write → recordEdit() + * Stop → QuickPick 总览 (含 Accept All / Reject All) */ import * as vscode from 'vscode'; @@ -17,131 +12,136 @@ import { DiffViewer } from '../render/diffViewer'; import { ClaudeHookEvent, FileChange } from '../models/types'; import { CONFIG_SECTION, CONFIG_KEYS } from '../models/constants'; -const SETTING_AUTO_SHOW_DIFF = `${CONFIG_SECTION}.${CONFIG_KEYS.AUTO_SHOW_DIFF_PER_EDIT}`; -const SETTING_SHOW_ALL_ON_STOP = `${CONFIG_SECTION}.${CONFIG_KEYS.SHOW_ALL_DIFFS_ON_STOP}`; - export class HookHandler { - private diffViewer: DiffViewer; - constructor( - private changeSetManager: ChangeSetManager - ) { - this.diffViewer = new DiffViewer(changeSetManager); - } + private changeSetManager: ChangeSetManager, + private diffViewer: DiffViewer + ) {} getDiffViewer(): DiffViewer { return this.diffViewer; } - /** - * 处理 PostToolUse 事件(Edit/Write) - * 收集变更,根据配置决定是否自动弹 Diff - */ handleEdit(event: ClaudeHookEvent): void { event.filePath = path.normalize(event.filePath); console.log(`[AI Diff] 收集变更: ${event.toolName} → ${event.filePath}`); this.changeSetManager.recordEdit( - event.filePath, - event.toolName, - event.oldString || '', - event.newString || '', - event.content || '' + event.filePath, event.toolName, + event.oldString || '', event.newString || '', event.content || '' ); - // 根据配置决定是否自动弹 Diff - const autoShow = vscode.workspace.getConfiguration(CONFIG_SECTION).get(CONFIG_KEYS.AUTO_SHOW_DIFF_PER_EDIT, false); + const autoShow = vscode.workspace + .getConfiguration(CONFIG_SECTION) + .get(CONFIG_KEYS.AUTO_SHOW_DIFF_PER_EDIT, false); + if (autoShow) { const set = this.changeSetManager.getCurrentSet(); if (set) { - const normalizedPath = path.normalize(event.filePath).replace(/\\/g, '/'); - const fileChange = set.changes.find( - c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath - ); - if (fileChange) { - const lastEdit = fileChange.edits[fileChange.edits.length - 1]; - this.diffViewer.showEditDiff(lastEdit); - } + const norm = path.normalize(event.filePath).replace(/\\/g, '/'); + const fc = set.changes.find(c => path.normalize(c.filePath).replace(/\\/g, '/') === norm); + if (fc) this.diffViewer.showEditDiff(fc.edits[fc.edits.length - 1], fc.id); } } - - // 更新上下文键 vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', true); } - /** - * 处理 Stop 事件(对话结束) - */ handleStop(): void { const changeSet = this.changeSetManager.markReady(); - if (!changeSet) { - console.log('[AI Diff] 对话结束,无文件变更'); - return; - } + if (!changeSet) { console.log('[AI Diff] 对话结束,无文件变更'); return; } - const count = changeSet.changes.length; const pendingCount = changeSet.changes.filter(c => c.status === 'pending').length; - console.log(`[AI Diff] 对话结束,${count} 个文件有变更 (pending: ${pendingCount})`); + console.log(`[AI Diff] 对话结束,pending: ${pendingCount}/${changeSet.changes.length}`); - const showAll = vscode.workspace.getConfiguration(CONFIG_SECTION).get(CONFIG_KEYS.SHOW_ALL_DIFFS_ON_STOP, true); + const showAll = vscode.workspace + .getConfiguration(CONFIG_SECTION) + .get(CONFIG_KEYS.SHOW_ALL_DIFFS_ON_STOP, true); if (showAll && pendingCount > 0) { - // 弹出 QuickPick 文件列表 this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending')); } else { - // 仅状态栏通知 const msg = pendingCount > 0 - ? `AI Diff: ${pendingCount} 个文件有待处理变更` - : `AI Diff: ${count} 个文件变更已完成`; - vscode.window.showInformationMessage(msg, '查看变更').then(selection => { - if (selection === '查看变更') { - this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending')); - } + ? `AI Diff: ${pendingCount} 个文件有变更` + : `AI Diff: ${changeSet.changes.length} 个文件已处理`; + vscode.window.showInformationMessage(msg, '查看总览').then(sel => { + if (sel === '查看总览') this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending')); }); } } - /** - * 弹出 QuickPick 文件列表 - */ async showFileListPicker(fileChanges?: FileChange[]): Promise { let changes = fileChanges; if (!changes) { - const set = this.changeSetManager.getCurrentSet(); - if (!set) return; - changes = set.changes.filter(c => c.status === 'pending'); + changes = this.changeSetManager.getCurrentSet()?.changes.filter(c => c.status === 'pending') || []; } - if (changes.length === 0) { vscode.window.showInformationMessage('AI Diff: 无待处理变更'); return; } - const items: vscode.QuickPickItem[] = changes.map(c => { + const totalEdits = changes.reduce((s, c) => s + c.edits.filter(e => e.status === 'pending').length, 0); + const totalAdds = changes.reduce((s, c) => s + c.diffLines.filter(d => d.type === 'add').length, 0); + const totalDels = changes.reduce((s, c) => s + c.diffLines.filter(d => d.type === 'delete').length, 0); + + interface Item extends vscode.QuickPickItem { + itemKind: 'file' | 'separator' | 'action'; + fileChange?: FileChange; + action?: 'acceptAll' | 'rejectAll'; + } + + const items: Item[] = []; + const typeMap: Record = { modify: '$(edit)', create: '$(new-file)', delete: '$(trash)' }; + + for (const c of changes) { const name = path.basename(c.filePath); const adds = c.diffLines.filter(d => d.type === 'add').length; const dels = c.diffLines.filter(d => d.type === 'delete').length; - const typeMap: Record = { modify: '$(edit)', create: '$(new-file)', delete: '$(trash)' }; - - return { + items.push({ + itemKind: 'file', label: `${typeMap[c.type] || '$(file)'} ${name}`, description: `+${adds} -${dels} · ${c.edits.length} 次编辑`, detail: c.filePath, - // 存储 fileChange id 以便查找 - id: c.id, - }; + fileChange: c, + }); + } + + items.push({ + itemKind: 'separator', + label: '──────────────────────────────', + description: '', }); - const selected = await vscode.window.showQuickPick(items, { - placeHolder: `AI Diff Review — ${changes.length} 个文件有变更`, - matchOnDescription: true, - matchOnDetail: true, + items.push({ + itemKind: 'action', + label: '$(check-all) ✅ Accept All — 接受所有变更', + description: `${changes.length} 文件 · +${totalAdds} -${totalDels} · ${totalEdits} 编辑`, + action: 'acceptAll', }); - if (selected && selected.id) { - const fileChange = changes.find(c => c.id === selected.id); - if (fileChange) { - await this.diffViewer.showFileDiff(fileChange); + items.push({ + itemKind: 'action', + label: '$(trash) ❌ Reject All — 拒绝所有变更', + description: `${changes.length} 文件 · +${totalAdds} -${totalDels} · ${totalEdits} 编辑`, + action: 'rejectAll', + }); + + const selected = await vscode.window.showQuickPick(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} 个文件 ✗`); } } } diff --git a/src/trigger/hookHandler_bak.ts b/src/trigger/hookHandler_bak.ts new file mode 100644 index 0000000..a51f647 --- /dev/null +++ b/src/trigger/hookHandler_bak.ts @@ -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(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(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 { + 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 = { 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(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} 个文件 ✗`); + } + } + } +} \ No newline at end of file diff --git a/测试.md b/测试.md index e7483e2..a04f542 100644 --- a/测试.md +++ b/测试.md @@ -52,6 +52,7 @@ ## 第五轮测试 - 内嵌悬浮窗验证 这次修改用于验证: + 1. Webview 面板能否正常弹出 2. 双栏 Diff 是否正确显示新增/删除行 3. Accept 按钮是否生效 @@ -64,3 +65,50 @@ - 修复了所有已知问题 - 现在应该能正常工作 - 验证: 面板弹出 + Accept/Reject + 文件回写 + +## 第七轮测试 + +改动内容: + +- 语法错误已修复 +- API 调用已优化 +- 新增错误处理逻辑 + +以下为原始代码(已删除): + +```js +function oldApi() { + return fetch('/api').then(r => r.json()); +} +``` + +替换为新的 async/await 写法: + +```js +async function newApi() { + const res = await fetch('/api'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} +``` + +## 第八轮测试 + +- 代码添加了错误处理 +- 验证 CodeLens 按钮和状态栏是否正常显示 +- 验证 Accept / Reject 快捷键是否生效 + +## 第九轮测试 - 多文件变更 + +同时修改了 3 个文件: + +1. 测试.md - 本文件 +2. test1.md - 新增功能模块 +3. test2.md - 新增 API 和模块 + +验证: + +- 状态栏显示 "3文件 N编辑" +- 点击弹出 Webview 总览面板 +- 每个文件可展开查看详细 Diff +- Accept/Reject 按钮正常生效