feat: 逐次编辑导航 + 状态栏增强 + 依赖更新

- 新增 nextEdit/prevEdit/showEditAtIndex 命令支持逐次查看编辑
- DiffViewer 增加 editIndex/totalEdits 跟踪编辑进度
- StatusBar 增加编辑计数和逐次导航按钮
- SnapshotManager 改进 EditRecord 粒度的 accept/reject
- HookHandler 支持多次编辑的二级选择(聚合/逐次)
- 更新 package.json 依赖(esbuild win32-x64)
- 将 .claude/hooks/ 加入 .gitignore
- 删除测试.md
This commit is contained in:
2026-06-24 18:09:19 +08:00
parent fa44163ca3
commit 122c38c51e
9 changed files with 237 additions and 128 deletions
+1
View File
@@ -6,3 +6,4 @@ out/
*.log
coverage/
.env
.claude/hooks/
+24 -7
View File
@@ -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",
+39 -2
View File
@@ -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",
+8
View File
@@ -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;
/**
+63 -2
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
+40 -1
View File
@@ -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();
}
}
+39 -1
View File
@@ -20,6 +20,13 @@ function norm(p: string): string {
export class ChangeSetManager {
private currentSet: ChangeSet | null = null;
private originalContents: Map<string, string> = 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 {
+23 -1
View File
@@ -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();
-114
View File
@@ -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 按钮正常生效