feat: 初始化 AI Code Diff Preview 插件项目

- 核心架构搭建:四层架构设计(触发层、隔离层、Diff引擎、UI层)
- 快照管理器:管理 Base/Suggest 双版本,隔离 AI 生成代码
- Diff 引擎:基于 Myers 算法计算差异,生成变更块
- 内联装饰器:红绿高亮显示删除/新增行
- CodeLens 提供器:提供 Accept/Reject 按钮
- 事务处理:Accept/Reject 原子化操作
- Claude Hooks 集成:支持 after_code_edit/after_write_file 事件
- 状态栏显示:实时显示变更状态
- 基础测试用例:7 个测试全部通过

技术栈:TypeScript + VSCode Extension API + diff 库 + esbuild
This commit is contained in:
2026-06-17 21:11:33 +08:00
commit 3168e81aa6
21 changed files with 6912 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
/**
* Diff 引擎测试
*/
import { describe, it, expect } from 'vitest';
import { DiffEngine } from '../diff/diffEngine';
describe('DiffEngine', () => {
const engine = new DiffEngine();
describe('computeChunks', () => {
it('应该检测新增行', () => {
const base = 'line1\nline2\nline3';
const suggest = 'line1\nline2\nline3\nline4';
const chunks = engine.computeChunks(base, suggest);
// diffLines 可能产生多个变更块(取决于行尾处理)
const addChunks = chunks.filter(c => c.type === 'add');
expect(addChunks.length).toBeGreaterThanOrEqual(1);
expect(addChunks.some(c => c.content.includes('line4'))).toBe(true);
});
it('应该检测删除行', () => {
const base = 'line1\nline2\nline3';
const suggest = 'line1\nline3';
const chunks = engine.computeChunks(base, suggest);
const deleteChunks = chunks.filter(c => c.type === 'delete');
expect(deleteChunks.length).toBeGreaterThanOrEqual(1);
expect(deleteChunks.some(c => c.content.includes('line2'))).toBe(true);
});
it('应该检测修改行', () => {
const base = 'line1\nline2\nline3';
const suggest = 'line1\nmodified\nline3';
const chunks = engine.computeChunks(base, suggest);
expect(chunks.length).toBeGreaterThan(0);
const hasModifyOrDelete = chunks.some(c => c.type === 'delete' || c.type === 'modify');
expect(hasModifyOrDelete).toBe(true);
});
it('应该处理空文本', () => {
const chunks = engine.computeChunks('', '');
expect(chunks).toHaveLength(0);
});
it('应该处理完全不同的文本', () => {
const base = 'old content';
const suggest = 'new content';
const chunks = engine.computeChunks(base, suggest);
expect(chunks.length).toBeGreaterThan(0);
});
});
describe('applyChunk', () => {
it('应该正确应用新增块', () => {
const base = 'line1\nline2\nline3';
const chunk = {
id: 'test',
startLine: 1,
endLine: 2,
type: 'add' as const,
content: 'new line\n',
status: 'pending' as const,
};
const result = engine.applyChunk(base, chunk);
expect(result).toContain('new line');
});
it('应该正确应用删除块', () => {
const base = 'line1\nline2\nline3';
const chunk = {
id: 'test',
startLine: 1,
endLine: 2,
type: 'delete' as const,
content: 'line2\n',
status: 'pending' as const,
};
const result = engine.applyChunk(base, chunk);
expect(result).not.toContain('line2');
});
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* Diff 计算引擎 - 基于 Myers 算法
*/
import { diffLines, Change } from 'diff';
import { v4 as uuidv4 } from 'uuid';
import { DiffChunk, ChunkType, Snapshot } from '../models/types';
export class DiffEngine {
/**
* 计算两个文本之间的差异块
*/
computeChunks(base: string, suggest: string): DiffChunk[] {
const changes = diffLines(base, suggest);
const chunks: DiffChunk[] = [];
let lineOffset = 0;
changes.forEach((change, index) => {
if (change.added || change.removed) {
const type: ChunkType = change.added ? 'add' : 'delete';
const lines = change.value.split('\n').filter(l => l.length > 0);
chunks.push({
id: uuidv4(),
startLine: lineOffset,
endLine: lineOffset + lines.length,
type,
content: change.value,
status: 'pending',
});
}
if (!change.removed) {
lineOffset += change.count || 0;
}
});
return this.mergeAdjacentChunks(chunks);
}
/**
* 合并相邻的变更块
*/
private mergeAdjacentChunks(chunks: DiffChunk[]): DiffChunk[] {
if (chunks.length <= 1) return chunks;
const merged: DiffChunk[] = [];
let current = { ...chunks[0] };
for (let i = 1; i < chunks.length; i++) {
const next = chunks[i];
// 如果相邻且类型相同,合并
if (current.endLine >= next.startLine && current.type === next.type) {
current = {
...current,
endLine: next.endLine,
content: current.content + next.content,
};
} else {
merged.push(current);
current = { ...next };
}
}
merged.push(current);
return merged;
}
/**
* 检测冲突 - 用户手动修改与 AI 建议的冲突
*/
detectConflicts(snapshot: Snapshot, currentContent: string): void {
const baseToCurrent = this.computeChunks(snapshot.baseContent, currentContent);
const baseToSuggest = snapshot.chunks;
// 重置所有非冲突状态
baseToSuggest.forEach(chunk => {
if (chunk.status === 'conflict') {
chunk.status = 'pending';
chunk.conflictWith = undefined;
}
});
// 检测重叠区域
baseToSuggest.forEach(suggestChunk => {
const hasConflict = baseToCurrent.some(currentChunk =>
this.isOverlapping(suggestChunk, currentChunk)
);
if (hasConflict) {
suggestChunk.status = 'conflict';
suggestChunk.conflictWith = baseToCurrent.find(c =>
this.isOverlapping(suggestChunk, c)
)?.id;
}
});
}
/**
* 判断两个变更块是否重叠
*/
private isOverlapping(chunk1: DiffChunk, chunk2: DiffChunk): boolean {
return chunk1.startLine < chunk2.endLine && chunk2.startLine < chunk1.endLine;
}
/**
* 应用变更块到文本
*/
applyChunk(baseContent: string, chunk: DiffChunk): string {
const lines = baseContent.split('\n');
const chunkLines = chunk.content.split('\n').filter(l => l.length > 0);
switch (chunk.type) {
case 'add':
lines.splice(chunk.startLine, 0, ...chunkLines);
break;
case 'delete':
lines.splice(chunk.startLine, chunk.endLine - chunk.startLine);
break;
case 'modify':
lines.splice(chunk.startLine, chunk.endLine - chunk.startLine, ...chunkLines);
break;
}
return lines.join('\n');
}
/**
* 应用多个变更块到文本
*/
applyChunks(baseContent: string, chunks: DiffChunk[]): string {
// 按行号倒序排序,从后往前应用,避免行号偏移
const sortedChunks = [...chunks]
.filter(c => c.status === 'accepted')
.sort((a, b) => b.startLine - a.startLine);
let result = baseContent;
sortedChunks.forEach(chunk => {
result = this.applyChunk(result, chunk);
});
return result;
}
}
+199
View File
@@ -0,0 +1,199 @@
/**
* AI Code Diff Preview - 插件入口
*/
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 { 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';
let snapshotManager: SnapshotManager;
let diffEngine: DiffEngine;
let inlineDecorator: InlineDecorator;
let codeLensProvider: CodeLensProvider;
let hookHandler: HookHandler;
let acceptHandler: AcceptHandler;
let rejectHandler: RejectHandler;
let statusBarManager: StatusBarManager;
export function activate(context: vscode.ExtensionContext) {
console.log('AI Code Diff Preview 插件已激活');
// 初始化核心模块
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);
// 注册命令
registerCommands(context);
// 注册 CodeLens 提供器
context.subscriptions.push(
vscode.languages.registerCodeLensProvider('*', codeLensProvider)
);
// 注册文档变更监听
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);
}
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 面板
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => {
// TODO: 实现 Diff 面板
vscode.window.showInformationMessage('Diff 面板功能开发中...');
})
);
}
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
);
}
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);
}
}
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);
}
}
}
export function deactivate() {
console.log('AI Code Diff Preview 插件已停用');
inlineDecorator.dispose();
statusBarManager.dispose();
}
+75
View File
@@ -0,0 +1,75 @@
/**
* AI Code Diff Preview - 常量定义
*/
/**
* 插件配置节名称
*/
export const CONFIG_SECTION = 'aiDiffPreview';
/**
* 命令 ID
*/
export const COMMANDS = {
ACCEPT_CHUNK: 'aiDiffPreview.acceptChunk',
REJECT_CHUNK: 'aiDiffPreview.rejectChunk',
ACCEPT_ALL: 'aiDiffPreview.acceptAll',
REJECT_ALL: 'aiDiffPreview.rejectAll',
SHOW_DIFF_PANEL: 'aiDiffPreview.showDiffPanel',
} as const;
/**
* 上下文键
*/
export const CONTEXT_KEYS = {
IS_ACTIVE: 'aiDiffPreview.isActive',
HAS_CONFLICTS: 'aiDiffPreview.hasConflicts',
CHUNK_COUNT: 'aiDiffPreview.chunkCount',
} as const;
/**
* 装饰器类型 ID
*/
export const DECORATION_TYPES = {
ADD_LINE: 'aiDiffPreview.addLine',
DELETE_LINE: 'aiDiffPreview.deleteLine',
MODIFY_LINE: 'aiDiffPreview.modifyLine',
CONFLICT_LINE: 'aiDiffPreview.conflictLine',
} as const;
/**
* 颜色配置
*/
export const COLORS = {
ADD_BACKGROUND: 'rgba(46, 160, 67, 0.15)',
ADD_GUTTER: '#2ea043',
DELETE_BACKGROUND: 'rgba(248, 81, 73, 0.15)',
DELETE_GUTTER: '#f85149',
MODIFY_BACKGROUND: 'rgba(210, 153, 34, 0.15)',
MODIFY_GUTTER: '#d29922',
CONFLICT_BACKGROUND: 'rgba(210, 153, 34, 0.3)',
} as const;
/**
* 默认配置值
*/
export const DEFAULT_CONFIG = {
ENABLE_AUTO_TRIGGER: true,
SHOW_INLINE_BUTTONS: true,
AUTO_LINT: true,
MAX_FILE_SIZE: 100000,
CONTEXT_LINES: 3,
} as const;
/**
* 状态栏优先级
*/
export const STATUS_BAR_PRIORITY = {
DIFF_STATUS: 100,
CHUNK_COUNT: 99,
} as const;
/**
* Webview 视图类型
*/
export const WEBVIEW_VIEW_TYPE = 'aiDiffPreview.diffPanel';
+98
View File
@@ -0,0 +1,98 @@
/**
* AI Code Diff Preview - 核心类型定义
*/
/**
* Claude Hook 事件类型
*/
export type HookEventType = 'after_code_edit' | 'after_write_file';
/**
* Claude Hook 事件
*/
export interface ClaudeHookEvent {
type: HookEventType;
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
}
/**
* 文件快照
*/
export interface Snapshot {
id: string;
filePath: string;
baseContent: string;
suggestContent: string;
timestamp: number;
chunks: DiffChunk[];
status: 'active' | 'completed' | 'cancelled';
}
/**
* Diff 渲染选项
*/
export interface DiffRenderOptions {
showInlineButtons: boolean;
showLineNumbers: boolean;
contextLines: number;
syntaxHighlight: boolean;
}
/**
* Accept/Reject 操作结果
*/
export interface TransactionResult {
success: boolean;
snapshotId: string;
chunkId?: string;
action: 'accept' | 'reject';
error?: string;
}
/**
* Diff 面板消息类型
*/
export type DiffPanelMessageType =
| { type: 'accept'; chunkId: string }
| { type: 'reject'; chunkId: string }
| { type: 'acceptAll' }
| { type: 'rejectAll' }
| { type: 'refresh' };
/**
* Diff 面板状态
*/
export interface DiffPanelState {
snapshotId: string;
filePath: string;
chunks: DiffChunk[];
totalChanges: number;
acceptedChanges: number;
rejectedChanges: number;
}
+96
View File
@@ -0,0 +1,96 @@
/**
* CodeLens 提供器 - 提供 Accept/Reject 按钮
*/
import * as vscode from 'vscode';
import { SnapshotManager } from '../snapshot/snapshotManager';
import { COMMANDS } from '../models/constants';
export class CodeLensProvider implements vscode.CodeLensProvider {
private _onDidChangeCodeLenses = new vscode.EventEmitter<void>();
readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
constructor(private snapshotManager: SnapshotManager) {}
/**
* 刷新 CodeLens
*/
refresh(): void {
this._onDidChangeCodeLenses.fire();
}
/**
* 提供 CodeLens
*/
provideCodeLenses(
document: vscode.TextDocument,
token: vscode.CancellationToken
): vscode.CodeLens[] {
const snapshot = this.snapshotManager.getSnapshotByFilePath(document.uri.fsPath);
if (!snapshot || snapshot.status !== 'active') {
return [];
}
const codeLenses: vscode.CodeLens[] = [];
// 为每个待处理的块添加 CodeLens
snapshot.chunks.forEach(chunk => {
if (chunk.status !== 'pending') return;
const range = new vscode.Range(
new vscode.Position(chunk.startLine, 0),
new vscode.Position(chunk.startLine, 0)
);
// Accept 按钮
codeLenses.push(
new vscode.CodeLens(range, {
title: '$(check) Accept',
command: COMMANDS.ACCEPT_CHUNK,
arguments: [snapshot.id, chunk.id],
tooltip: '接受此变更块',
})
);
// Reject 按钮
codeLenses.push(
new vscode.CodeLens(range, {
title: '$(close) Reject',
command: COMMANDS.REJECT_CHUNK,
arguments: [snapshot.id, chunk.id],
tooltip: '拒绝此变更块',
})
);
});
// 如果有多个块,添加全局操作按钮
const pendingChunks = snapshot.chunks.filter(c => c.status === 'pending');
if (pendingChunks.length > 1) {
const firstChunk = pendingChunks[0];
const range = new vscode.Range(
new vscode.Position(firstChunk.startLine, 0),
new vscode.Position(firstChunk.startLine, 0)
);
codeLenses.push(
new vscode.CodeLens(range, {
title: '$(check-all) Accept All',
command: COMMANDS.ACCEPT_ALL,
arguments: [snapshot.id],
tooltip: '接受所有变更',
})
);
codeLenses.push(
new vscode.CodeLens(range, {
title: '$(trash) Reject All',
command: COMMANDS.REJECT_ALL,
arguments: [snapshot.id],
tooltip: '拒绝所有变更',
})
);
}
return codeLenses;
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* 内联装饰器 - 渲染 Diff 高亮和按钮
*/
import * as vscode from 'vscode';
import { DiffChunk, ChunkType } from '../models/types';
import { COLORS, DECORATION_TYPES } from '../models/constants';
export class InlineDecorator {
private addDecorationType: vscode.TextEditorDecorationType;
private deleteDecorationType: vscode.TextEditorDecorationType;
private modifyDecorationType: vscode.TextEditorDecorationType;
private conflictDecorationType: vscode.TextEditorDecorationType;
private acceptButtonDecorationType: vscode.TextEditorDecorationType;
constructor() {
this.addDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.ADD_BACKGROUND,
isWholeLine: true,
gutterIconPath: this.createGutterIconPath(COLORS.ADD_GUTTER),
gutterIconSize: 'contain',
});
this.deleteDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.DELETE_BACKGROUND,
isWholeLine: true,
textDecoration: 'line-through',
gutterIconPath: this.createGutterIconPath(COLORS.DELETE_GUTTER),
gutterIconSize: 'contain',
});
this.modifyDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.MODIFY_BACKGROUND,
isWholeLine: true,
gutterIconPath: this.createGutterIconPath(COLORS.MODIFY_GUTTER),
gutterIconSize: 'contain',
});
this.conflictDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.CONFLICT_BACKGROUND,
isWholeLine: true,
borderColor: COLORS.MODIFY_GUTTER,
borderStyle: 'solid',
borderWidth: '1px',
});
this.acceptButtonDecorationType = vscode.window.createTextEditorDecorationType({
after: {
contentText: ' ✓ Accept',
color: '#ffffff',
backgroundColor: COLORS.ADD_GUTTER,
margin: '0 0 0 1em',
border: '1px solid rgba(255,255,255,0.2)',
cursor: 'pointer',
},
});
}
private createGutterIconPath(color: string): vscode.Uri {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<circle cx="8" cy="8" r="6" fill="${color}"/>
</svg>`;
return vscode.Uri.parse(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`);
}
/**
* 渲染变更块
*/
renderChunks(editor: vscode.TextEditor, chunks: DiffChunk[]): void {
const addRanges: vscode.Range[] = [];
const deleteRanges: vscode.Range[] = [];
const modifyRanges: vscode.Range[] = [];
const conflictRanges: vscode.Range[] = [];
const buttonRanges: vscode.Range[] = [];
chunks.forEach(chunk => {
if (chunk.status === 'accepted' || chunk.status === 'rejected') {
return; // 跳过已处理的块
}
const range = new vscode.Range(
new vscode.Position(chunk.startLine, 0),
new vscode.Position(chunk.endLine, 0)
);
switch (chunk.type) {
case 'add':
addRanges.push(range);
buttonRanges.push(range);
break;
case 'delete':
deleteRanges.push(range);
break;
case 'modify':
modifyRanges.push(range);
buttonRanges.push(range);
break;
}
if (chunk.status === 'conflict') {
conflictRanges.push(range);
}
});
editor.setDecorations(this.addDecorationType, addRanges);
editor.setDecorations(this.deleteDecorationType, deleteRanges);
editor.setDecorations(this.modifyDecorationType, modifyRanges);
editor.setDecorations(this.conflictDecorationType, conflictRanges);
editor.setDecorations(this.acceptButtonDecorationType, buttonRanges);
}
/**
* 清除所有装饰器
*/
clearDecorations(editor: vscode.TextEditor): void {
editor.setDecorations(this.addDecorationType, []);
editor.setDecorations(this.deleteDecorationType, []);
editor.setDecorations(this.modifyDecorationType, []);
editor.setDecorations(this.conflictDecorationType, []);
editor.setDecorations(this.acceptButtonDecorationType, []);
}
/**
* 释放资源
*/
dispose(): void {
this.addDecorationType.dispose();
this.deleteDecorationType.dispose();
this.modifyDecorationType.dispose();
this.conflictDecorationType.dispose();
this.acceptButtonDecorationType.dispose();
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 状态栏管理器 - 显示 Diff 状态信息
*/
import * as vscode from 'vscode';
import { Snapshot } from '../models/types';
import { STATUS_BAR_PRIORITY } from '../models/constants';
export class StatusBarManager {
private statusBarItem: vscode.StatusBarItem;
private chunkCountItem: vscode.StatusBarItem;
constructor() {
this.statusBarItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
STATUS_BAR_PRIORITY.DIFF_STATUS
);
this.chunkCountItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
STATUS_BAR_PRIORITY.CHUNK_COUNT
);
}
/**
* 更新状态栏
*/
update(snapshot: Snapshot): void {
const pendingChunks = snapshot.chunks.filter(c => c.status === 'pending');
const conflictChunks = snapshot.chunks.filter(c => c.status === 'conflict');
// 更新主状态
this.statusBarItem.text = '$(diff) AI Diff';
this.statusBarItem.tooltip = 'AI Diff Preview 活跃';
this.statusBarItem.show();
// 更新变更块数量
if (pendingChunks.length > 0 || conflictChunks.length > 0) {
this.chunkCountItem.text = `$(edit) ${pendingChunks.length} 个变更`;
if (conflictChunks.length > 0) {
this.chunkCountItem.text += ` (${conflictChunks.length} 个冲突)`;
this.chunkCountItem.backgroundColor = new vscode.ThemeColor(
'statusBarItem.warningBackground'
);
} else {
this.chunkCountItem.backgroundColor = undefined;
}
this.chunkCountItem.tooltip = '点击查看变更详情';
this.chunkCountItem.show();
} else {
this.chunkCountItem.hide();
}
}
/**
* 清除状态栏
*/
clear(): void {
this.statusBarItem.hide();
this.chunkCountItem.hide();
}
/**
* 释放资源
*/
dispose(): void {
this.statusBarItem.dispose();
this.chunkCountItem.dispose();
}
}
+148
View File
@@ -0,0 +1,148 @@
/**
* 快照管理器 - 管理文件的 Base/Suggest 双版本
*/
import { v4 as uuidv4 } from 'uuid';
import { Snapshot, DiffChunk, ChunkStatus } from '../models/types';
export class SnapshotManager {
private snapshots: Map<string, Snapshot> = new Map();
private filePathIndex: Map<string, string> = new Map(); // filePath -> snapshotId
/**
* 创建 Base 快照
*/
createBaseSnapshot(filePath: string, baseContent: string): string {
const id = uuidv4();
const snapshot: Snapshot = {
id,
filePath,
baseContent,
suggestContent: '',
timestamp: Date.now(),
chunks: [],
status: 'active',
};
this.snapshots.set(id, snapshot);
this.filePathIndex.set(filePath, 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}`);
}
snapshot.suggestContent = patch;
snapshot.chunks = chunks;
}
/**
* 获取快照
*/
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);
}
}
}
}
+190
View File
@@ -0,0 +1,190 @@
/**
* Accept 处理器 - 处理接受变更的逻辑
*/
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 { TransactionResult } from '../models/types';
export class AcceptHandler {
constructor(
private snapshotManager: SnapshotManager,
private diffEngine: DiffEngine,
private inlineDecorator: InlineDecorator
) {}
/**
* 接受单个变更块
*/
async acceptChunk(snapshotId: string, chunkId: string): Promise<TransactionResult> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) {
return {
success: false,
snapshotId,
chunkId,
action: 'accept',
error: '快照不存在',
};
}
const chunk = snapshot.chunks.find(c => c.id === chunkId);
if (!chunk) {
return {
success: false,
snapshotId,
chunkId,
action: 'accept',
error: '变更块不存在',
};
}
if (chunk.status === 'conflict') {
return {
success: false,
snapshotId,
chunkId,
action: 'accept',
error: '存在冲突,请先解决冲突',
};
}
try {
// 更新变更块状态
this.snapshotManager.updateChunkStatus(snapshotId, chunkId, 'accepted');
// 检查是否所有块都已接受
const allAccepted = snapshot.chunks.every(
c => c.status === 'accepted' || c.status === 'rejected'
);
if (allAccepted) {
// 应用所有已接受的变更到文件
await this.applyChanges(snapshotId);
}
return {
success: true,
snapshotId,
chunkId,
action: 'accept',
};
} catch (error) {
return {
success: false,
snapshotId,
chunkId,
action: 'accept',
error: `接受失败: ${error}`,
};
}
}
/**
* 接受所有变更块
*/
async acceptAll(snapshotId: string): Promise<TransactionResult> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) {
return {
success: false,
snapshotId,
action: 'accept',
error: '快照不存在',
};
}
// 检查是否有冲突
const hasConflicts = snapshot.chunks.some(c => c.status === 'conflict');
if (hasConflicts) {
return {
success: false,
snapshotId,
action: 'accept',
error: '存在冲突,请先解决冲突',
};
}
try {
// 接受所有待处理的块
this.snapshotManager.updateAllChunksStatus(snapshotId, 'accepted');
// 应用变更
await this.applyChanges(snapshotId);
return {
success: true,
snapshotId,
action: 'accept',
};
} catch (error) {
return {
success: false,
snapshotId,
action: 'accept',
error: `接受失败: ${error}`,
};
}
}
/**
* 应用变更到文件
*/
private async applyChanges(snapshotId: string): Promise<void> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) return;
// 计算最终内容
const finalContent = this.diffEngine.applyChunks(
snapshot.baseContent,
snapshot.chunks
);
// 原子化写入文件
await this.writeFileAtomic(snapshot.filePath, finalContent);
// 刷新 LSP
await this.refreshLSP(snapshot.filePath);
// 标记快照完成
this.snapshotManager.completeSnapshot(snapshotId);
}
/**
* 原子化写入文件
*/
private async writeFileAtomic(filePath: string, content: string): Promise<void> {
const uri = vscode.Uri.file(filePath);
const document = await vscode.workspace.openTextDocument(uri);
const edit = new vscode.WorkspaceEdit();
const fullRange = new vscode.Range(
document.positionAt(0),
document.positionAt(document.getText().length)
);
edit.replace(uri, fullRange, content);
await vscode.workspace.applyEdit(edit);
await document.save();
}
/**
* 刷新 LSP
*/
private async refreshLSP(filePath: string): Promise<void> {
// 触发 LSP 重新解析
const uri = vscode.Uri.file(filePath);
const document = await vscode.workspace.openTextDocument(uri);
// 通过修改文档触发 LSP 更新
const edit = new vscode.WorkspaceEdit();
edit.insert(uri, new vscode.Position(0, 0), '');
await vscode.workspace.applyEdit(edit);
// 立即撤销
await vscode.commands.executeCommand('undo');
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Reject 处理器 - 处理拒绝变更的逻辑
*/
import * as vscode from 'vscode';
import { SnapshotManager } from '../snapshot/snapshotManager';
import { InlineDecorator } from '../render/inlineDecorator';
import { TransactionResult } from '../models/types';
import { CONTEXT_KEYS } from '../models/constants';
export class RejectHandler {
constructor(
private snapshotManager: SnapshotManager,
private inlineDecorator: InlineDecorator
) {}
/**
* 拒绝单个变更块
*/
async rejectChunk(snapshotId: string, chunkId: string): Promise<TransactionResult> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) {
return {
success: false,
snapshotId,
chunkId,
action: 'reject',
error: '快照不存在',
};
}
const chunk = snapshot.chunks.find(c => c.id === chunkId);
if (!chunk) {
return {
success: false,
snapshotId,
chunkId,
action: 'reject',
error: '变更块不存在',
};
}
try {
// 更新变更块状态
this.snapshotManager.updateChunkStatus(snapshotId, chunkId, 'rejected');
// 检查是否所有块都已处理
const allProcessed = snapshot.chunks.every(
c => c.status === 'accepted' || c.status === 'rejected'
);
if (allProcessed) {
// 清理 UI
await this.cleanupUI(snapshotId);
}
return {
success: true,
snapshotId,
chunkId,
action: 'reject',
};
} catch (error) {
return {
success: false,
snapshotId,
chunkId,
action: 'reject',
error: `拒绝失败: ${error}`,
};
}
}
/**
* 拒绝所有变更块
*/
async rejectAll(snapshotId: string): Promise<TransactionResult> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) {
return {
success: false,
snapshotId,
action: 'reject',
error: '快照不存在',
};
}
try {
// 拒绝所有待处理的块
this.snapshotManager.updateAllChunksStatus(snapshotId, 'rejected');
// 清理 UI
await this.cleanupUI(snapshotId);
return {
success: true,
snapshotId,
action: 'reject',
};
} catch (error) {
return {
success: false,
snapshotId,
action: 'reject',
error: `拒绝失败: ${error}`,
};
}
}
/**
* 清理 UI
*/
private async cleanupUI(snapshotId: string): Promise<void> {
const snapshot = this.snapshotManager.getSnapshot(snapshotId);
if (!snapshot) return;
// 查找对应的编辑器
const editor = vscode.window.visibleTextEditors.find(
e => e.document.uri.fsPath === snapshot.filePath
);
if (editor) {
// 清除装饰器
this.inlineDecorator.clearDecorations(editor);
}
// 取消快照
this.snapshotManager.cancelSnapshot(snapshotId);
// 检查是否还有其他活跃快照
const activeSnapshots = this.snapshotManager.getActiveSnapshots();
if (activeSnapshots.length === 0) {
vscode.commands.executeCommand('setContext', CONTEXT_KEYS.IS_ACTIVE, false);
}
vscode.window.showInformationMessage('AI Diff Preview: 已拒绝所有变更');
}
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Claude Hook 处理器 - 处理 after_code_edit 和 after_write_file 事件
*/
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';
export class HookHandler {
constructor(
private snapshotManager: SnapshotManager,
private diffEngine: DiffEngine,
private inlineDecorator: InlineDecorator,
private statusBarManager: StatusBarManager
) {}
/**
* 处理 Claude Hook 事件
*/
async handleHook(event: ClaudeHookEvent): Promise<void> {
console.log(`收到 Hook 事件: ${event.type}, 文件: ${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(
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 监听器已注册');
}
}