refactor: 架构变更:Webview → VSCode 内置 Diff + 浮动标签

This commit is contained in:
2026-06-18 08:05:24 +08:00
parent f06914994d
commit fcda651760
7 changed files with 548 additions and 98 deletions
+163 -6
View File
@@ -2,10 +2,16 @@
* AI Code Diff Preview - 插件入口
*
* 工作流程:
* 1. PostToolUse (Edit/Write) → 收集变更到 ChangeSet
* 2. Stop (对话结束) → 弹出通知
* 3. 用户点击"查看更改" → 打开 Review 双栏面板
* 1. PostToolUse (Edit/Write) → 收集变更到 ChangeSet (EditRecord 粒度)
* 2. Stop (对话结束) → 根据配置弹出 QuickPick 文件列表或通知
* 3. 用户选择文件 → 打开 VSCode 内置 Diff 编辑器
* 4. 用户 Accept/Reject → 应用或丢弃变更
*
* 快捷键:
* - Alt+↑/↓: 上一个/下一个变更
* - Tab: 接受当前编辑
* - Esc: 拒绝当前编辑
* - Ctrl+Shift+D: 显示文件列表
*/
import * as vscode from 'vscode';
@@ -13,12 +19,14 @@ import * as path from 'path';
import * as fs from 'fs';
import { ChangeSetManager } from './snapshot/snapshotManager';
import { HookHandler } from './trigger/hookHandler';
import { DiffViewer } from './render/diffViewer';
import { ReviewPanel } from './render/reviewPanel';
import { COMMANDS } from './models/constants';
import { ClaudeHookEvent } from './models/types';
let changeSetManager: ChangeSetManager;
let hookHandler: HookHandler;
let diffViewer: DiffViewer;
let reviewPanel: ReviewPanel;
export function activate(context: vscode.ExtensionContext) {
@@ -27,6 +35,7 @@ export function activate(context: vscode.ExtensionContext) {
// 初始化核心模块
changeSetManager = new ChangeSetManager();
hookHandler = new HookHandler(changeSetManager);
diffViewer = hookHandler.getDiffViewer();
reviewPanel = new ReviewPanel(changeSetManager);
// 注册命令
@@ -40,12 +49,160 @@ export function activate(context: vscode.ExtensionContext) {
}
function registerCommands(context: vscode.ExtensionContext) {
// 显示 Review 面板
// === Diff 查看 ===
// 显示文件列表 (QuickPick)
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.SHOW_DIFF_PANEL, () => {
reviewPanel.show();
hookHandler.showFileListPicker();
})
);
// 显示当前活动文件的 Diff
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.SHOW_CURRENT_FILE_DIFF, () => {
diffViewer.showCurrentFileDiff();
})
);
// === Accept / Reject ===
// 接受当前编辑(光标所在位置的编辑)
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.ACCEPT_CURRENT_EDIT, () => {
const result = findCurrentEditAtCursor();
if (!result) {
vscode.window.showInformationMessage('AI Diff: 光标不在变更区域内');
return;
}
changeSetManager.acceptEditRecord(result.fileChange.id, result.edit.id);
vscode.window.showInformationMessage(`AI Diff: 已接受编辑 #${result.edit.id.slice(0, 6)}`);
})
);
// 拒绝当前编辑
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.REJECT_CURRENT_EDIT, () => {
const result = findCurrentEditAtCursor();
if (!result) {
vscode.window.showInformationMessage('AI Diff: 光标不在变更区域内');
return;
}
changeSetManager.rejectEditRecord(result.fileChange.id, result.edit.id);
vscode.window.showInformationMessage(`AI Diff: 已拒绝编辑 #${result.edit.id.slice(0, 6)}`);
})
);
// 接受全部
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.ACCEPT_ALL, () => {
const count = changeSetManager.acceptAll();
vscode.window.showInformationMessage(`AI Diff: 已接受 ${count} 个文件的变更`);
})
);
// 拒绝全部
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.REJECT_ALL, () => {
const count = changeSetManager.rejectAll();
vscode.window.showInformationMessage(`AI Diff: 已拒绝 ${count} 个文件的变更`);
})
);
// === 导航 ===
// 下一个变更
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.NEXT_DIFF, () => {
navigateDiff(1);
})
);
// 上一个变更
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.PREV_DIFF, () => {
navigateDiff(-1);
})
);
// 旧版兼容命令
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.ACCEPT_CHUNK, () => {
vscode.commands.executeCommand(COMMANDS.ACCEPT_CURRENT_EDIT);
})
);
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.REJECT_CHUNK, () => {
vscode.commands.executeCommand(COMMANDS.REJECT_CURRENT_EDIT);
})
);
}
/**
* 查找光标所在位置的编辑记录
*/
function findCurrentEditAtCursor(): { fileChange: any; edit: any } | null {
const editor = vscode.window.activeTextEditor;
if (!editor) return null;
const set = changeSetManager.getCurrentSet();
if (!set) return null;
const normalizedPath = path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/');
const fileChange = set.changes.find(
c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath && c.status === 'pending'
);
if (!fileChange) return null;
const cursorLine = editor.selection.active.line;
// 在文件聚合 diff 中查找光标所在行对应的 edit
for (const edit of fileChange.edits) {
if (edit.status !== 'pending') continue;
// 找到该 edit 中匹配的行范围
for (const d of edit.diffLines) {
if (d.type === 'context' || d.type === 'add') {
if (d.newLineNum - 1 === cursorLine) {
return { fileChange, edit };
}
}
}
}
// 回退:返回第一个 pending edit
const firstPending = fileChange.edits.find(e => e.status === 'pending');
if (firstPending) {
return { fileChange, edit: firstPending };
}
return null;
}
/**
* 在变更文件之间导航
*/
function navigateDiff(direction: 1 | -1): void {
const set = changeSetManager.getCurrentSet();
if (!set) return;
const pending = set.changes.filter(c => c.status === 'pending');
if (pending.length === 0) {
vscode.window.showInformationMessage('AI Diff: 无待处理变更');
return;
}
const editor = vscode.window.activeTextEditor;
const currentPath = editor
? path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/')
: null;
// 找到当前文件在 pending 列表中的位置
let idx = currentPath
? pending.findIndex(c => path.normalize(c.filePath).replace(/\\/g, '/') === currentPath)
: -1;
idx = idx === -1 ? 0 : (idx + direction + pending.length) % pending.length;
diffViewer.showFileDiff(pending[idx]);
}
/**
@@ -53,7 +210,7 @@ function registerCommands(context: vscode.ExtensionContext) {
*
* 监听 .claude/hooks/pending.json:
* - toolName: Edit/Write → 收集变更
* - toolName: Stop → 弹出 Review 通知
* - toolName: Stop → 弹出 Review 通知/文件列表
*/
function registerHookFileWatcher(context: vscode.ExtensionContext) {
const hookDir = vscode.workspace.workspaceFolders?.[0]
+21
View File
@@ -16,6 +16,16 @@ export const COMMANDS = {
ACCEPT_ALL: 'aiDiffPreview.acceptAll',
REJECT_ALL: 'aiDiffPreview.rejectAll',
SHOW_DIFF_PANEL: 'aiDiffPreview.showDiffPanel',
/** 显示当前文件的 Diff */
SHOW_CURRENT_FILE_DIFF: 'aiDiffPreview.showCurrentFileDiff',
/** 接受光标所在位置的编辑 */
ACCEPT_CURRENT_EDIT: 'aiDiffPreview.acceptCurrentEdit',
/** 拒绝光标所在位置的编辑 */
REJECT_CURRENT_EDIT: 'aiDiffPreview.rejectCurrentEdit',
/** 跳转到下一个待处理变更 */
NEXT_DIFF: 'aiDiffPreview.nextDiff',
/** 跳转到上一个待处理变更 */
PREV_DIFF: 'aiDiffPreview.prevDiff',
} as const;
/**
@@ -27,6 +37,17 @@ export const CONTEXT_KEYS = {
CHUNK_COUNT: 'aiDiffPreview.chunkCount',
} as const;
/**
* 配置项键名
*/
export const CONFIG_KEYS = {
ENABLE_AUTO_TRIGGER: 'enableAutoTrigger',
AUTO_SHOW_DIFF_PER_EDIT: 'autoShowDiffPerEdit',
SHOW_ALL_DIFFS_ON_STOP: 'showAllDiffsOnStop',
FLOATING_LABEL_MODE: 'floatingLabelMode',
MAX_FILE_SIZE: 'maxFileSize',
} as const;
/**
* 装饰器类型 ID
*/
+122
View File
@@ -0,0 +1,122 @@
/**
* Diff 查看器 - 调用 VSCode 内置 Diff 编辑器
*
* 使用 vscode.diff 命令展示原始内容 vs 变更后内容,
* 完全不使用自定义 Webview,保持 VSCode 原生交互体验。
*/
import * as vscode from 'vscode';
import * as path from 'path';
import { EditRecord, FileChange } from '../models/types';
import { ChangeSetManager } from '../snapshot/snapshotManager';
export class DiffViewer {
constructor(private changeSetManager: ChangeSetManager) {}
/**
* 打开单次编辑的 Diff(VSCode 内置 Diff 编辑器)
*/
async showEditDiff(editRecord: EditRecord): Promise<void> {
const beforeDoc = await vscode.workspace.openTextDocument({
content: editRecord.beforeContent,
language: this.getLanguageId(editRecord.filePath),
});
const afterDoc = await vscode.workspace.openTextDocument({
content: editRecord.afterContent,
language: this.getLanguageId(editRecord.filePath),
});
const fileName = path.basename(editRecord.filePath);
const toolLabel = editRecord.toolName === 'Write' ? '写入' : '编辑';
await vscode.commands.executeCommand(
'vscode.diff',
beforeDoc.uri,
afterDoc.uri,
`${fileName} — AI ${toolLabel} #${editRecord.id.slice(0, 6)} (原始 ↔ 变更后)`
);
}
/**
* 打开整个文件的聚合 Diff
*/
async showFileDiff(fileChange: FileChange): Promise<void> {
const beforeDoc = await vscode.workspace.openTextDocument({
content: fileChange.originalContent,
language: this.getLanguageId(fileChange.filePath),
});
const afterDoc = await vscode.workspace.openTextDocument({
content: fileChange.latestContent,
language: this.getLanguageId(fileChange.filePath),
});
const fileName = path.basename(fileChange.filePath);
await vscode.commands.executeCommand(
'vscode.diff',
beforeDoc.uri,
afterDoc.uri,
`${fileName} — 原始 ↔ 变更后 (${fileChange.edits.length} 次编辑)`
);
}
/**
* 打开当前活动文件在编辑器中的 Diff
*/
async showCurrentFileDiff(): Promise<void> {
const changeSet = this.changeSetManager.getCurrentSet();
if (!changeSet) return;
const editor = vscode.window.activeTextEditor;
if (!editor) return;
const normalizedPath = path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/');
const fileChange = changeSet.changes.find(
c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath && c.status === 'pending'
);
if (!fileChange) {
vscode.window.showInformationMessage('AI Diff: 当前文件无待处理变更');
return;
}
await this.showFileDiff(fileChange);
}
/**
* 聚焦文件到编辑器(跳转到该文件并定位到第一个变更行)
*/
async focusFileInEditor(fileChange: FileChange): Promise<void> {
const uri = vscode.Uri.file(fileChange.filePath);
const doc = await vscode.workspace.openTextDocument(uri);
const editor = await vscode.window.showTextDocument(doc, { preview: false });
// 滚动到第一个变更行
if (fileChange.diffLines.length > 0) {
const firstChange = fileChange.diffLines.find(
d => d.type === 'add' || d.type === 'delete'
);
if (firstChange) {
const line = (firstChange.newLineNum || firstChange.oldLineNum) - 1;
const pos = new vscode.Position(Math.max(0, line), 0);
editor.selection = new vscode.Selection(pos, pos);
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
}
}
}
private getLanguageId(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const map: Record<string, string> = {
'.ts': 'typescript', '.tsx': 'typescriptreact',
'.js': 'javascript', '.jsx': 'javascriptreact',
'.json': 'json', '.md': 'markdown',
'.css': 'css', '.scss': 'scss', '.less': 'less',
'.html': 'html', '.htm': 'html',
'.py': 'python', '.rs': 'rust', '.go': 'go',
'.java': 'java', '.cpp': 'cpp', '.c': 'c',
'.yaml': 'yaml', '.yml': 'yaml',
'.xml': 'xml', '.sql': 'sql', '.sh': 'shell',
};
return map[ext] || 'plaintext';
}
}
+104 -15
View File
@@ -1,23 +1,41 @@
/**
* Hook 处理器
*
* PostToolUse (Edit/Write) → 收集变更,不显示 UI
* Stop → 对话结束,弹出 Review 通知
* PostToolUse (Edit/Write) → 收集变更
* + autoShowDiffPerEdit=true → 立即弹出 VSCode 内置 Diff
* + autoShowDiffPerEdit=false → 静默收集
*
* Stop → 对话结束
* + showAllDiffsOnStop=true → 弹出 QuickPick 文件列表
* + showAllDiffsOnStop=false → 仅状态栏通知
*/
import * as vscode from 'vscode';
import * as path from 'path';
import { ChangeSetManager } from '../snapshot/snapshotManager';
import { ClaudeHookEvent } from '../models/types';
import { DiffViewer } from '../render/diffViewer';
import { ClaudeHookEvent, FileChange } from '../models/types';
import { CONFIG_SECTION, CONFIG_KEYS } from '../models/constants';
const SETTING_AUTO_SHOW_DIFF = `${CONFIG_SECTION}.${CONFIG_KEYS.AUTO_SHOW_DIFF_PER_EDIT}`;
const SETTING_SHOW_ALL_ON_STOP = `${CONFIG_SECTION}.${CONFIG_KEYS.SHOW_ALL_DIFFS_ON_STOP}`;
export class HookHandler {
private diffViewer: DiffViewer;
constructor(
private changeSetManager: ChangeSetManager
) {}
) {
this.diffViewer = new DiffViewer(changeSetManager);
}
getDiffViewer(): DiffViewer {
return this.diffViewer;
}
/**
* 处理 PostToolUse 事件(Edit/Write)
* 只收集变更,不弹 UI
* 收集变更,根据配置决定是否自动弹 Diff
*/
handleEdit(event: ClaudeHookEvent): void {
event.filePath = path.normalize(event.filePath);
@@ -30,11 +48,29 @@ export class HookHandler {
event.newString || '',
event.content || ''
);
// 根据配置决定是否自动弹 Diff
const autoShow = vscode.workspace.getConfiguration(CONFIG_SECTION).get<boolean>(CONFIG_KEYS.AUTO_SHOW_DIFF_PER_EDIT, false);
if (autoShow) {
const set = this.changeSetManager.getCurrentSet();
if (set) {
const normalizedPath = path.normalize(event.filePath).replace(/\\/g, '/');
const fileChange = set.changes.find(
c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath
);
if (fileChange) {
const lastEdit = fileChange.edits[fileChange.edits.length - 1];
this.diffViewer.showEditDiff(lastEdit);
}
}
}
// 更新上下文键
vscode.commands.executeCommand('setContext', 'aiDiffPreview.isActive', true);
}
/**
* 处理 Stop 事件(对话结束)
* 弹出通知,提示用户查看变更
*/
handleStop(): void {
const changeSet = this.changeSetManager.markReady();
@@ -44,16 +80,69 @@ export class HookHandler {
}
const count = changeSet.changes.length;
console.log(`[AI Diff] 对话结束,${count} 个文件有变更`);
const pendingCount = changeSet.changes.filter(c => c.status === 'pending').length;
console.log(`[AI Diff] 对话结束,${count} 个文件有变更 (pending: ${pendingCount})`);
// 弹出通知,带"查看更改"按钮
vscode.window.showInformationMessage(
`AI Diff: ${count} 个文件已修改`,
'查看更改'
).then(selection => {
if (selection === '查看更改') {
vscode.commands.executeCommand('aiDiffPreview.showDiffPanel');
}
const showAll = vscode.workspace.getConfiguration(CONFIG_SECTION).get<boolean>(CONFIG_KEYS.SHOW_ALL_DIFFS_ON_STOP, true);
if (showAll && pendingCount > 0) {
// 弹出 QuickPick 文件列表
this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending'));
} else {
// 仅状态栏通知
const msg = pendingCount > 0
? `AI Diff: ${pendingCount} 个文件有待处理变更`
: `AI Diff: ${count} 个文件变更已完成`;
vscode.window.showInformationMessage(msg, '查看变更').then(selection => {
if (selection === '查看变更') {
this.showFileListPicker(changeSet.changes.filter(c => c.status === 'pending'));
}
});
}
}
/**
* 弹出 QuickPick 文件列表
*/
async showFileListPicker(fileChanges?: FileChange[]): Promise<void> {
let changes = fileChanges;
if (!changes) {
const set = this.changeSetManager.getCurrentSet();
if (!set) return;
changes = set.changes.filter(c => c.status === 'pending');
}
if (changes.length === 0) {
vscode.window.showInformationMessage('AI Diff: 无待处理变更');
return;
}
const items: vscode.QuickPickItem[] = changes.map(c => {
const name = path.basename(c.filePath);
const adds = c.diffLines.filter(d => d.type === 'add').length;
const dels = c.diffLines.filter(d => d.type === 'delete').length;
const typeMap: Record<string, string> = { modify: '$(edit)', create: '$(new-file)', delete: '$(trash)' };
return {
label: `${typeMap[c.type] || '$(file)'} ${name}`,
description: `+${adds} -${dels} · ${c.edits.length} 次编辑`,
detail: c.filePath,
// 存储 fileChange id 以便查找
id: c.id,
};
});
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `AI Diff Review — ${changes.length} 个文件有变更`,
matchOnDescription: true,
matchOnDetail: true,
});
if (selected && selected.id) {
const fileChange = changes.find(c => c.id === selected.id);
if (fileChange) {
await this.diffViewer.showFileDiff(fileChange);
}
}
}
}