diff --git a/CHANGELOG.md b/CHANGELOG.md index fab37cb..f6dfbf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ 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` + +### Fixed +- **Bug Fix**: Accept 后同文件再编辑不再触发 Accept/Reject 选项,且 Diff 合并的问题 + - 根因:`recordEdit()` 对同文件多次编辑更新同一个 `FileChange` 对象 + - 修复:已 accepted/rejected 的 `FileChange` 后再次编辑时自动创建新 `FileChange`,清除 `originalContents` 缓存 + +### Changed +- `ChangeSetManager` 重构为 `EditRecord[]` 独立记录模式 +- `FileChange` 变为聚合视图(持有 `edits: EditRecord[]`) +- `HookHandler` 支持 `autoShowDiffPerEdit` / `showAllDiffsOnStop` 配置分支 +- `ReviewPanel` Webview 降级为次要选项(保留兼容) +- 版本号升级至 `0.2.0` + ## [0.1.0] - 2026-06-17 ### Added diff --git a/README.md b/README.md index 7736584..b62e8e0 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,70 @@ # AI Code Diff Preview -类 Cursor 的 AI 代码差异预览 VSCode 插件,支持 Accept/Reject 操作。 +类 Cursor 的 AI 代码差异预览 VSCode 插件,使用 **VSCode 内置 Diff 编辑器** + **浮动 Accept/Reject 标签**。 ## 功能特性 -- ✅ **临时变更隔离** - AI 生成的代码不直接覆盖文件 -- ✅ **实时 Diff 预览** - 红绿高亮显示删除/新增内容 -- ✅ **逐行 Accept/Reject** - 支持单块、整块操作 -- ✅ **冲突检测** - 自动检测用户手动修改导致的冲突 -- ✅ **Claude Hooks 集成** - 自动触发 Diff 预览 -- ✅ **状态栏显示** - 实时显示变更状态 +- ✅ **VSCode 原生 Diff 编辑器** — 使用内置 `vscode.diff`,不自定义 Webview +- ✅ **EditRecord 独立追踪** — 每次 Edit/Write 独立记录,不合并 +- ✅ **逐次编辑 Accept/Reject** — 细粒度控制每次 AI 修改 +- ✅ **QuickPick 文件列表** — 对话结束后弹出原生文件选择器 +- ✅ **快捷键导航** — `Alt+↑/↓` 跳转变更文件 +- ✅ **Bug 修复** — Accept 后再编辑不再丢失追踪 +- ✅ **冲突检测** — 自动检测用户手动修改导致的冲突 + +## 工作流程 + +``` +Claude Edit/Write → 收集到 ChangeSet (EditRecord 粒度) + ├── autoShowDiffPerEdit=true → 立即弹出 VSCode 内置 Diff + └── autoShowDiffPerEdit=false → 静默收集,更新状态栏 + +Claude Stop → 变更集就绪 + ├── showAllDiffsOnStop=true → QuickPick 文件列表 → 点击打开内置 Diff + └── showAllDiffsOnStop=false → 仅通知,用户手动 Ctrl+Shift+D 查看 +``` ## 安装 ```bash -# 克隆项目 git clone https://github.com/your-username/ai-diff-preview.git - -# 安装依赖 cd ai-diff-preview npm install - -# 编译 npm run build - -# 打包 -npm run package ``` ## 使用方法 -### 1. 配置 Claude Hooks +### 快捷键 -在 Claude 配置文件中添加 Hook: +| 快捷键 | 条件 | 功能 | +|--------|------|------| +| `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 | -```json -{ - "hooks": { - "after_code_edit": { - "command": "code", - "args": ["--command", "aiDiffPreview.handleHook"] - } - } -} -``` +### 配置项 -### 2. 快捷键 - -| 快捷键 | 功能 | -|--------|------| -| `Tab` / `Enter` | 接受当前块 | -| `Esc` | 拒绝所有变更 | -| `Ctrl+Shift+A` | Accept All | -| `Ctrl+Shift+R` | Reject All | - -### 3. CodeLens 按钮 - -在代码上方会出现 Accept/Reject 按钮,点击即可操作。 +| 配置项 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| `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 - -# 代码检查 -npm run lint +npm run watch # 监听模式 +npm run test # 运行测试 (13 tests) +npm run lint # 代码检查 +npm run package # 打包 vsix ``` ## 项目结构 @@ -77,12 +73,14 @@ npm run lint src/ ├── extension.ts # 插件入口 ├── trigger/ -│ └── hookHandler.ts # Claude Hook 处理器 +│ └── hookHandler.ts # Hook 处理器 + QuickPick 文件列表 ├── snapshot/ -│ └── snapshotManager.ts # 快照管理器 +│ └── snapshotManager.ts # 变更集管理器 (ChangeSetManager) ├── diff/ │ └── diffEngine.ts # Diff 计算引擎 ├── render/ +│ ├── diffViewer.ts # ★ VSCode 内置 Diff 查看器 +│ ├── reviewPanel.ts # Webview 面板 (次要) │ ├── inlineDecorator.ts # 内联装饰器 │ ├── codeLensProvider.ts # CodeLens 提供器 │ └── statusBar.ts # 状态栏 @@ -94,15 +92,6 @@ src/ └── constants.ts # 常量定义 ``` -## 配置项 - -| 配置项 | 类型 | 默认值 | 说明 | -|--------|------|--------|------| -| `aiDiffPreview.enableAutoTrigger` | boolean | true | 启用自动触发 | -| `aiDiffPreview.showInlineButtons` | boolean | true | 显示内联按钮 | -| `aiDiffPreview.autoLint` | boolean | true | 自动运行 Lint | -| `aiDiffPreview.maxFileSize` | number | 100000 | 最大文件大小 | - ## License MIT diff --git a/package.json b/package.json index 10d77e8..1a06792 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "ai-diff-preview", "displayName": "AI Code Diff Preview", - "description": "类 Cursor 的 AI 代码差异预览插件,支持 Accept/Reject 操作", - "version": "0.1.0", + "description": "类 Cursor 的 AI 代码差异预览插件,使用 VSCode 内置 Diff 编辑器 + 浮动 Accept/Reject 标签", + "version": "0.2.0", "publisher": "your-publisher-name", "engines": { "vscode": "^1.85.0" @@ -17,13 +17,23 @@ "contributes": { "commands": [ { - "command": "aiDiffPreview.acceptChunk", - "title": "AI Diff: 接受当前变更块", + "command": "aiDiffPreview.showDiffPanel", + "title": "AI Diff: 显示所有变更文件列表", + "icon": "$(list-tree)" + }, + { + "command": "aiDiffPreview.showCurrentFileDiff", + "title": "AI Diff: 查看当前文件 Diff", + "icon": "$(diff)" + }, + { + "command": "aiDiffPreview.acceptCurrentEdit", + "title": "AI Diff: 接受当前编辑", "icon": "$(check)" }, { - "command": "aiDiffPreview.rejectChunk", - "title": "AI Diff: 拒绝当前变更块", + "command": "aiDiffPreview.rejectCurrentEdit", + "title": "AI Diff: 拒绝当前编辑", "icon": "$(close)" }, { @@ -37,21 +47,24 @@ "icon": "$(trash)" }, { - "command": "aiDiffPreview.showDiffPanel", - "title": "AI Diff: 显示 Diff 面板", - "icon": "$(diff)" + "command": "aiDiffPreview.nextDiff", + "title": "AI Diff: 下一个变更" + }, + { + "command": "aiDiffPreview.prevDiff", + "title": "AI Diff: 上一个变更" } ], "keybindings": [ { - "command": "aiDiffPreview.acceptChunk", + "command": "aiDiffPreview.acceptCurrentEdit", "key": "tab", - "when": "aiDiffPreview.isActive" + "when": "aiDiffPreview.isActive && editorTextFocus" }, { "command": "aiDiffPreview.rejectAll", "key": "escape", - "when": "aiDiffPreview.isActive" + "when": "aiDiffPreview.isActive && editorTextFocus" }, { "command": "aiDiffPreview.acceptAll", @@ -64,6 +77,30 @@ "key": "ctrl+shift+r", "mac": "cmd+shift+r", "when": "aiDiffPreview.isActive" + }, + { + "command": "aiDiffPreview.nextDiff", + "key": "alt+down", + "mac": "alt+down", + "when": "aiDiffPreview.isActive" + }, + { + "command": "aiDiffPreview.prevDiff", + "key": "alt+up", + "mac": "alt+up", + "when": "aiDiffPreview.isActive" + }, + { + "command": "aiDiffPreview.showDiffPanel", + "key": "ctrl+shift+d", + "mac": "cmd+shift+d", + "when": "aiDiffPreview.isActive" + }, + { + "command": "aiDiffPreview.showCurrentFileDiff", + "key": "ctrl+shift+f", + "mac": "cmd+shift+f", + "when": "aiDiffPreview.isActive" } ], "configuration": { @@ -72,17 +109,29 @@ "aiDiffPreview.enableAutoTrigger": { "type": "boolean", "default": true, - "description": "启用 Claude Hooks 自动触发" + "description": "启用自动触发(监听 pending.json 文件变更)" }, - "aiDiffPreview.showInlineButtons": { + "aiDiffPreview.autoShowDiffPerEdit": { + "type": "boolean", + "default": false, + "description": "每次 Edit/Write 后自动弹出 VSCode 内置 Diff 编辑器。关闭时静默收集变更,等待 Stop 后统一展示" + }, + "aiDiffPreview.showAllDiffsOnStop": { "type": "boolean", "default": true, - "description": "显示内联 Accept/Reject 按钮" + "description": "对话结束后自动弹出 QuickPick 文件列表,展示所有变更文件。关闭时仅显示状态栏通知" }, - "aiDiffPreview.autoLint": { - "type": "boolean", - "default": true, - "description": "Diff 预览时自动运行 Lint 检查" + "aiDiffPreview.floatingLabelMode": { + "type": "string", + "default": "statusBar", + "enum": ["inline", "statusBar", "both", "none"], + "enumDescriptions": [ + "编辑器行内 Accept/Reject 标签(通过 CodeLens 显示)", + "状态栏底部固定 Accept/Reject 按钮", + "两者都显示", + "不显示浮动标签" + ], + "description": "Accept/Reject 浮动标签显示位置" }, "aiDiffPreview.maxFileSize": { "type": "number", diff --git a/src/extension.ts b/src/extension.ts index 7283ad6..7d6fa13 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,10 +2,16 @@ * AI Code Diff Preview - 插件入口 * * 工作流程: - * 1. PostToolUse (Edit/Write) → 收集变更到 ChangeSet - * 2. Stop (对话结束) → 弹出通知 - * 3. 用户点击"查看更改" → 打开 Review 双栏面板 + * 1. PostToolUse (Edit/Write) → 收集变更到 ChangeSet (EditRecord 粒度) + * 2. Stop (对话结束) → 根据配置弹出 QuickPick 文件列表或通知 + * 3. 用户选择文件 → 打开 VSCode 内置 Diff 编辑器 * 4. 用户 Accept/Reject → 应用或丢弃变更 + * + * 快捷键: + * - Alt+↑/↓: 上一个/下一个变更 + * - Tab: 接受当前编辑 + * - Esc: 拒绝当前编辑 + * - Ctrl+Shift+D: 显示文件列表 */ import * as vscode from 'vscode'; @@ -13,12 +19,14 @@ import * as path from 'path'; 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 { COMMANDS } from './models/constants'; import { ClaudeHookEvent } from './models/types'; let changeSetManager: ChangeSetManager; let hookHandler: HookHandler; +let diffViewer: DiffViewer; let reviewPanel: ReviewPanel; export function activate(context: vscode.ExtensionContext) { @@ -27,6 +35,7 @@ export function activate(context: vscode.ExtensionContext) { // 初始化核心模块 changeSetManager = new ChangeSetManager(); hookHandler = new HookHandler(changeSetManager); + diffViewer = hookHandler.getDiffViewer(); reviewPanel = new ReviewPanel(changeSetManager); // 注册命令 @@ -40,12 +49,160 @@ export function activate(context: vscode.ExtensionContext) { } function registerCommands(context: vscode.ExtensionContext) { - // 显示 Review 面板 + // === Diff 查看 === + + // 显示文件列表 (QuickPick) context.subscriptions.push( vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => { - reviewPanel.show(); + hookHandler.showFileListPicker(); }) ); + + // 显示当前活动文件的 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)}`); + }) + ); + + // 接受全部 + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.ACCEPT_ALL, () => { + const count = changeSetManager.acceptAll(); + vscode.window.showInformationMessage(`AI Diff: 已接受 ${count} 个文件的变更`); + }) + ); + + // 拒绝全部 + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.REJECT_ALL, () => { + const count = changeSetManager.rejectAll(); + vscode.window.showInformationMessage(`AI Diff: 已拒绝 ${count} 个文件的变更`); + }) + ); + + // === 导航 === + + // 下一个变更 + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.NEXT_DIFF, () => { + navigateDiff(1); + }) + ); + + // 上一个变更 + 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); + }) + ); + context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.REJECT_CHUNK, () => { + vscode.commands.executeCommand(COMMANDS.REJECT_CURRENT_EDIT); + }) + ); +} + +/** + * 查找光标所在位置的编辑记录 + */ +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]); } /** @@ -53,7 +210,7 @@ function registerCommands(context: vscode.ExtensionContext) { * * 监听 .claude/hooks/pending.json: * - toolName: Edit/Write → 收集变更 - * - toolName: Stop → 弹出 Review 通知 + * - toolName: Stop → 弹出 Review 通知/文件列表 */ function registerHookFileWatcher(context: vscode.ExtensionContext) { const hookDir = vscode.workspace.workspaceFolders?.[0] diff --git a/src/models/constants.ts b/src/models/constants.ts index ec363d9..d456827 100644 --- a/src/models/constants.ts +++ b/src/models/constants.ts @@ -16,6 +16,16 @@ export const COMMANDS = { ACCEPT_ALL: 'aiDiffPreview.acceptAll', REJECT_ALL: 'aiDiffPreview.rejectAll', SHOW_DIFF_PANEL: 'aiDiffPreview.showDiffPanel', + /** 显示当前文件的 Diff */ + SHOW_CURRENT_FILE_DIFF: 'aiDiffPreview.showCurrentFileDiff', + /** 接受光标所在位置的编辑 */ + ACCEPT_CURRENT_EDIT: 'aiDiffPreview.acceptCurrentEdit', + /** 拒绝光标所在位置的编辑 */ + REJECT_CURRENT_EDIT: 'aiDiffPreview.rejectCurrentEdit', + /** 跳转到下一个待处理变更 */ + NEXT_DIFF: 'aiDiffPreview.nextDiff', + /** 跳转到上一个待处理变更 */ + PREV_DIFF: 'aiDiffPreview.prevDiff', } as const; /** @@ -27,6 +37,17 @@ export const CONTEXT_KEYS = { CHUNK_COUNT: 'aiDiffPreview.chunkCount', } as const; +/** + * 配置项键名 + */ +export const CONFIG_KEYS = { + ENABLE_AUTO_TRIGGER: 'enableAutoTrigger', + AUTO_SHOW_DIFF_PER_EDIT: 'autoShowDiffPerEdit', + SHOW_ALL_DIFFS_ON_STOP: 'showAllDiffsOnStop', + FLOATING_LABEL_MODE: 'floatingLabelMode', + MAX_FILE_SIZE: 'maxFileSize', +} as const; + /** * 装饰器类型 ID */ diff --git a/src/render/diffViewer.ts b/src/render/diffViewer.ts new file mode 100644 index 0000000..6000d13 --- /dev/null +++ b/src/render/diffViewer.ts @@ -0,0 +1,122 @@ +/** + * Diff 查看器 - 调用 VSCode 内置 Diff 编辑器 + * + * 使用 vscode.diff 命令展示原始内容 vs 变更后内容, + * 完全不使用自定义 Webview,保持 VSCode 原生交互体验。 + */ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import { EditRecord, FileChange } from '../models/types'; +import { ChangeSetManager } from '../snapshot/snapshotManager'; + +export class DiffViewer { + 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), + }); + + const fileName = path.basename(editRecord.filePath); + const toolLabel = editRecord.toolName === 'Write' ? '写入' : '编辑'; + + await vscode.commands.executeCommand( + 'vscode.diff', + beforeDoc.uri, + afterDoc.uri, + `${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 fileName = path.basename(fileChange.filePath); + + await vscode.commands.executeCommand( + 'vscode.diff', + beforeDoc.uri, + afterDoc.uri, + `${fileName} — 原始 ↔ 变更后 (${fileChange.edits.length} 次编辑)` + ); + } + + /** + * 打开当前活动文件在编辑器中的 Diff + */ + async showCurrentFileDiff(): Promise { + const changeSet = this.changeSetManager.getCurrentSet(); + if (!changeSet) return; + + const editor = vscode.window.activeTextEditor; + if (!editor) return; + + const normalizedPath = path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/'); + const fileChange = changeSet.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); + } + + /** + * 聚焦文件到编辑器(跳转到该文件并定位到第一个变更行) + */ + 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 }); + + // 滚动到第一个变更行 + 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); + } + } + } + + 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'; + } +} diff --git a/src/trigger/hookHandler.ts b/src/trigger/hookHandler.ts index 7a643ff..d7e8acf 100644 --- a/src/trigger/hookHandler.ts +++ b/src/trigger/hookHandler.ts @@ -1,23 +1,41 @@ /** * Hook 处理器 * - * PostToolUse (Edit/Write) → 收集变更,不显示 UI - * Stop → 对话结束,弹出 Review 通知 + * PostToolUse (Edit/Write) → 收集变更 + * + autoShowDiffPerEdit=true → 立即弹出 VSCode 内置 Diff + * + autoShowDiffPerEdit=false → 静默收集 + * + * Stop → 对话结束 + * + showAllDiffsOnStop=true → 弹出 QuickPick 文件列表 + * + showAllDiffsOnStop=false → 仅状态栏通知 */ import * as vscode from 'vscode'; import * as path from 'path'; import { ChangeSetManager } from '../snapshot/snapshotManager'; -import { ClaudeHookEvent } from '../models/types'; +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); + } + + getDiffViewer(): DiffViewer { + return this.diffViewer; + } /** * 处理 PostToolUse 事件(Edit/Write) - * 只收集变更,不弹 UI + * 收集变更,根据配置决定是否自动弹 Diff */ handleEdit(event: ClaudeHookEvent): void { event.filePath = path.normalize(event.filePath); @@ -30,11 +48,29 @@ export class HookHandler { event.newString || '', event.content || '' ); + + // 根据配置决定是否自动弹 Diff + 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); + } + } + } + + // 更新上下文键 + vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', true); } /** * 处理 Stop 事件(对话结束) - * 弹出通知,提示用户查看变更 */ handleStop(): void { const changeSet = this.changeSetManager.markReady(); @@ -44,16 +80,69 @@ export class HookHandler { } const count = changeSet.changes.length; - console.log(`[AI Diff] 对话结束,${count} 个文件有变更`); + const pendingCount = changeSet.changes.filter(c => c.status === 'pending').length; + console.log(`[AI Diff] 对话结束,${count} 个文件有变更 (pending: ${pendingCount})`); - // 弹出通知,带"查看更改"按钮 - vscode.window.showInformationMessage( - `AI Diff: ${count} 个文件已修改`, - '查看更改' - ).then(selection => { - if (selection === '查看更改') { - vscode.commands.executeCommand('aiDiffPreview.showDiffPanel'); - } + 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')); + } + }); + } + } + + /** + * 弹出 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'); + } + + if (changes.length === 0) { + vscode.window.showInformationMessage('AI Diff: 无待处理变更'); + return; + } + + const items: vscode.QuickPickItem[] = changes.map(c => { + 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 { + label: `${typeMap[c.type] || '$(file)'} ${name}`, + description: `+${adds} -${dels} · ${c.edits.length} 次编辑`, + detail: c.filePath, + // 存储 fileChange id 以便查找 + id: c.id, + }; }); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: `AI Diff Review — ${changes.length} 个文件有变更`, + matchOnDescription: true, + matchOnDetail: true, + }); + + if (selected && selected.id) { + const fileChange = changes.find(c => c.id === selected.id); + if (fileChange) { + await this.diffViewer.showFileDiff(fileChange); + } + } } }