mirror of
https://github.com/Gmaker689/ai-diff-preview.git
synced 2026-09-28 07:51:52 +08:00
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:
@@ -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
@@ -12,7 +12,7 @@
|
||||
"Programming Languages",
|
||||
"Other"
|
||||
],
|
||||
"activationEvents": [],
|
||||
"activationEvents": ["onStartupFinished"],
|
||||
"main": "./dist/extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
+99
-160
@@ -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;
|
||||
|
||||
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 handleDocumentChange(event: vscode.TextDocumentChangeEvent) {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document !== event.document) return;
|
||||
/**
|
||||
* 检查激活时是否已有 pending 触发文件
|
||||
*/
|
||||
function checkPendingHookFile() {
|
||||
const hookDir = vscode.workspace.workspaceFolders?.[0]
|
||||
? path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, '.claude', 'hooks')
|
||||
: null;
|
||||
if (!hookDir) return;
|
||||
|
||||
const snapshot = snapshotManager.getSnapshotByFilePath(event.document.uri.fsPath);
|
||||
if (!snapshot) return;
|
||||
const pendingFile = path.join(hookDir, 'pending.json');
|
||||
if (!fs.existsSync(pendingFile)) return;
|
||||
|
||||
// 检测用户手动修改是否与 AI 建议冲突
|
||||
diffEngine.detectConflicts(snapshot, event.document.getText());
|
||||
refreshUI(editor);
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(pendingFile, 'utf-8');
|
||||
fs.unlinkSync(pendingFile);
|
||||
const event: ClaudeHookEvent = JSON.parse(content);
|
||||
|
||||
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 (Date.now() - event.timestamp < 15000) {
|
||||
if (event.toolName === 'Stop') {
|
||||
hookHandler.handleStop();
|
||||
} else if (event.toolName === 'Edit' || event.toolName === 'Write') {
|
||||
hookHandler.handleEdit(event);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 检查是否所有块都已处理
|
||||
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);
|
||||
}
|
||||
} 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
@@ -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' };
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
</script>
|
||||
<style>.collapsed .diff-body{display:none;}</style>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
+201
-105
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用 AI 生成的 Patch
|
||||
*/
|
||||
applyPatch(snapshotId: string, patch: string, chunks: DiffChunk[]): void {
|
||||
const snapshot = this.snapshots.get(snapshotId);
|
||||
if (!snapshot) {
|
||||
throw new Error(`快照不存在: ${snapshotId}`);
|
||||
getCurrentSet(): ChangeSet | null {
|
||||
return this.currentSet;
|
||||
}
|
||||
|
||||
snapshot.suggestContent = patch;
|
||||
snapshot.chunks = chunks;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取快照
|
||||
* 记录一次文件编辑
|
||||
*
|
||||
* Edit: oldString=修改前的代码片段, newString=修改后的代码片段
|
||||
* → 文件当前内容 = 原始内容中 oldString 被替换为 newString 后的结果
|
||||
* → 原始内容 = 当前内容中首次出现的 newString 替换回 oldString
|
||||
*
|
||||
* Write: content=写入的完整内容
|
||||
* → 原始内容无法获取(已覆写),标记为 create
|
||||
*/
|
||||
getSnapshot(snapshotId: string): Snapshot | undefined {
|
||||
return this.snapshots.get(snapshotId);
|
||||
recordEdit(filePath: string, toolName: string, oldString: string, newString: string, _content: string): void {
|
||||
if (!this.currentSet) {
|
||||
this.startNewSet();
|
||||
}
|
||||
|
||||
filePath = norm(filePath);
|
||||
console.log(`[AI Diff] recordEdit: ${toolName} → ${filePath}`);
|
||||
|
||||
// 首次编辑该文件时,捕获原始内容
|
||||
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 写入磁盘)
|
||||
*/
|
||||
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;
|
||||
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 写回磁盘,恢复原始状态)
|
||||
*/
|
||||
updateAllChunksStatus(snapshotId: string, status: ChunkStatus): void {
|
||||
const snapshot = this.snapshots.get(snapshotId);
|
||||
if (!snapshot) return;
|
||||
|
||||
snapshot.chunks.forEach(chunk => {
|
||||
chunk.status = status;
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记快照完成
|
||||
*/
|
||||
completeSnapshot(snapshotId: string): void {
|
||||
const snapshot = this.snapshots.get(snapshotId);
|
||||
if (!snapshot) return;
|
||||
|
||||
snapshot.status = 'completed';
|
||||
this.filePathIndex.delete(snapshot.filePath);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消快照
|
||||
*/
|
||||
cancelSnapshot(snapshotId: string): void {
|
||||
const snapshot = this.snapshots.get(snapshotId);
|
||||
if (!snapshot) return;
|
||||
|
||||
snapshot.status = 'cancelled';
|
||||
this.filePathIndex.delete(snapshot.filePath);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除快照
|
||||
*/
|
||||
deleteSnapshot(snapshotId: string): void {
|
||||
const snapshot = this.snapshots.get(snapshotId);
|
||||
if (snapshot) {
|
||||
this.filePathIndex.delete(snapshot.filePath);
|
||||
}
|
||||
this.snapshots.delete(snapshotId);
|
||||
clear(): void {
|
||||
this.currentSet = null;
|
||||
this.originalContents.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有活跃快照
|
||||
*/
|
||||
getActiveSnapshots(): Snapshot[] {
|
||||
return Array.from(this.snapshots.values()).filter(s => s.status === 'active');
|
||||
hasPendingChanges(): boolean {
|
||||
return !!this.currentSet &&
|
||||
this.currentSet.status === 'ready' &&
|
||||
this.currentSet.changes.some(c => c.status === 'pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否有活跃快照
|
||||
*/
|
||||
hasActiveSnapshot(filePath: string): boolean {
|
||||
const snapshotId = this.filePathIndex.get(filePath);
|
||||
if (!snapshotId) return false;
|
||||
const snapshot = this.snapshots.get(snapshotId);
|
||||
return snapshot?.status === 'active';
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理已完成或取消的快照
|
||||
*/
|
||||
cleanup(): void {
|
||||
for (const [id, snapshot] of this.snapshots.entries()) {
|
||||
if (snapshot.status !== 'active') {
|
||||
this.filePathIndex.delete(snapshot.filePath);
|
||||
this.snapshots.delete(id);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-107
@@ -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('文件已有活跃快照,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 读取当前文件内容作为 Base
|
||||
const baseContent = await this.readFileContent(event.filePath);
|
||||
if (!baseContent) {
|
||||
console.error('无法读取文件内容');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 创建 Base 快照
|
||||
const snapshotId = this.snapshotManager.createBaseSnapshot(
|
||||
this.changeSetManager.recordEdit(
|
||||
event.filePath,
|
||||
baseContent
|
||||
event.toolName,
|
||||
event.oldString || '',
|
||||
event.newString || '',
|
||||
event.content || ''
|
||||
);
|
||||
|
||||
// 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);
|
||||
/**
|
||||
* 处理 Stop 事件(对话结束)
|
||||
* 弹出通知,提示用户查看变更
|
||||
*/
|
||||
handleStop(): void {
|
||||
const changeSet = this.changeSetManager.markReady();
|
||||
if (!changeSet) {
|
||||
console.log('[AI Diff] 对话结束,无文件变更');
|
||||
return;
|
||||
}
|
||||
|
||||
const count = changeSet.changes.length;
|
||||
console.log(`[AI Diff] 对话结束,${count} 个文件有变更`);
|
||||
|
||||
// 弹出通知,带"查看更改"按钮
|
||||
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 监听器已注册');
|
||||
`AI Diff: ${count} 个文件已修改`,
|
||||
'查看更改'
|
||||
).then(selection => {
|
||||
if (selection === '查看更改') {
|
||||
vscode.commands.executeCommand('aiDiffPreview.showDiffPanel');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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 + 文件回写
|
||||
Reference in New Issue
Block a user