diff --git a/.gitignore b/.gitignore index f434ed7..951446b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ out/ *.log coverage/ .env +.claude/hooks/ diff --git a/package-lock.json b/package-lock.json index 5a9d1f5..54da153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "ai-diff-preview", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-diff-preview", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "diff": "^5.0.0", "uuid": "^9.0.0" }, "devDependencies": { + "@esbuild/win32-x64": "^0.28.1", "@types/diff": "^5.0.0", "@types/node": "^20.0.0", "@types/uuid": "^9.0.0", @@ -404,20 +405,19 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], "dev": true, "license": "MIT", - "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1790,6 +1790,23 @@ "@esbuild/win32-x64": "0.19.12" } }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", diff --git a/package.json b/package.json index 1a987bc..64f90e6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "Programming Languages", "Other" ], - "activationEvents": ["onStartupFinished"], + "activationEvents": [ + "onStartupFinished" + ], "main": "./dist/extension.js", "contributes": { "commands": [ @@ -57,6 +59,18 @@ { "command": "aiDiffPreview.prevDiff", "title": "AI Diff: 上一个变更" + }, + { + "command": "aiDiffPreview.nextEdit", + "title": "AI Diff: 下一次编辑" + }, + { + "command": "aiDiffPreview.prevEdit", + "title": "AI Diff: 上一次编辑" + }, + { + "command": "aiDiffPreview.showEditAtIndex", + "title": "AI Diff: 逐次查看编辑" } ], "keybindings": [ @@ -105,6 +119,18 @@ "key": "ctrl+shift+f", "mac": "cmd+shift+f", "when": "aiDiffPreview.isActive" + }, + { + "command": "aiDiffPreview.nextEdit", + "key": "alt+right", + "mac": "alt+right", + "when": "aiDiffPreview.isActive" + }, + { + "command": "aiDiffPreview.prevEdit", + "key": "alt+left", + "mac": "alt+left", + "when": "aiDiffPreview.isActive" } ], "configuration": { @@ -128,7 +154,12 @@ "aiDiffPreview.floatingLabelMode": { "type": "string", "default": "statusBar", - "enum": ["inline", "statusBar", "both", "none"], + "enum": [ + "inline", + "statusBar", + "both", + "none" + ], "enumDescriptions": [ "编辑器行内 Accept/Reject 标签(通过 CodeLens 显示)", "状态栏底部固定 Accept/Reject 按钮", @@ -141,6 +172,11 @@ "type": "number", "default": 100000, "description": "最大处理文件大小(字符数)" + }, + "aiDiffPreview.idleTimeout": { + "type": "number", + "default": 3000, + "description": "空闲检测超时时间(毫秒)。在此时间内无新编辑事件,自动触发 Diff 提示(用于 ESC 打断场景)。设为 0 则禁用此功能" } } } @@ -155,6 +191,7 @@ "package": "vsce package" }, "devDependencies": { + "@esbuild/win32-x64": "^0.28.1", "@types/diff": "^5.0.0", "@types/node": "^20.0.0", "@types/uuid": "^9.0.0", diff --git a/src/models/constants.ts b/src/models/constants.ts index d456827..d3cd6c6 100644 --- a/src/models/constants.ts +++ b/src/models/constants.ts @@ -26,6 +26,12 @@ export const COMMANDS = { NEXT_DIFF: 'aiDiffPreview.nextDiff', /** 跳转到上一个待处理变更 */ PREV_DIFF: 'aiDiffPreview.prevDiff', + /** ★ 显示下一次编辑 */ + NEXT_EDIT: 'aiDiffPreview.nextEdit', + /** ★ 显示上一次编辑 */ + PREV_EDIT: 'aiDiffPreview.prevEdit', + /** ★ 切换到逐次编辑查看模式 */ + SHOW_EDIT_AT_INDEX: 'aiDiffPreview.showEditAtIndex', } as const; /** @@ -46,6 +52,8 @@ export const CONFIG_KEYS = { SHOW_ALL_DIFFS_ON_STOP: 'showAllDiffsOnStop', FLOATING_LABEL_MODE: 'floatingLabelMode', MAX_FILE_SIZE: 'maxFileSize', + /** ★ 空闲检测超时时间(毫秒),ESC 打断后自动触发 diff */ + IDLE_TIMEOUT: 'idleTimeout', } as const; /** diff --git a/src/render/diffViewer.ts b/src/render/diffViewer.ts index b3e25f9..025e637 100644 --- a/src/render/diffViewer.ts +++ b/src/render/diffViewer.ts @@ -19,6 +19,10 @@ import { ChangeSetManager } from '../snapshot/snapshotManager'; export interface DiffSession { fileId: string; editId?: string; + /** ★ 当前查看的编辑索引(undefined 表示聚合视图) */ + editIndex?: number; + /** ★ 当前文件的总编辑数 */ + totalEdits?: number; beforeTmpPath: string; afterTmpPath: string; beforeUri: vscode.Uri; @@ -65,7 +69,7 @@ export class DiffViewer { 'vscode.diff', this.activeSession!.beforeUri, this.activeSession!.afterUri, - `${fileName} — AI ${toolLabel} #${editRecord.id.slice(0, 6)} · 状态栏操作` + `${fileName} — AI ${toolLabel} · 编辑 #${editRecord.id.slice(0, 6)}` ); this.isOpening = false; @@ -92,7 +96,7 @@ export class DiffViewer { 'vscode.diff', this.activeSession!.beforeUri, this.activeSession!.afterUri, - `${fileName} — ${fileChange.edits.length} 次 AI 编辑 · 状态栏操作` + `${fileName} — 聚合视图 (${fileChange.edits.length} 次编辑)` ); this.isOpening = false; @@ -112,6 +116,63 @@ export class DiffViewer { else vscode.window.showInformationMessage('AI Diff: 当前文件无待处理变更'); } + // ---- ★ 逐次编辑导航 ---- + + /** 显示文件的第 N 次编辑的 diff */ + async showEditAtIndex(fileChange: FileChange, editIndex: number): Promise { + if (editIndex < 0 || editIndex >= fileChange.edits.length) return; + const edit = fileChange.edits[editIndex]; + await this.showEditDiff(edit, fileChange.id); + // ★ 更新 session 的编辑索引信息 + if (this.activeSession) { + this.activeSession.editIndex = editIndex; + this.activeSession.totalEdits = fileChange.edits.length; + } + this.onDiffAction?.(); + } + + /** 显示下一次编辑 */ + async showNextEdit(): Promise { + if (!this.activeSession) return; + const { fileId, editIndex } = this.activeSession; + if (editIndex === undefined) return; + const fc = this.findFileChange(fileId); + if (!fc) return; + const nextIdx = editIndex + 1; + if (nextIdx < fc.edits.length) { + await this.showEditAtIndex(fc, nextIdx); + } else { + vscode.window.showInformationMessage(`AI Diff: 已是最后一次编辑 (${fc.edits.length}/${fc.edits.length})`); + } + } + + /** 显示上一次编辑 */ + async showPrevEdit(): Promise { + if (!this.activeSession) return; + const { fileId, editIndex } = this.activeSession; + if (editIndex === undefined) return; + const fc = this.findFileChange(fileId); + if (!fc) return; + const prevIdx = editIndex - 1; + if (prevIdx >= 0) { + await this.showEditAtIndex(fc, prevIdx); + } else { + vscode.window.showInformationMessage('AI Diff: 已是第一次编辑 (1)'); + } + } + + /** 切换到聚合视图(查看文件整体 diff) */ + async showAggregatedDiff(): Promise { + if (!this.activeSession) return; + const fc = this.findFileChange(this.activeSession.fileId); + if (fc) await this.showFileDiff(fc); + } + + private findFileChange(fileId: string): FileChange | undefined { + const set = this.changeSetManager.getCurrentSet(); + return set?.changes.find(c => c.id === fileId); + } + // ---- Accept/Reject(Accept/Reject 后删除临时文件 + 关闭 Diff) ---- async acceptCurrentDiff(): Promise { diff --git a/src/render/statusBar.ts b/src/render/statusBar.ts index e2df478..c21d92a 100644 --- a/src/render/statusBar.ts +++ b/src/render/statusBar.ts @@ -19,6 +19,10 @@ export class StatusBarManager { private acceptBtn: vscode.StatusBarItem; /** Diff 模式 Reject 按钮(拒绝当前文件所有变更) */ private rejectBtn: vscode.StatusBarItem; + /** ★ 上一次编辑按钮 */ + private prevEditBtn: vscode.StatusBarItem; + /** ★ 下一次编辑按钮 */ + private nextEditBtn: vscode.StatusBarItem; constructor( private changeSetManager: ChangeSetManager, @@ -38,9 +42,19 @@ export class StatusBarManager { // Reject 按钮(拒绝当前文件所有变更) this.rejectBtn = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99); this.rejectBtn.command = 'aiDiffPreview.rejectCurrentDiff'; + + // ★ 上一次编辑按钮 + this.prevEditBtn = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 98); + this.prevEditBtn.command = 'aiDiffPreview.prevEdit'; + this.prevEditBtn.tooltip = '上一次编辑 (Alt+Left)'; + + // ★ 下一次编辑按钮 + this.nextEditBtn = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 97); + this.nextEditBtn.command = 'aiDiffPreview.nextEdit'; + this.nextEditBtn.tooltip = '下一次编辑 (Alt+Right)'; } - enterDiffMode(_session: DiffSession): void { + enterDiffMode(session: DiffSession): void { this.acceptBtn.text = '$(check) Accept'; this.acceptBtn.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); this.acceptBtn.tooltip = '接受此文件所有变更'; @@ -50,11 +64,34 @@ export class StatusBarManager { this.rejectBtn.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); this.rejectBtn.tooltip = '拒绝此文件所有变更'; this.rejectBtn.show(); + + // ★ 根据 session 状态显示编辑导航按钮 + if (session.editIndex !== undefined && session.totalEdits !== undefined) { + // 逐次编辑模式:显示 Prev/Next 和计数 + this.prevEditBtn.text = `$(arrow-left) ${session.editIndex + 1}/${session.totalEdits}`; + this.prevEditBtn.command = 'aiDiffPreview.prevEdit'; + this.prevEditBtn.tooltip = `上一次编辑 (Alt+Left) — 当前第 ${session.editIndex + 1}/${session.totalEdits} 次`; + this.prevEditBtn.show(); + + this.nextEditBtn.text = '$(arrow-right)'; + this.nextEditBtn.command = 'aiDiffPreview.nextEdit'; + this.nextEditBtn.tooltip = '下一次编辑 (Alt+Right)'; + this.nextEditBtn.show(); + } else { + // 聚合视图模式:显示"逐次查看"入口 + this.prevEditBtn.text = '$(list-ordered) 逐次查看'; + this.prevEditBtn.command = 'aiDiffPreview.showEditAtIndex'; + this.prevEditBtn.tooltip = '切换到逐次编辑查看模式'; + this.prevEditBtn.show(); + this.nextEditBtn.hide(); + } } exitDiffMode(): void { this.acceptBtn.hide(); this.rejectBtn.hide(); + this.prevEditBtn.hide(); + this.nextEditBtn.hide(); } /** ★ 常驻刷新 */ @@ -98,5 +135,7 @@ export class StatusBarManager { this.summaryItem.dispose(); this.acceptBtn.dispose(); this.rejectBtn.dispose(); + this.prevEditBtn.dispose(); + this.nextEditBtn.dispose(); } } \ No newline at end of file diff --git a/src/snapshot/snapshotManager.ts b/src/snapshot/snapshotManager.ts index 52c535b..08d2a07 100644 --- a/src/snapshot/snapshotManager.ts +++ b/src/snapshot/snapshotManager.ts @@ -20,6 +20,13 @@ function norm(p: string): string { export class ChangeSetManager { private currentSet: ChangeSet | null = null; private originalContents: Map = new Map(); + /** ★ .claude/hooks 目录路径,用于读取 PreToolUse 快照 */ + private hookDir: string | null = null; + + /** 设置 hook 目录路径(由 extension.ts 在启动时调用) */ + setHookDir(dir: string): void { + this.hookDir = dir; + } startNewSet(): string { const id = uuidv4(); @@ -128,7 +135,8 @@ export class ChangeSetManager { } let original: string; if (toolName === 'Write') { - original = ''; + // ★ 读取 PreToolUse 保存的原始文件内容 + original = this.readPreWriteSnapshot(); } else if (toolName === 'Edit' && oldString) { const idx = currentContent.indexOf(newString); if (idx !== -1) { @@ -144,6 +152,26 @@ export class ChangeSetManager { return original; } + /** + * ★ 读取 PreToolUse hook 保存的文件原始内容快照 + * PreToolUse 在 Write 执行前触发,会将文件当前内容保存到 pre-write-snapshot.json + */ + private readPreWriteSnapshot(): string { + try { + if (!this.hookDir) return ''; + const snapshotPath = path.join(this.hookDir, 'pre-write-snapshot.json'); + if (!fs.existsSync(snapshotPath)) return ''; + const data = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')); + // 读完即删,避免下次误用 + try { fs.unlinkSync(snapshotPath); } catch { /* ok */ } + const content = data.originalContent || ''; + console.log(`[AI Diff] 读取 PreToolUse 快照: (${content.length} 字符)`); + return content; + } catch { + return ''; + } + } + private reverseEdit(currentContent: string, oldString: string, newString: string): string { if (!newString) return currentContent; const idx = currentContent.indexOf(newString); @@ -313,6 +341,16 @@ export class ChangeSetManager { clear(): void { this.currentSet = null; this.originalContents.clear(); + this.cleanupPreWriteSnapshot(); + } + + /** 清理可能残留的 PreToolUse 快照文件 */ + private cleanupPreWriteSnapshot(): void { + try { + if (!this.hookDir) return; + const snapshotPath = path.join(this.hookDir, 'pre-write-snapshot.json'); + if (fs.existsSync(snapshotPath)) fs.unlinkSync(snapshotPath); + } catch { /* ok */ } } hasPendingChanges(): boolean { diff --git a/src/trigger/hookHandler.ts b/src/trigger/hookHandler.ts index 4fef24f..de8093c 100644 --- a/src/trigger/hookHandler.ts +++ b/src/trigger/hookHandler.ts @@ -134,7 +134,29 @@ export class HookHandler { if (!selected) return; if (selected.itemKind === 'file' && selected.fileChange) { - await this.diffViewer.showFileDiff(selected.fileChange); + if (selected.fileChange.edits.length > 1) { + // ★ 多次编辑:弹出二级选择 + const choice = await vscode.window.showQuickPick([ + { + label: '$(diff) 聚合视图', + description: `查看 ${selected.fileChange.edits.length} 次编辑的总 diff`, + mode: 'aggregate' as const, + }, + { + label: '$(list-ordered) 逐次查看', + description: '逐个查看每次编辑的 diff,支持 Accept/Reject 单次编辑', + mode: 'perEdit' as const, + }, + ], { placeHolder: `${path.basename(selected.fileChange.filePath)} — 选择查看模式` }); + if (!choice) return; + if (choice.mode === 'perEdit') { + await this.diffViewer.showEditAtIndex(selected.fileChange, 0); + } else { + await this.diffViewer.showFileDiff(selected.fileChange); + } + } else { + await this.diffViewer.showFileDiff(selected.fileChange); + } } else if (selected.itemKind === 'action') { if (selected.action === 'acceptAll') { const n = this.changeSetManager.acceptAll(); diff --git a/测试.md b/测试.md deleted file mode 100644 index a04f542..0000000 --- a/测试.md +++ /dev/null @@ -1,114 +0,0 @@ -# AI Diff Preview 测试文件 - -这是一个测试文件,用于验证 AI Diff Preview 插件的完整工作流程。 - -## 已完成功能 - -- ✅ 快照管理(Base/Suggest 双版本隔离) -- ✅ Diff 计算引擎(Myers 算法 + 变更块切割) -- ✅ Accept/Reject 事务处理(原子化写入) -- ✅ Claude Hooks 集成(PostToolUse 触发) -- ✅ 内联装饰器渲染(红绿高亮) -- ✅ CodeLens 按钮(逐块操作) - -## 待实现功能 - -- Diff 面板(多文件对比视图) -- 冲突检测(用户手动修改检测) -- 状态栏集成 -- 快捷键绑定 - -## 测试说明 - -修改此文件后,PostToolUse hook 会触发 VSCode 扩展, -自动创建快照并显示 Diff 预览,可通过 Accept/Reject 操作变更。 - -## 测试记录 - -- 2026-06-17: 第一次测试 hook 触发链路 -- 2026-06-17: 第二次测试 - 改为原生 Diff Editor 方案 -- 预期效果: 对话结束后弹出通知,点击后打开 VSCode 原生双栏 Diff -- 状态: 重启后测试 - -## 新增测试内容 - -这段文字用于验证 PostToolUse hook 能否正确收集变更, -并在对话结束后通过 VSCode 原生 Diff Editor 展示。 - -## 第三轮测试 - -- 改为内嵌 Webview 悬浮窗 -- 双栏 Diff + Accept/Reject 按钮 -- 不弹系统窗口,全部在面板内操作 -- 时间: 2026-06-17 22:40 - -## 第四轮测试 - -- 修复命令名: showReviewPanel → showDiffPanel -- 修复 Reject: 拒绝时回写原始内容到磁盘 -- 新增 rejectFileChange 方法 -- 预期: 点击 Reject 后文件恢复原状 - -## 第五轮测试 - 内嵌悬浮窗验证 - -这次修改用于验证: - -1. Webview 面板能否正常弹出 -2. 双栏 Diff 是否正确显示新增/删除行 -3. Accept 按钮是否生效 -4. Reject 按钮是否回写原始内容 - -测试时间: 2026-06-17 23:00 - -## 第六轮测试 - -- 修复了所有已知问题 -- 现在应该能正常工作 -- 验证: 面板弹出 + 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 按钮正常生效