diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..640414f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,27 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|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||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{}})\"", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "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:'stop',filePath:'',content:'',oldString:'',newString:'',timestamp:Date.now(),toolName:'Stop'}));fs.renameSync(tmp,t)}catch{}\"", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/package.json b/package.json index 7cf9d8c..10d77e8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "Programming Languages", "Other" ], - "activationEvents": [], + "activationEvents": ["onStartupFinished"], "main": "./dist/extension.js", "contributes": { "commands": [ diff --git a/scripts/build-package.bat b/scripts/build-package.bat new file mode 100644 index 0000000..93d4655 --- /dev/null +++ b/scripts/build-package.bat @@ -0,0 +1,33 @@ +@echo off +echo ======================================== +echo AI Code Diff Preview - 构建打包脚本 +echo ======================================== + +echo. +echo [1/4] 清理旧文件... +if exist dist rmdir /s /q dist +if exist *.vsix del *.vsix + +echo. +echo [2/4] 安装依赖... +call npm install + +echo. +echo [3/4] 编译构建... +call npm run build + +echo. +echo [4/4] 打包 VSIX... +call npx vsce package + +echo. +echo ======================================== +echo 构建完成! +echo. +echo 生成的文件: +dir /b *.vsix +echo. +echo 安装方法: +echo code --install-extension ai-diff-preview-0.1.0.vsix +echo ======================================== +pause diff --git a/scripts/hook-trigger.js b/scripts/hook-trigger.js new file mode 100644 index 0000000..bc0425f --- /dev/null +++ b/scripts/hook-trigger.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Claude Code PostToolUse Hook 脚本 + * + * 当 Claude Code 执行 Write 或 Edit 工具后,此脚本被调用。 + * 它从 stdin 读取 JSON,将事件写入触发文件,供 VSCode 扩展监听。 + * + * stdin JSON 格式: + * { + * "session_id": "abc123", + * "tool_name": "Write" | "Edit", + * "tool_input": { "file_path": "...", "content": "..." }, + * "tool_response": { "success": true } + * } + */ + +const fs = require('fs'); +const path = require('path'); + +// 触发文件目录 - VSCode 扩展会监听此目录 +const TRIGGER_DIR = path.join(__dirname, '..', '.claude', 'hooks'); +const TRIGGER_FILE = path.join(TRIGGER_DIR, 'pending.json'); + +async function main() { + // 读取 stdin + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + const input = Buffer.concat(chunks).toString('utf-8'); + + let data; + try { + data = JSON.parse(input); + } catch (e) { + // 非 JSON 输入,忽略 + process.exit(0); + } + + const { tool_name, tool_input, tool_response } = data; + + // 只处理成功的 Write/Edit 操作 + if (!tool_response || tool_response.error) { + process.exit(0); + } + + const filePath = tool_input?.file_path || tool_response?.filePath; + if (!filePath) { + process.exit(0); + } + + // 构造事件数据 + const event = { + type: tool_name === 'Write' ? 'after_write_file' : 'after_code_edit', + filePath: filePath, + content: tool_input?.content || '', + patch: tool_input?.new_content || tool_input?.content || '', + oldString: tool_input?.old_string || '', + newString: tool_input?.new_string || '', + timestamp: Date.now(), + toolName: tool_name, + }; + + // 确保目录存在 + if (!fs.existsSync(TRIGGER_DIR)) { + fs.mkdirSync(TRIGGER_DIR, { recursive: true }); + } + + // 写入触发文件(原子写入:先写临时文件再重命名) + const tmpFile = TRIGGER_FILE + '.tmp'; + fs.writeFileSync(tmpFile, JSON.stringify(event, null, 2), 'utf-8'); + fs.renameSync(tmpFile, TRIGGER_FILE); +} + +main().catch(() => { + // 静默失败,不阻塞 Claude Code + process.exit(0); +}); diff --git a/scripts/install-local.bat b/scripts/install-local.bat new file mode 100644 index 0000000..01259cb --- /dev/null +++ b/scripts/install-local.bat @@ -0,0 +1,19 @@ +@echo off +echo ======================================== +echo AI Code Diff Preview - 本地安装脚本 +echo ======================================== + +echo. +echo [1/2] 构建插件... +call npm run build + +echo. +echo [2/2] 安装到 VSCode... +call code --install-extension . + +echo. +echo ======================================== +echo 安装完成! +echo 请重启 VSCode 或重新加载窗口 (Ctrl+Shift+P -> Reload Window) +echo ======================================== +pause diff --git a/src/extension.ts b/src/extension.ts index 6a50ca6..7283ad6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,199 +1,138 @@ /** * AI Code Diff Preview - 插件入口 + * + * 工作流程: + * 1. PostToolUse (Edit/Write) → 收集变更到 ChangeSet + * 2. Stop (对话结束) → 弹出通知 + * 3. 用户点击"查看更改" → 打开 Review 双栏面板 + * 4. 用户 Accept/Reject → 应用或丢弃变更 */ import * as vscode from 'vscode'; -import { SnapshotManager } from './snapshot/snapshotManager'; -import { DiffEngine } from './diff/diffEngine'; -import { InlineDecorator } from './render/inlineDecorator'; -import { CodeLensProvider } from './render/codeLensProvider'; +import * as path from 'path'; +import * as fs from 'fs'; +import { ChangeSetManager } from './snapshot/snapshotManager'; import { HookHandler } from './trigger/hookHandler'; -import { AcceptHandler } from './transaction/acceptHandler'; -import { RejectHandler } from './transaction/rejectHandler'; -import { StatusBarManager } from './render/statusBar'; -import { COMMANDS, CONTEXT_KEYS } from './models/constants'; +import { ReviewPanel } from './render/reviewPanel'; +import { COMMANDS } from './models/constants'; +import { ClaudeHookEvent } from './models/types'; -let snapshotManager: SnapshotManager; -let diffEngine: DiffEngine; -let inlineDecorator: InlineDecorator; -let codeLensProvider: CodeLensProvider; +let changeSetManager: ChangeSetManager; let hookHandler: HookHandler; -let acceptHandler: AcceptHandler; -let rejectHandler: RejectHandler; -let statusBarManager: StatusBarManager; +let reviewPanel: ReviewPanel; export function activate(context: vscode.ExtensionContext) { - console.log('AI Code Diff Preview 插件已激活'); + console.log('[AI Diff] 插件已激活'); // 初始化核心模块 - snapshotManager = new SnapshotManager(); - diffEngine = new DiffEngine(); - inlineDecorator = new InlineDecorator(); - codeLensProvider = new CodeLensProvider(snapshotManager); - statusBarManager = new StatusBarManager(); - - // 初始化处理器 - acceptHandler = new AcceptHandler(snapshotManager, diffEngine, inlineDecorator); - rejectHandler = new RejectHandler(snapshotManager, inlineDecorator); - hookHandler = new HookHandler(snapshotManager, diffEngine, inlineDecorator, statusBarManager); + changeSetManager = new ChangeSetManager(); + hookHandler = new HookHandler(changeSetManager); + reviewPanel = new ReviewPanel(changeSetManager); // 注册命令 registerCommands(context); - // 注册 CodeLens 提供器 - context.subscriptions.push( - vscode.languages.registerCodeLensProvider('*', codeLensProvider) - ); + // 注册 Hook 触发文件监听 + registerHookFileWatcher(context); - // 注册文档变更监听 - context.subscriptions.push( - vscode.workspace.onDidChangeTextDocument(event => { - handleDocumentChange(event); - }) - ); - - // 注册编辑器切换监听 - context.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor(editor => { - if (editor) { - handleEditorSwitch(editor); - } - }) - ); - - // 设置初始上下文 - vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, false); + // 检查激活时是否已有 pending 触发文件 + checkPendingHookFile(); } function registerCommands(context: vscode.ExtensionContext) { - // Accept 当前块 - context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.ACCEPT_CHUNK, async () => { - const editor = vscode.window.activeTextEditor; - if (!editor) return; - - const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath); - if (!snapshot) return; - - const chunk = getChunkAtCursor(editor, snapshot.chunks); - if (chunk) { - await acceptHandler.acceptChunk(snapshot.id, chunk.id); - refreshUI(editor); - } - }) - ); - - // Reject 当前块 - context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.REJECT_CHUNK, async () => { - const editor = vscode.window.activeTextEditor; - if (!editor) return; - - const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath); - if (!snapshot) return; - - const chunk = getChunkAtCursor(editor, snapshot.chunks); - if (chunk) { - await rejectHandler.rejectChunk(snapshot.id, chunk.id); - refreshUI(editor); - } - }) - ); - - // Accept All - context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.ACCEPT_ALL, async () => { - const editor = vscode.window.activeTextEditor; - if (!editor) return; - - const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath); - if (!snapshot) return; - - await acceptHandler.acceptAll(snapshot.id); - refreshUI(editor); - }) - ); - - // Reject All - context.subscriptions.push( - vscode.commands.registerCommand(COMMANDS.REJECT_ALL, async () => { - const editor = vscode.window.activeTextEditor; - if (!editor) return; - - const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath); - if (!snapshot) return; - - await rejectHandler.rejectAll(snapshot.id); - refreshUI(editor); - }) - ); - - // 显示 Diff 面板 + // 显示 Review 面板 context.subscriptions.push( vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => { - // TODO: 实现 Diff 面板 - vscode.window.showInformationMessage('Diff 面板功能开发中...'); + reviewPanel.show(); }) ); } -function getChunkAtCursor( - editor: vscode.TextEditor, - chunks: import('./models/types').DiffChunk[] -): import('./models/types').DiffChunk | undefined { - const cursorLine = editor.selection.active.line; - return chunks.find( - chunk => chunk.status === 'pending' && - cursorLine >= chunk.startLine && - cursorLine <= chunk.endLine - ); -} +/** + * 注册 Hook 触发文件监听器 + * + * 监听 .claude/hooks/pending.json: + * - toolName: Edit/Write → 收集变更 + * - toolName: Stop → 弹出 Review 通知 + */ +function registerHookFileWatcher(context: vscode.ExtensionContext) { + const hookDir = vscode.workspace.workspaceFolders?.[0] + ? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks') + : null; -function handleDocumentChange(event: vscode.TextDocumentChangeEvent) { - const editor = vscode.window.activeTextEditor; - if (!editor || editor.document !== event.document) return; - - const snapshot = snapshotManager.getSnapshotByFilePath(event.document.uri.fsPath); - if (!snapshot) return; - - // 检测用户手动修改是否与 AI 建议冲突 - diffEngine.detectConflicts(snapshot, event.document.getText()); - refreshUI(editor); -} - -function handleEditorSwitch(editor: vscode.TextEditor) { - const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath); - if (snapshot) { - inlineDecorator.renderChunks(editor, snapshot.chunks); - statusBarManager.update(snapshot); - vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, true); - } else { - inlineDecorator.clearDecorations(editor); - statusBarManager.clear(); - vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, false); + if (!hookDir) { + console.log('[AI Diff] 无工作区目录,Hook 监听未启动'); + return; } + + if (!fs.existsSync(hookDir)) { + fs.mkdirSync(hookDir, { recursive: true }); + } + + const pendingFile = path.join(hookDir, 'pending.json'); + + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(hookDir, 'pending.json') + ); + + const handleTrigger = () => { + try { + if (!fs.existsSync(pendingFile)) return; + const content = fs.readFileSync(pendingFile, 'utf-8'); + fs.unlinkSync(pendingFile); + + const event: ClaudeHookEvent = JSON.parse(content); + + // 只处理最近 10 秒内的事件 + if (Date.now() - event.timestamp > 10000) return; + + if (event.toolName === 'Stop') { + hookHandler.handleStop(); + } else if (event.toolName === 'Edit' || event.toolName === 'Write') { + hookHandler.handleEdit(event); + } + } catch (e) { + console.error('[AI Diff] 处理触发文件失败:', e); + } + }; + + watcher.onDidChange(handleTrigger); + watcher.onDidCreate(handleTrigger); + context.subscriptions.push(watcher); + + console.log(`[AI Diff] Hook 文件监听已启动: ${hookDir}`); } -function refreshUI(editor: vscode.TextEditor) { - const snapshot = snapshotManager.getSnapshotByFilePath(editor.document.uri.fsPath); - if (snapshot) { - inlineDecorator.renderChunks(editor, snapshot.chunks); - codeLensProvider.refresh(); - statusBarManager.update(snapshot); +/** + * 检查激活时是否已有 pending 触发文件 + */ +function checkPendingHookFile() { + const hookDir = vscode.workspace.workspaceFolders?.[0] + ? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks') + : null; + if (!hookDir) return; - // 检查是否所有块都已处理 - const allProcessed = snapshot.chunks.every(c => c.status !== 'pending'); - if (allProcessed) { - snapshotManager.completeSnapshot(snapshot.id); - inlineDecorator.clearDecorations(editor); - statusBarManager.clear(); - vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, false); + const pendingFile = path.join(hookDir, 'pending.json'); + if (!fs.existsSync(pendingFile)) return; + + try { + const content = fs.readFileSync(pendingFile, 'utf-8'); + 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); + } } + } catch (e) { + console.error('[AI Diff] 处理 pending hook 失败:', e); } } export function deactivate() { - console.log('AI Code Diff Preview 插件已停用'); - inlineDecorator.dispose(); - statusBarManager.dispose(); + console.log('[AI Diff] 插件已停用'); + reviewPanel?.dispose(); } diff --git a/src/models/types.ts b/src/models/types.ts index 42e9783..041b44e 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -3,96 +3,84 @@ */ /** - * Claude Hook 事件类型 - */ -export type HookEventType = 'after_code_edit' | 'after_write_file'; - -/** - * Claude Hook 事件 + * Claude Hook 事件(从 pending.json 读取) */ export interface ClaudeHookEvent { - type: HookEventType; + type: string; filePath: string; - patch: string; - timestamp: number; - metadata?: Record; -} - -/** - * 变更块类型 - */ -export type ChunkType = 'add' | 'delete' | 'modify'; - -/** - * 变更块状态 - */ -export type ChunkStatus = 'pending' | 'accepted' | 'rejected' | 'conflict'; - -/** - * Diff 变更块 - */ -export interface DiffChunk { - id: string; - startLine: number; - endLine: number; - type: ChunkType; content: string; - status: ChunkStatus; - conflictWith?: string; // 冲突的变更块 ID + oldString: string; + newString: string; + timestamp: number; + toolName: string; // Edit | Write | Stop } /** - * 文件快照 + * 文件变更类型 */ -export interface Snapshot { +export type FileChangeType = 'create' | 'modify' | 'delete'; + +/** + * 文件变更状态 + */ +export type FileChangeStatus = 'pending' | 'accepted' | 'rejected'; + +/** + * 单个文件的变更 + */ +export interface FileChange { id: string; filePath: string; - baseContent: string; - suggestContent: string; + type: FileChangeType; + /** 修改前的内容(modify/delete 时有值) */ + oldContent: string; + /** 修改后的内容(modify/create 时有值) */ + newContent: string; + /** Diff 行 */ + diffLines: DiffLine[]; + status: FileChangeStatus; timestamp: number; - chunks: DiffChunk[]; - status: 'active' | 'completed' | 'cancelled'; } /** - * Diff 渲染选项 + * Diff 行类型 */ -export interface DiffRenderOptions { - showInlineButtons: boolean; - showLineNumbers: boolean; - contextLines: number; - syntaxHighlight: boolean; +export type DiffLineType = 'context' | 'add' | 'delete'; + +/** + * 单行 Diff + */ +export interface DiffLine { + type: DiffLineType; + oldLineNum: number; // 0 表示新增行无对应旧行 + newLineNum: number; // 0 表示删除行无对应新行 + content: string; } /** - * Accept/Reject 操作结果 + * 一轮对话的变更集(包含多个文件变更) */ -export interface TransactionResult { - success: boolean; - snapshotId: string; - chunkId?: string; - action: 'accept' | 'reject'; - error?: string; +export interface ChangeSet { + id: string; + changes: FileChange[]; + createdAt: number; + status: 'collecting' | 'ready' | 'reviewed'; } /** - * Diff 面板消息类型 + * Review 面板发送给 Webview 的消息 */ -export type DiffPanelMessageType = - | { type: 'accept'; chunkId: string } - | { type: 'reject'; chunkId: string } +export type ReviewPanelMessageToWebview = + | { type: 'init'; changeSet: ChangeSet } + | { type: 'updateFileStatus'; fileId: string; status: FileChangeStatus } + | { type: 'updateAllStatus'; status: FileChangeStatus }; + +/** + * Webview 发送给扩展的消息 + */ +export type ReviewPanelMessageFromWebview = + | { type: 'acceptFile'; fileId: string } + | { type: 'rejectFile'; fileId: string } | { type: 'acceptAll' } | { type: 'rejectAll' } - | { type: 'refresh' }; - -/** - * Diff 面板状态 - */ -export interface DiffPanelState { - snapshotId: string; - filePath: string; - chunks: DiffChunk[]; - totalChanges: number; - acceptedChanges: number; - rejectedChanges: number; -} + | { type: 'ready' }; diff --git a/src/render/reviewPanel.ts b/src/render/reviewPanel.ts new file mode 100644 index 0000000..e6409c9 --- /dev/null +++ b/src/render/reviewPanel.ts @@ -0,0 +1,250 @@ +/** + * Review 面板 - 内嵌 Webview 悬浮窗 + * + * 双栏 Diff + Accept/Reject 按钮,不弹系统窗口 + */ + +import * as vscode from 'vscode'; +import { ChangeSetManager } from '../snapshot/snapshotManager'; +import { ChangeSet, FileChange, FileChangeStatus } from '../models/types'; + +export class ReviewPanel { + private panel: vscode.WebviewPanel | undefined; + + constructor( + private changeSetManager: ChangeSetManager + ) {} + + show(): void { + const changeSet = this.changeSetManager.getCurrentSet(); + if (!changeSet || changeSet.changes.length === 0) { + return; + } + + if (this.panel) { + this.panel.reveal(vscode.ViewColumn.Active); + this.sendData(changeSet); + return; + } + + this.panel = vscode.window.createWebviewPanel( + 'aiDiffReview', + 'AI Diff Review', + { viewColumn: vscode.ViewColumn.Active, preserveFocus: false }, + { enableScripts: true, retainContextWhenHidden: true } + ); + + this.panel.webview.html = this.getHtml(); + + this.panel.webview.onDidReceiveMessage((msg: any) => { + switch (msg.type) { + case 'ready': + this.sendData(changeSet); + break; + case 'acceptFile': + this.changeSetManager.updateFileStatus(msg.fileId, 'accepted'); + this.changeSetManager.applyFileChange(msg.fileId); + this.refresh(); + break; + case 'rejectFile': + this.changeSetManager.updateFileStatus(msg.fileId, 'rejected'); + this.changeSetManager.rejectFileChange(msg.fileId); + this.refresh(); + break; + case 'acceptAll': + this.changeSetManager.updateAllStatus('accepted'); + this.changeSetManager.applyAllAccepted(); + this.refresh(); + break; + case 'rejectAll': + this.changeSetManager.updateAllStatus('rejected'); + this.changeSetManager.rejectAll(); + this.refresh(); + break; + } + }); + + this.panel.onDidDispose(() => { this.panel = undefined; }); + } + + private refresh(): void { + const changeSet = this.changeSetManager.getCurrentSet(); + if (changeSet && this.panel) { + this.sendData(changeSet); + } + } + + private sendData(changeSet: ChangeSet): void { + this.panel?.webview.postMessage({ type: 'init', changeSet }); + } + + dispose(): void { + this.panel?.dispose(); + this.panel = undefined; + } + + private getHtml(): string { + return /*html*/` + + + + + + +
+

🔍 AI Diff Review

+
+ + +
+
+
+ + + + +`; + } +} diff --git a/src/snapshot/snapshotManager.ts b/src/snapshot/snapshotManager.ts index ab78ad1..81e7b4e 100644 --- a/src/snapshot/snapshotManager.ts +++ b/src/snapshot/snapshotManager.ts @@ -1,148 +1,244 @@ /** - * 快照管理器 - 管理文件的 Base/Suggest 双版本 + * 变更集管理器 - 收集一轮对话中的所有文件变更 */ +import * as path from 'path'; +import * as fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; -import { Snapshot, DiffChunk, ChunkStatus } from '../models/types'; +import { ChangeSet, FileChange, FileChangeType, FileChangeStatus, DiffLine } from '../models/types'; +import { diffLines, Change } from 'diff'; -export class SnapshotManager { - private snapshots: Map = new Map(); - private filePathIndex: Map = new Map(); // filePath -> snapshotId +function norm(p: string): string { + return path.normalize(p).replace(/\\/g, '/'); +} - /** - * 创建 Base 快照 - */ - createBaseSnapshot(filePath: string, baseContent: string): string { +export class ChangeSetManager { + private currentSet: ChangeSet | null = null; + /** 原始文件内容快照(首次编辑前捕获) */ + private originalContents: Map = new Map(); + + startNewSet(): string { const id = uuidv4(); - const snapshot: Snapshot = { + this.currentSet = { id, - filePath, - baseContent, - suggestContent: '', - timestamp: Date.now(), - chunks: [], - status: 'active', + changes: [], + createdAt: Date.now(), + status: 'collecting', }; - - this.snapshots.set(id, snapshot); - this.filePathIndex.set(filePath, id); - + this.originalContents.clear(); + console.log(`[AI Diff] 新建变更集: ${id}`); return id; } + getCurrentSet(): ChangeSet | null { + return this.currentSet; + } + + markReady(): ChangeSet | null { + if (this.currentSet && this.currentSet.changes.length > 0) { + this.currentSet.status = 'ready'; + console.log(`[AI Diff] 变更集就绪: ${this.currentSet.changes.length} 个文件`); + return this.currentSet; + } + return null; + } + /** - * 应用 AI 生成的 Patch + * 记录一次文件编辑 + * + * Edit: oldString=修改前的代码片段, newString=修改后的代码片段 + * → 文件当前内容 = 原始内容中 oldString 被替换为 newString 后的结果 + * → 原始内容 = 当前内容中首次出现的 newString 替换回 oldString + * + * Write: content=写入的完整内容 + * → 原始内容无法获取(已覆写),标记为 create */ - applyPatch(snapshotId: string, patch: string, chunks: DiffChunk[]): void { - const snapshot = this.snapshots.get(snapshotId); - if (!snapshot) { - throw new Error(`快照不存在: ${snapshotId}`); + recordEdit(filePath: string, toolName: string, oldString: string, newString: string, _content: string): void { + if (!this.currentSet) { + this.startNewSet(); } - snapshot.suggestContent = patch; - snapshot.chunks = chunks; - } + filePath = norm(filePath); + console.log(`[AI Diff] recordEdit: ${toolName} → ${filePath}`); - /** - * 获取快照 - */ - getSnapshot(snapshotId: string): Snapshot | undefined { - return this.snapshots.get(snapshotId); - } - - /** - * 根据文件路径获取快照 - */ - getSnapshotByFilePath(filePath: string): Snapshot | undefined { - const snapshotId = this.filePathIndex.get(filePath); - if (!snapshotId) return undefined; - return this.snapshots.get(snapshotId); - } - - /** - * 更新变更块状态 - */ - updateChunkStatus(snapshotId: string, chunkId: string, status: ChunkStatus): void { - const snapshot = this.snapshots.get(snapshotId); - if (!snapshot) return; - - const chunk = snapshot.chunks.find(c => c.id === chunkId); - if (chunk) { - chunk.status = status; - } - } - - /** - * 批量更新变更块状态 - */ - updateAllChunksStatus(snapshotId: string, status: ChunkStatus): void { - const snapshot = this.snapshots.get(snapshotId); - if (!snapshot) return; - - snapshot.chunks.forEach(chunk => { - chunk.status = status; - }); - } - - /** - * 标记快照完成 - */ - completeSnapshot(snapshotId: string): void { - const snapshot = this.snapshots.get(snapshotId); - if (!snapshot) return; - - snapshot.status = 'completed'; - this.filePathIndex.delete(snapshot.filePath); - } - - /** - * 取消快照 - */ - cancelSnapshot(snapshotId: string): void { - const snapshot = this.snapshots.get(snapshotId); - if (!snapshot) return; - - snapshot.status = 'cancelled'; - this.filePathIndex.delete(snapshot.filePath); - } - - /** - * 删除快照 - */ - deleteSnapshot(snapshotId: string): void { - const snapshot = this.snapshots.get(snapshotId); - if (snapshot) { - this.filePathIndex.delete(snapshot.filePath); - } - this.snapshots.delete(snapshotId); - } - - /** - * 获取所有活跃快照 - */ - getActiveSnapshots(): Snapshot[] { - return Array.from(this.snapshots.values()).filter(s => s.status === 'active'); - } - - /** - * 检查文件是否有活跃快照 - */ - hasActiveSnapshot(filePath: string): boolean { - const snapshotId = this.filePathIndex.get(filePath); - if (!snapshotId) return false; - const snapshot = this.snapshots.get(snapshotId); - return snapshot?.status === 'active'; - } - - /** - * 清理已完成或取消的快照 - */ - cleanup(): void { - for (const [id, snapshot] of this.snapshots.entries()) { - if (snapshot.status !== 'active') { - this.filePathIndex.delete(snapshot.filePath); - this.snapshots.delete(id); + // 首次编辑该文件时,捕获原始内容 + if (!this.originalContents.has(filePath)) { + if (toolName === 'Write') { + // Write: 无法获取旧内容 + this.originalContents.set(filePath, ''); + } else if (toolName === 'Edit' && oldString) { + // Edit: 当前文件 = oldString 被替换为 newString 后的结果 + // 反推: 当前文件中找 newString 替换回 oldString = 原始内容 + const currentContent = this.readFile(filePath); + if (currentContent !== null) { + const idx = currentContent.indexOf(newString); + if (idx !== -1) { + const original = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length); + this.originalContents.set(filePath, original); + console.log(`[AI Diff] 捕获原始内容: ${filePath} (${original.length} 字符)`); + } else { + // newString 不在文件中,可能已经被之前的编辑影响 + // 用 oldString 作为最小化的原始内容 + this.originalContents.set(filePath, oldString); + console.log(`[AI Diff] newString 未找到,用 oldString 作为原始内容`); + } + } } } + + // 更新文件变更记录 + const existing = this.currentSet.changes.find(c => norm(c.filePath) === filePath); + const currentContent = this.readFile(filePath) || ''; + const originalContent = this.originalContents.get(filePath) || ''; + + if (existing) { + // 多次编辑同一文件:更新 newContent 和 diff + existing.newContent = currentContent; + existing.diffLines = this.computeDiffLines(originalContent, currentContent); + console.log(`[AI Diff] 更新变更记录: ${filePath}, diff=${existing.diffLines.length} 行`); + } else { + // 新建变更记录 + const changeType: FileChangeType = (toolName === 'Write' && !originalContent) + ? 'create' + : 'modify'; + + const fileChange: FileChange = { + id: uuidv4(), + filePath, + type: changeType, + oldContent: originalContent, + newContent: currentContent, + diffLines: this.computeDiffLines(originalContent, currentContent), + status: 'pending', + timestamp: Date.now(), + }; + + this.currentSet.changes.push(fileChange); + console.log(`[AI Diff] 新增变更记录: ${filePath}, type=${changeType}, diff=${fileChange.diffLines.length} 行`); + } + } + + updateFileStatus(fileId: string, status: FileChangeStatus): void { + if (!this.currentSet) return; + const change = this.currentSet.changes.find(c => c.id === fileId); + if (change) change.status = status; + } + + updateAllStatus(status: FileChangeStatus): void { + if (!this.currentSet) return; + this.currentSet.changes.forEach(c => { c.status = status; }); + } + + /** + * 接受文件变更(把 newContent 写入磁盘) + */ + applyFileChange(fileId: string): boolean { + if (!this.currentSet) return false; + const change = this.currentSet.changes.find(c => c.id === fileId); + if (!change || change.status !== 'accepted') return false; + try { + fs.writeFileSync(change.filePath, change.newContent, 'utf-8'); + console.log(`[AI Diff] 已接受: ${change.filePath}`); + return true; + } catch (e) { + console.error(`[AI Diff] 写入文件失败: ${change.filePath}`, e); + return false; + } + } + + /** + * 拒绝文件变更(把 oldContent 写回磁盘,恢复原始状态) + */ + rejectFileChange(fileId: string): boolean { + if (!this.currentSet) return false; + const change = this.currentSet.changes.find(c => c.id === fileId); + if (!change || change.status !== 'rejected') return false; + try { + if (change.type === 'create') { + // 新建的文件 → 删除 + if (fs.existsSync(change.filePath)) { + fs.unlinkSync(change.filePath); + } + } else { + // 修改/删除的文件 → 恢复原始内容 + fs.writeFileSync(change.filePath, change.oldContent, 'utf-8'); + } + console.log(`[AI Diff] 已拒绝: ${change.filePath}`); + return true; + } catch (e) { + console.error(`[AI Diff] 恢复文件失败: ${change.filePath}`, e); + return false; + } + } + + applyAllAccepted(): number { + if (!this.currentSet) return 0; + let count = 0; + for (const change of this.currentSet.changes) { + if (change.status === 'accepted' && this.applyFileChange(change.id)) { + count++; + } + } + return count; + } + + rejectAll(): number { + if (!this.currentSet) return 0; + let count = 0; + for (const change of this.currentSet.changes) { + if (change.status === 'rejected' && this.rejectFileChange(change.id)) { + count++; + } + } + return count; + } + + clear(): void { + this.currentSet = null; + this.originalContents.clear(); + } + + hasPendingChanges(): boolean { + return !!this.currentSet && + this.currentSet.status === 'ready' && + this.currentSet.changes.some(c => c.status === 'pending'); + } + + private computeDiffLines(oldContent: string, newContent: string): DiffLine[] { + const changes: Change[] = diffLines(oldContent, newContent); + const lines: DiffLine[] = []; + let oldLine = 1; + let newLine = 1; + + for (const change of changes) { + const changeLines = change.value.split('\n'); + if (changeLines[changeLines.length - 1] === '') { + changeLines.pop(); + } + + for (const line of changeLines) { + if (change.added) { + lines.push({ type: 'add', oldLineNum: 0, newLineNum: newLine, content: line }); + newLine++; + } else if (change.removed) { + lines.push({ type: 'delete', oldLineNum: oldLine, newLineNum: 0, content: line }); + oldLine++; + } else { + lines.push({ type: 'context', oldLineNum: oldLine, newLineNum: newLine, content: line }); + oldLine++; + newLine++; + } + } + } + + return lines; + } + + private readFile(filePath: string): string | null { + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return null; + } } } diff --git a/src/trigger/hookHandler.ts b/src/trigger/hookHandler.ts index 9843482..7a643ff 100644 --- a/src/trigger/hookHandler.ts +++ b/src/trigger/hookHandler.ts @@ -1,127 +1,59 @@ /** - * Claude Hook 处理器 - 处理 after_code_edit 和 after_write_file 事件 + * Hook 处理器 + * + * PostToolUse (Edit/Write) → 收集变更,不显示 UI + * Stop → 对话结束,弹出 Review 通知 */ import * as vscode from 'vscode'; -import * as fs from 'fs'; -import { SnapshotManager } from '../snapshot/snapshotManager'; -import { DiffEngine } from '../diff/diffEngine'; -import { InlineDecorator } from '../render/inlineDecorator'; -import { StatusBarManager } from '../render/statusBar'; -import { ClaudeHookEvent, HookEventType } from '../models/types'; -import { CONTEXT_KEYS } from '../models/constants'; +import * as path from 'path'; +import { ChangeSetManager } from '../snapshot/snapshotManager'; +import { ClaudeHookEvent } from '../models/types'; export class HookHandler { constructor( - private snapshotManager: SnapshotManager, - private diffEngine: DiffEngine, - private inlineDecorator: InlineDecorator, - private statusBarManager: StatusBarManager + private changeSetManager: ChangeSetManager ) {} /** - * 处理 Claude Hook 事件 + * 处理 PostToolUse 事件(Edit/Write) + * 只收集变更,不弹 UI */ - async handleHook(event: ClaudeHookEvent): Promise { - console.log(`收到 Hook 事件: ${event.type}, 文件: ${event.filePath}`); + handleEdit(event: ClaudeHookEvent): void { + event.filePath = path.normalize(event.filePath); + console.log(`[AI Diff] 收集变更: ${event.toolName} → ${event.filePath}`); - // 检查文件是否有活跃快照 - if (this.snapshotManager.hasActiveSnapshot(event.filePath)) { - console.log('文件已有活跃快照,跳过'); + this.changeSetManager.recordEdit( + event.filePath, + event.toolName, + event.oldString || '', + event.newString || '', + event.content || '' + ); + } + + /** + * 处理 Stop 事件(对话结束) + * 弹出通知,提示用户查看变更 + */ + handleStop(): void { + const changeSet = this.changeSetManager.markReady(); + if (!changeSet) { + console.log('[AI Diff] 对话结束,无文件变更'); return; } - try { - // 1. 读取当前文件内容作为 Base - const baseContent = await this.readFileContent(event.filePath); - if (!baseContent) { - console.error('无法读取文件内容'); - return; + const count = changeSet.changes.length; + console.log(`[AI Diff] 对话结束,${count} 个文件有变更`); + + // 弹出通知,带"查看更改"按钮 + vscode.window.showInformationMessage( + `AI Diff: ${count} 个文件已修改`, + '查看更改' + ).then(selection => { + if (selection === '查看更改') { + vscode.commands.executeCommand('aiDiffPreview.showDiffPanel'); } - - // 2. 创建 Base 快照 - const snapshotId = this.snapshotManager.createBaseSnapshot( - event.filePath, - baseContent - ); - - // 3. 计算 Diff 块 - const chunks = this.diffEngine.computeChunks(baseContent, event.patch); - - // 4. 应用 Patch 到快照 - this.snapshotManager.applyPatch(snapshotId, event.patch, chunks); - - // 5. 渲染 Diff UI - await this.renderDiffUI(event.filePath, snapshotId); - - // 6. 更新状态栏 - const snapshot = this.snapshotManager.getSnapshot(snapshotId); - if (snapshot) { - this.statusBarManager.update(snapshot); - } - - // 7. 设置上下文 - vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, true); - - vscode.window.showInformationMessage( - `AI Diff Preview: 检测到 ${chunks.length} 个变更块` - ); - } catch (error) { - console.error('处理 Hook 事件失败:', error); - vscode.window.showErrorMessage(`AI Diff Preview 处理失败: ${error}`); - } - } - - /** - * 渲染 Diff UI - */ - private async renderDiffUI(filePath: string, snapshotId: string): Promise { - const snapshot = this.snapshotManager.getSnapshot(snapshotId); - if (!snapshot) return; - - // 查找对应的编辑器 - const editor = vscode.window.visibleTextEditors.find( - e => e.document.uri.fsPath === filePath - ); - - if (editor) { - this.inlineDecorator.renderChunks(editor, snapshot.chunks); - } else { - // 如果文件未打开,尝试打开 - const uri = vscode.Uri.file(filePath); - const doc = await vscode.workspace.openTextDocument(uri); - const newEditor = await vscode.window.showTextDocument(doc); - this.inlineDecorator.renderChunks(newEditor, snapshot.chunks); - } - } - - /** - * 读取文件内容 - */ - private async readFileContent(filePath: string): Promise { - try { - return fs.readFileSync(filePath, 'utf-8'); - } catch (error) { - console.error('读取文件失败:', error); - return null; - } - } - - /** - * 注册 Hook 监听器 - * - * 注意:实际的 Hook 注册需要在 Claude 的配置中完成 - * 这个方法提供了一个 HTTP 端点来接收 Hook 调用 - */ - registerHookListener(): void { - // 方案 1: 通过 HTTP 端点接收 Hook - // 需要在 Claude 配置中设置 Hook URL - - // 方案 2: 通过文件监听接收 Hook - // Claude Hook 可以将事件写入临时文件,插件监听该文件 - - // 方案 3: 通过 VSCode 命令接收 - // Claude Hook 可以调用 VSCode 命令 - console.log('Hook 监听器已注册'); + }); } } diff --git a/tsconfig.json b/tsconfig.json index 263f4f8..25b8d9a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,12 +4,13 @@ "target": "ES2022", "outDir": "dist", "rootDir": "src", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "sourceMap": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, + "types": ["node"], "resolveJsonModule": true, "declaration": true, "declarationMap": true diff --git a/测试.md b/测试.md new file mode 100644 index 0000000..e7483e2 --- /dev/null +++ b/测试.md @@ -0,0 +1,66 @@ +# 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 + 文件回写