refactor: 重构为 Webview Review 面板架构

- 移除旧的 DiffEngine、InlineDecorator、CodeLensProvider 等模块
- 新增 ChangeSetManager 统一管理代码变更快照
- 新增 reviewPanel.ts 实现双栏 Review Webview 面板
- 简化 hookHandler.ts,直接通过 ChangeSet 收集变更
- 简化 extension.ts 入口,移除冗余模块导入
- 更新 types.ts,精简 ChangeSet 和 ChangeEntry 类型定义
- package.json 添加 onStartupFinished 激活事件
This commit is contained in:
2026-06-18 00:09:56 +08:00
parent 3168e81aa6
commit a7c992a804
12 changed files with 897 additions and 468 deletions
+27
View File
@@ -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
}
]
}
]
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
"Programming Languages",
"Other"
],
"activationEvents": [],
"activationEvents": ["onStartupFinished"],
"main": "./dist/extension.js",
"contributes": {
"commands": [
+33
View File
@@ -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
+78
View File
@@ -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);
});
+19
View File
@@ -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
+101 -162
View File
@@ -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();
}
+56 -68
View File
@@ -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<string, unknown>;
}
/**
* 变更块类型
*/
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' };
+250
View File
@@ -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*/`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:var(--vscode-font-family),sans-serif; font-size:13px; color:var(--vscode-foreground); background:var(--vscode-editor-background); }
.toolbar {
display:flex; align-items:center; justify-content:space-between;
padding:10px 16px; background:var(--vscode-titleBar-activeBackground);
border-bottom:1px solid var(--vscode-panel-border); position:sticky; top:0; z-index:10;
}
.toolbar h2 { font-size:14px; font-weight:600; }
.toolbar .actions { display:flex; gap:8px; }
.btn {
padding:6px 16px; border:none; border-radius:4px; cursor:pointer;
font-size:12px; font-weight:500; transition:opacity .15s;
}
.btn:hover { opacity:.85; }
.btn-accept { background:#2ea043; color:#fff; }
.btn-reject { background:#da3633; color:#fff; }
.files { padding:8px 0; }
.file-card {
margin:6px 12px; border:1px solid var(--vscode-panel-border);
border-radius:6px; overflow:hidden;
}
.file-header {
display:flex; align-items:center; justify-content:space-between;
padding:8px 12px; background:var(--vscode-sideBar-background);
border-bottom:1px solid var(--vscode-panel-border); cursor:pointer;
}
.file-path { font-weight:500; display:flex; align-items:center; gap:8px; }
.badge {
font-size:10px; padding:2px 6px; border-radius:3px; font-weight:600;
}
.badge-modify { background:rgba(210,153,34,.2); color:#d29922; }
.badge-create { background:rgba(46,160,67,.2); color:#3fb950; }
.badge-delete { background:rgba(248,81,73,.2); color:#f85149; }
.badge-accepted { background:rgba(46,160,67,.3); color:#3fb950; }
.badge-rejected { background:rgba(248,81,73,.3); color:#f85149; text-decoration:line-through; }
.file-actions { display:flex; gap:6px; }
.btn-sm { padding:3px 10px; border:none; border-radius:3px; cursor:pointer; font-size:11px; }
.diff-wrap { display:grid; grid-template-columns:1fr 1fr; font-family:'Cascadia Code','Fira Code',Consolas,monospace; font-size:12px; line-height:20px; }
.diff-side { overflow-x:auto; }
.diff-side.left { border-right:1px solid var(--vscode-panel-border); }
.diff-title {
padding:4px 12px; font-size:11px; font-weight:600;
color:var(--vscode-descriptionForeground); border-bottom:1px solid var(--vscode-panel-border);
}
.diff-side.left .diff-title { background:rgba(248,81,73,.06); }
.diff-side.right .diff-title { background:rgba(46,160,67,.06); }
.diff-line { display:flex; min-height:20px; }
.ln { min-width:36px; padding:0 8px; text-align:right; color:var(--vscode-editorLineNumber-foreground); user-select:none; flex-shrink:0; }
.lc { padding:0 10px; white-space:pre; flex:1; }
.diff-line.add { background:rgba(46,160,67,.12); }
.diff-line.del { background:rgba(248,81,73,.12); }
.diff-line.ctx { opacity:.55; }
.diff-side.left .diff-line.add { background:transparent; }
.diff-side.right .diff-line.del { background:transparent; }
.empty-line { min-height:20px; }
</style>
</head>
<body>
<div class="toolbar">
<h2>🔍 AI Diff Review</h2>
<div class="actions">
<button class="btn btn-accept" onclick="acceptAll()">✓ Accept All</button>
<button class="btn btn-reject" onclick="rejectAll()">✗ Reject All</button>
</div>
</div>
<div class="files" id="files"></div>
<script>
const vscode = acquireVsCodeApi();
let cs = null;
vscode.postMessage({type:'ready'});
window.addEventListener('message', e => {
const m = e.data;
if (m.type === 'init') { cs = m.changeSet; render(); }
});
function acceptAll(){ vscode.postMessage({type:'acceptAll'}); }
function rejectAll(){ vscode.postMessage({type:'rejectAll'}); }
function acceptFile(id){ vscode.postMessage({type:'acceptFile',fileId:id}); }
function rejectFile(id){ vscode.postMessage({type:'rejectFile',fileId:id}); }
function render(){
if(!cs) return;
const el = document.getElementById('files');
el.innerHTML = '';
cs.changes.forEach(c => el.appendChild(card(c)));
}
function card(c){
const d = document.createElement('div');
d.className = 'file-card';
const name = c.filePath.split(/[/\\\\]/).pop();
const typeMap = {modify:'修改',create:'新增',delete:'删除'};
const badgeCls = 'badge badge-' + c.type;
let statusBadge = '';
if(c.status==='accepted') statusBadge = '<span class="badge badge-accepted">已接受</span>';
if(c.status==='rejected') statusBadge = '<span class="badge badge-rejected">已拒绝</span>';
d.innerHTML =
'<div class="file-header" onclick="this.nextElementSibling.classList.toggle(' + "'" + 'collapsed' + "'" + ')">' +
'<span class="file-path">📄 ' + esc(name) + ' <span class="' + badgeCls + '">' + typeMap[c.type] + '</span>' + statusBadge + '</span>' +
(c.status==='pending' ?
'<span class="file-actions">' +
'<button class="btn-sm btn-accept" onclick="event.stopPropagation();acceptFile(' + "'" + c.id + "'" + ')">✓</button>' +
'<button class="btn-sm btn-reject" onclick="event.stopPropagation();rejectFile(' + "'" + c.id + "'" + ')">✗</button>' +
'</span>' : ''
) +
'</div>' +
'<div class="diff-body">' + buildDiff(c) + '</div>';
return d;
}
function buildDiff(c){
if(c.type==='create') return buildSingle(c.newLines||[],'right','新文件');
if(c.type==='delete') return buildSingle(c.oldLines||[],'left','已删除');
return buildDual(c.diffLines||[]);
}
function buildSingle(lines,side,title){
let h = '<div class="diff-wrap"><div class="diff-side ' + side + '"><div class="diff-title">' + title + '</div>';
lines.forEach(l => {
h += '<div class="diff-line add"><span class="ln">' + (l.num||'') + '</span><span class="lc">' + esc(l.content||l) + '</span></div>';
});
return h + '</div></div>';
}
function buildDual(dlines){
let lh='', rh='';
dlines.forEach(l => {
if(l.type==='context'){
lh += '<div class="diff-line ctx"><span class="ln">'+l.oldLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>';
rh += '<div class="diff-line ctx"><span class="ln">'+l.newLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>';
} else if(l.type==='delete'){
lh += '<div class="diff-line del"><span class="ln">'+l.oldLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>';
rh += '<div class="diff-line empty-line"></div>';
} else if(l.type==='add'){
lh += '<div class="diff-line empty-line"></div>';
rh += '<div class="diff-line add"><span class="ln">'+l.newLineNum+'</span><span class="lc">'+esc(l.content)+'</span></div>';
}
});
return '<div class="diff-wrap">' +
'<div class="diff-side left"><div class="diff-title">原始代码</div>'+lh+'</div>' +
'<div class="diff-side right"><div class="diff-title">变更后</div>'+rh+'</div>' +
'</div>';
}
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
</script>
<style>.collapsed .diff-body{display:none;}</style>
</body>
</html>`;
}
}
+223 -127
View File
@@ -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<string, Snapshot> = new Map();
private filePathIndex: Map<string, string> = 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<string, string> = 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;
}
}
}
+41 -109
View File
@@ -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<void> {
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<void> {
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<string | null> {
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 监听器已注册');
});
}
}
+2 -1
View File
@@ -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
+66
View File
@@ -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 + 文件回写