Compare commits

...

2 Commits

Author SHA1 Message Date
Gmarker689 122c38c51e feat: 逐次编辑导航 + 状态栏增强 + 依赖更新
- 新增 nextEdit/prevEdit/showEditAtIndex 命令支持逐次查看编辑
- DiffViewer 增加 editIndex/totalEdits 跟踪编辑进度
- StatusBar 增加编辑计数和逐次导航按钮
- SnapshotManager 改进 EditRecord 粒度的 accept/reject
- HookHandler 支持多次编辑的二级选择(聚合/逐次)
- 更新 package.json 依赖(esbuild win32-x64)
- 将 .claude/hooks/ 加入 .gitignore
- 删除测试.md
2026-06-24 18:09:19 +08:00
Gmarker689 fa44163ca3 refactor: 用 StopFailure 事件替代定时轮询,移除空闲检测定时器
- 移除 heartbeat 空闲检测机制(PreToolUse 空 matcher)
- 新增 StopFailure hooks 处理 API 错误中断场景
- PostToolUse 仅保留 Edit(Write 通过 PreToolUse 快照实现)
- 移除 extension.ts 中 idleTimer/idleCooldown/heartbeatWatcher
- 更新 README hooks 配置文档
- 添加 MIT LICENSE
2026-06-24 17:58:15 +08:00
13 changed files with 369 additions and 150 deletions
+26 -3
View File
@@ -1,12 +1,24 @@
{
"hooks": {
"PostToolUse": [
"PreToolUse": [
{
"matcher": "Write|Edit",
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const e={type:'edit',filePath:j.tool_input?.file_path||j.tool_response?.filePath||'',content:j.tool_input?.content||'',oldString:j.tool_input?.old_string||'',newString:j.tool_input?.new_string||'',timestamp:Date.now(),toolName:j.tool_name};fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify(e));fs.renameSync(tmp,t)}catch{}})\"",
"command": "node -e \"const fs=require('fs'),p=require('path');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const fp=j.tool_input?.file_path;if(!fp)return;const d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');fs.mkdirSync(d,{recursive:true});let oc='';try{oc=fs.readFileSync(fp,'utf-8')}catch(e){}const s={originalContent:oc,timestamp:Date.now()};const f=p.join(d,'pre-write-snapshot.json'),tmp=f+'.tmp';fs.writeFileSync(tmp,JSON.stringify(s));fs.renameSync(tmp,f)}catch(e){}})\"",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const e={type:'edit',filePath:j.tool_input?.file_path||'',content:j.tool_input?.content||'',oldString:j.tool_input?.old_string||'',newString:j.tool_input?.new_string||'',timestamp:Date.now(),toolName:'Edit'};fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify(e));fs.renameSync(tmp,t)}catch{}})\"",
"timeout": 5
}
]
@@ -22,6 +34,17 @@
}
]
}
],
"StopFailure": [
{
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');try{fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify({type:'stopFailure',filePath:'',content:'',oldString:'',newString:'',timestamp:Date.now(),toolName:'StopFailure'}));fs.renameSync(tmp,t)}catch{}\"",
"timeout": 5
}
]
}
]
}
}
+1
View File
@@ -6,3 +6,4 @@ out/
*.log
coverage/
.env
.claude/hooks/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gmaker689
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+41 -9
View File
@@ -22,13 +22,25 @@
```json
{
"hooks": {
"PostToolUse": [
"PreToolUse": [
{
"matcher": "Write|Edit",
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const e={type:'edit',filePath:j.tool_input?.file_path||j.tool_response?.filePath||'',content:j.tool_input?.content||'',oldString:j.tool_input?.old_string||'',newString:j.tool_input?.new_string||'',timestamp:Date.now(),toolName:j.tool_name};fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify(e));fs.renameSync(tmp,t)}catch{}})\"",
"command": "node -e \"const fs=require('fs'),p=require('path');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const fp=j.tool_input?.file_path;if(!fp)return;const d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');fs.mkdirSync(d,{recursive:true});let oc='';try{oc=fs.readFileSync(fp,'utf-8')}catch(e){}const s={originalContent:oc,timestamp:Date.now()};const f=p.join(d,'pre-write-snapshot.json'),tmp=f+'.tmp';fs.writeFileSync(tmp,JSON.stringify(s));fs.renameSync(tmp,f)}catch(e){}})\"",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(b);const e={type:'edit',filePath:j.tool_input?.file_path||'',content:j.tool_input?.content||'',oldString:j.tool_input?.old_string||'',newString:j.tool_input?.new_string||'',timestamp:Date.now(),toolName:'Edit'};fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify(e));fs.renameSync(tmp,t)}catch{}})\"",
"timeout": 5
}
]
@@ -44,20 +56,40 @@
}
]
}
],
"StopFailure": [
{
"hooks": [
{
"type": "command",
"command": "node -e \"const fs=require('fs'),p=require('path'),d=p.join(process.env.CLAUDE_PLUGIN_ROOT||'.','.claude','hooks');try{fs.mkdirSync(d,{recursive:true});const t=p.join(d,'pending.json'),tmp=t+'.tmp';fs.writeFileSync(tmp,JSON.stringify({type:'stopFailure',filePath:'',content:'',oldString:'',newString:'',timestamp:Date.now(),toolName:'StopFailure'}));fs.renameSync(tmp,t)}catch{}\"",
"timeout": 5
}
]
}
]
}
}
```
### Hooks 说明
| Hook | 匹配器 | 作用 |
| --- | --- | --- |
| **PreToolUse** | `Write` | 写入前保存文件原始内容快照,用于 diff 对比 |
| **PostToolUse** | `Edit` | 记录编辑信息(oldString/newString),用于精确 diff |
| **Stop** | — | Claude 正常完成响应 → 触发变更总览 |
| **StopFailure** | — | API 错误导致中断 → 触发变更总览 |
## 命令
| 命令 | 快捷键 | 功能 |
| --------------------------------- | ---------------- | ---------------------------------------- |
| 命令 | 快捷键 | 功能 |
| --- | --- | --- |
| `AI Diff: 显示所有变更文件列表` | `Ctrl+Shift+D` | QuickPick 总览 + Accept All / Reject All |
| `AI Diff: 下一个变更` | `Alt+↓` | 跳转下一个 pending 文件 |
| `AI Diff: 上一个变更` | `Alt+↑` | 跳转上一个 pending 文件 |
| `AI Diff: 接受所有变更` | `Ctrl+Shift+A` | Accept All |
| `AI Diff: 拒绝所有变更` | `Ctrl+Shift+R` | Reject All |
| `AI Diff: 下一个变更` | `Alt+↓` | 跳转下一个 pending 文件 |
| `AI Diff: 上一个变更` | `Alt+↑` | 跳转上一个 pending 文件 |
| `AI Diff: 接受所有变更` | `Ctrl+Shift+A` | Accept All |
| `AI Diff: 拒绝所有变更` | `Ctrl+Shift+R` | Reject All |
Diff 编辑器打开后,状态栏右侧自动出现 **Accept / Reject / 接受全部** 按钮。
+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",
+44 -10
View File
@@ -30,6 +30,10 @@ export function activate(context: vscode.ExtensionContext) {
hookHandler = new HookHandler(changeSetManager, diffViewer);
statusBar = new StatusBarManager(changeSetManager, diffViewer);
// ★ 设置 hook 目录路径,供 snapshotManager 读取 PreToolUse 快照
const hookDir = getHookDir();
if (hookDir) changeSetManager.setHookDir(hookDir);
// DiffViewer 操作后刷新状态栏
diffViewer.onDiffAction = () => refreshUI();
@@ -95,6 +99,24 @@ function registerCommands(context: vscode.ExtensionContext) {
vscode.commands.registerCommand(COMMANDS.PREV_DIFF, () => navigateDiff(-1))
);
// ★ 逐次编辑导航
context.subscriptions.push(
vscode.commands.registerCommand('aiDiffPreview.nextEdit', () => diffViewer.showNextEdit())
);
context.subscriptions.push(
vscode.commands.registerCommand('aiDiffPreview.prevEdit', () => diffViewer.showPrevEdit())
);
context.subscriptions.push(
vscode.commands.registerCommand('aiDiffPreview.showEditAtIndex', () => {
const session = diffViewer.getActiveSession();
if (session) {
const set = changeSetManager.getCurrentSet();
const fc = set?.changes.find(c => c.id === session.fileId);
if (fc) diffViewer.showEditAtIndex(fc, 0);
}
})
);
// Diff 界面状态栏按钮
context.subscriptions.push(
vscode.commands.registerCommand('aiDiffPreview.acceptCurrentDiff', async () => {
@@ -150,6 +172,14 @@ function refreshUI(): void {
vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', hasPending);
}
// ---- Hook 目录 ----
function getHookDir(): string | null {
const folder = vscode.workspace.workspaceFolders?.[0];
if (!folder) return null;
return path.join(folder.uri.fsPath, '.claude', 'hooks');
}
// ---- Hook 监听 ----
function registerHookFileWatcher(context: vscode.ExtensionContext) {
@@ -160,11 +190,13 @@ function registerHookFileWatcher(context: vscode.ExtensionContext) {
if (!fs.existsSync(hookDir)) fs.mkdirSync(hookDir, { recursive: true });
const pendingFile = path.join(hookDir, 'pending.json');
const watcher = vscode.workspace.createFileSystemWatcher(
// ★ 监听 pending.json(Edit/Stop/StopFailure 事件)
const pendingWatcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(hookDir, 'pending.json')
);
const handle = () => {
const handlePending = () => {
try {
if (!fs.existsSync(pendingFile)) return;
const content = fs.readFileSync(pendingFile, 'utf-8');
@@ -172,18 +204,20 @@ function registerHookFileWatcher(context: vscode.ExtensionContext) {
const event: ClaudeHookEvent = JSON.parse(content);
if (Date.now() - event.timestamp > 10000) return;
if (event.toolName === 'Stop') {
if (event.toolName === 'Stop' || event.toolName === 'StopFailure') {
// ★ 对话结束(正常完成或 API 失败)→ 触发 diff 总览
hookHandler.handleStop();
} else if (event.toolName === 'Edit' || event.toolName === 'Write') {
} else if (event.toolName === 'Edit') {
hookHandler.handleEdit(event);
}
refreshUI();
} catch (e) { console.error('[AI Diff] 处理触发文件失败:', e); }
} catch (e) { console.error('[AI Diff] 处理 pending 文件失败:', e); }
};
watcher.onDidChange(handle);
watcher.onDidCreate(handle);
context.subscriptions.push(watcher);
pendingWatcher.onDidChange(handlePending);
pendingWatcher.onDidCreate(handlePending);
context.subscriptions.push(pendingWatcher);
console.log(`[AI Diff] Hook 监听已启动: ${hookDir}`);
}
@@ -199,8 +233,8 @@ function checkPendingHookFile() {
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' || event.toolName === 'StopFailure') hookHandler.handleStop();
else if (event.toolName === 'Edit') hookHandler.handleEdit(event);
refreshUI();
}
} catch (e) { console.error('[AI Diff] 处理 pending hook 失败:', e); }
+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 按钮正常生效