diff --git a/.claude/settings.json b/.claude/settings.json index 640414f..28df53a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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 + } + ] + } ] } } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1020c59 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 5d8ac44..7184a39 100644 --- a/README.md +++ b/README.md @@ -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 / 接受全部** 按钮。 diff --git a/src/extension.ts b/src/extension.ts index 3127d4b..1ab9ae0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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); }