v0.2.0: VSCode内置Diff + QuickPick总览 + EditRecord粒度 + Bug修复

新增:
- VSCode内置Diff编辑器 (diffViewer.ts) 替代Webview,零保存提示
- EditRecord独立追踪: 每次Edit/Write不合并,支持逐次编辑Accept/Reject
- QuickPick文件列表总览,含Accept All / Reject All
- 智能Auto-Advance: Accept/Reject后自动推进,同文件多编辑智能停留
- 状态栏常驻显示  AI Diff,Diff模式下切换为操作按钮

修复:
- Accept后同文件再编辑不触发选项 (pending-priority匹配)
- Accept All / Reject All写入与状态顺序修复
- acceptFile/rejectFile改为先写入成功再标记状态
- 同文件多个FileChange孤儿编辑问题

变更:
- ChangeSetManager重构为EditRecord[] + FileChange聚合
- HookHandler精简,QuickPick移入hookHandler
- 移除InlineDecorator/CodeLens (主流程),ReviewPanel降级为备用
- package.json: publisher=YonHao Guo, repository, v0.2.0, 新配置项/快捷键

测试: 13/13通过 (7 diffEngine + 6 changeSetManager回归)
This commit is contained in:
2026-06-18 19:37:21 +08:00
parent fcda651760
commit dfa3a585c5
14 changed files with 1454 additions and 958 deletions
+92 -64
View File
@@ -1,96 +1,124 @@
/**
* CodeLens 提供器 - 提供 Accept/Reject 按钮
* CodeLens 提供器 - 在编辑器行上方显示 Accept/Reject 按钮
*
* 基于 ChangeSetManager 和 EditRecord,为每次编辑提供可点击的操作按钮。
* 按钮显示在对应编辑的变更区域上方。
*/
import * as vscode from 'vscode';
import { SnapshotManager } from '../snapshot/snapshotManager';
import * as path from 'path';
import { ChangeSetManager } from '../snapshot/snapshotManager';
import { COMMANDS } from '../models/constants';
import { FileChange, EditRecord } from '../models/types';
export class CodeLensProvider implements vscode.CodeLensProvider {
export class AIDiffCodeLensProvider implements vscode.CodeLensProvider {
private _onDidChangeCodeLenses = new vscode.EventEmitter<void>();
readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
constructor(private snapshotManager: SnapshotManager) {}
constructor(private changeSetManager: ChangeSetManager) {}
/**
* 刷新 CodeLens
*/
refresh(): void {
this._onDidChangeCodeLenses.fire();
}
/**
* 提供 CodeLens
*/
provideCodeLenses(
document: vscode.TextDocument,
token: vscode.CancellationToken
_token: vscode.CancellationToken
): vscode.CodeLens[] {
const snapshot = this.snapshotManager.getSnapshotByFilePath(document.uri.fsPath);
if (!snapshot || snapshot.status !== 'active') {
return [];
}
const set = this.changeSetManager.getCurrentSet();
if (!set || set.status !== 'ready') return [];
const codeLenses: vscode.CodeLens[] = [];
const normalizedPath = path.normalize(document.uri.fsPath).replace(/\\/g, '/');
const fileChange = set.changes.find(
c => path.normalize(c.filePath).replace(/\\/g, '/') === normalizedPath && c.status === 'pending'
);
if (!fileChange) return [];
// 为每个待处理的块添加 CodeLens
snapshot.chunks.forEach(chunk => {
if (chunk.status !== 'pending') return;
const lenses: vscode.CodeLens[] = [];
const range = new vscode.Range(
new vscode.Position(chunk.startLine, 0),
new vscode.Position(chunk.startLine, 0)
// 文件标题行 (第 0 行): 全部接受/拒绝
lenses.push(
new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), {
title: `$(diff) AI Diff · ${fileChange.edits.length} 次编辑`,
command: COMMANDS.SHOW_CURRENT_FILE_DIFF,
tooltip: '在 VSCode 内置 Diff 中查看变更',
}),
new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), {
title: '$(check-all) 接受全部',
command: COMMANDS.ACCEPT_ALL,
tooltip: '接受此文件所有变更',
}),
new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), {
title: '$(trash) 拒绝全部',
command: COMMANDS.REJECT_ALL,
tooltip: '拒绝此文件所有变更',
})
);
// 为每个待处理的 EditRecord 添加按钮
for (const edit of fileChange.edits) {
if (edit.status !== 'pending') continue;
// 找到该编辑在 diffLines 中的第一个变更行
const firstChangedLine = this.findFirstChangeLine(edit);
if (firstChangedLine < 0) continue;
const pos = new vscode.Position(firstChangedLine, 0);
const range = new vscode.Range(pos, pos);
const toolLabel = edit.toolName === 'Write' ? '写入' : '编辑';
const summary = this.editSummary(edit);
// 标题行
lenses.push(
new vscode.CodeLens(range, {
title: `— AI ${toolLabel} #${edit.id.slice(0, 4)} · ${summary}`,
command: COMMANDS.SHOW_CURRENT_FILE_DIFF,
tooltip: '点击查看本次编辑的 Diff',
})
);
// Accept 按钮
codeLenses.push(
// Accept / Reject 按钮
lenses.push(
new vscode.CodeLens(range, {
title: '$(check) Accept',
command: COMMANDS.ACCEPT_CHUNK,
arguments: [snapshot.id, chunk.id],
tooltip: '接受此变更块',
})
);
// Reject 按钮
codeLenses.push(
command: COMMANDS.ACCEPT_CURRENT_EDIT,
arguments: [fileChange.id, edit.id],
tooltip: `接受此${toolLabel}`,
}),
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: '拒绝所有变更',
command: COMMANDS.REJECT_CURRENT_EDIT,
arguments: [fileChange.id, edit.id],
tooltip: `拒绝此${toolLabel}`,
})
);
}
return codeLenses;
return lenses;
}
/**
* 找到 EditRecord 在文件的 diffLines 中对应的第一个变更行号
*/
private findFirstChangeLine(edit: EditRecord): number {
for (const d of edit.diffLines) {
if (d.type === 'add' || d.type === 'delete') {
return d.newLineNum > 0 ? d.newLineNum - 1 : d.oldLineNum - 1;
}
}
return -1;
}
/**
* 编辑摘要: "+3 -2"
*/
private editSummary(edit: EditRecord): string {
const adds = edit.diffLines.filter(d => d.type === 'add').length;
const dels = edit.diffLines.filter(d => d.type === 'delete').length;
const parts: string[] = [];
if (adds > 0) parts.push(`+${adds}`);
if (dels > 0) parts.push(`-${dels}`);
return parts.join(' ');
}
}
+187 -78
View File
@@ -1,122 +1,231 @@
/**
* Diff 查看器 - 调用 VSCode 内置 Diff 编辑器
* Diff 查看器 - VSCode 内置 Diff 编辑器 + Accept/Reject 状态栏
*
* 使用 vscode.diff 命令展示原始内容 vs 变更后内容,
* 完全不使用自定义 Webview,保持 VSCode 原生交互体验。
* 关键:
* 1. 临时文件(非 untitled)避免保存提示
* 2. Accept/Reject 后自动推进到下一个 pending 文件
* 3. 状态栏按钮在 Diff 模式下可见
*/
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { v4 as uuidv4 } from 'uuid';
import { EditRecord, FileChange } from '../models/types';
import { ChangeSetManager } from '../snapshot/snapshotManager';
export interface DiffSession {
fileId: string;
editId?: string;
beforeTmpPath: string;
afterTmpPath: string;
beforeUri: vscode.Uri;
afterUri: vscode.Uri;
}
export class DiffViewer {
private activeSession: DiffSession | null = null;
private closeListener: vscode.Disposable | null = null;
private tmpFiles: string[] = [];
/** 外部回调:Diff 操作完成后通知(用于刷新 UI) */
onDiffAction?: () => void;
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),
});
getActiveSession(): DiffSession | null {
return this.activeSession;
}
// ---- 打开 Diff ----
async showEditDiff(editRecord: EditRecord, fileChangeId: string): Promise<void> {
const ext = path.extname(editRecord.filePath);
const fileName = path.basename(editRecord.filePath);
const toolLabel = editRecord.toolName === 'Write' ? '写入' : '编辑';
const beforePath = this.makeTmpFile(`ai-diff-before-${editRecord.id.slice(0, 8)}${ext}`, editRecord.beforeContent);
const afterPath = this.makeTmpFile(`ai-diff-after-${editRecord.id.slice(0, 8)}${ext}`, editRecord.afterContent);
this.setSession({
fileId: fileChangeId,
editId: editRecord.id,
beforeTmpPath: beforePath,
afterTmpPath: afterPath,
beforeUri: vscode.Uri.file(beforePath),
afterUri: vscode.Uri.file(afterPath),
});
await vscode.commands.executeCommand(
'vscode.diff',
beforeDoc.uri,
afterDoc.uri,
`${fileName} — AI ${toolLabel} #${editRecord.id.slice(0, 6)} (原始 ↔ 变更后)`
this.activeSession!.beforeUri,
this.activeSession!.afterUri,
`${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 ext = path.extname(fileChange.filePath);
const fileName = path.basename(fileChange.filePath);
const beforePath = this.makeTmpFile(`ai-diff-before-${fileChange.id.slice(0, 8)}${ext}`, fileChange.originalContent);
const afterPath = this.makeTmpFile(`ai-diff-after-${fileChange.id.slice(0, 8)}${ext}`, fileChange.latestContent);
this.setSession({
fileId: fileChange.id,
beforeTmpPath: beforePath,
afterTmpPath: afterPath,
beforeUri: vscode.Uri.file(beforePath),
afterUri: vscode.Uri.file(afterPath),
});
await vscode.commands.executeCommand(
'vscode.diff',
beforeDoc.uri,
afterDoc.uri,
`${fileName} — 原始 ↔ 变更后 (${fileChange.edits.length} 次编辑)`
this.activeSession!.beforeUri,
this.activeSession!.afterUri,
`${fileName} — ${fileChange.edits.length} 次 AI 编辑 · 状态栏操作`
);
}
/**
* 打开当前活动文件在编辑器中的 Diff
*/
async showCurrentFileDiff(): Promise<void> {
const changeSet = this.changeSetManager.getCurrentSet();
if (!changeSet) return;
const set = this.changeSetManager.getCurrentSet();
if (!set) return;
const editor = vscode.window.activeTextEditor;
if (!editor) return;
const normalizedPath = path.normalize(editor.document.uri.fsPath).replace(/\\/g, '/');
const fileChange = changeSet.changes.find(
const fc = set.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);
if (fc) await this.showFileDiff(fc);
else vscode.window.showInformationMessage('AI Diff: 当前文件无待处理变更');
}
/**
* 聚焦文件到编辑器(跳转到该文件并定位到第一个变更行)
*/
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 });
// ---- Diff 界面操作 (Bug 1 修复: 自动推进) ----
// 滚动到第一个变更行
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);
async acceptCurrentDiff(): Promise<void> {
if (!this.activeSession) return;
const { fileId, editId } = this.activeSession;
if (editId) {
this.changeSetManager.acceptEditRecord(fileId, editId);
} else {
this.changeSetManager.acceptFile(fileId);
}
vscode.window.showInformationMessage('AI Diff: 已接受 ✓');
await this.closeDiff();
this.onDiffAction?.();
// ★ Bug 1 修复: 自动推进到下一个待处理文件
this.advanceToNext(fileId);
}
async rejectCurrentDiff(): Promise<void> {
if (!this.activeSession) return;
const { fileId, editId } = this.activeSession;
if (editId) {
this.changeSetManager.rejectEditRecord(fileId, editId);
} else {
this.changeSetManager.rejectFile(fileId);
}
vscode.window.showInformationMessage('AI Diff: 已拒绝 ✗');
await this.closeDiff();
this.onDiffAction?.();
// ★ Bug 1 修复: 自动推进
this.advanceToNext(fileId);
}
/** ★ 接受当前文件的所有编辑 (Diff 界面 Accept All) */
async acceptAllFromDiff(): Promise<void> {
if (!this.activeSession) return;
const { fileId } = this.activeSession;
const ok = this.changeSetManager.acceptFile(fileId);
if (ok) {
vscode.window.showInformationMessage('AI Diff: 已接受此文件全部 ✓');
}
await this.closeDiff();
this.onDiffAction?.();
this.advanceToNext(fileId);
}
// ---- ★ Bug 1: 自动推进 ----
private advanceToNext(afterFileId: string): void {
// 如果此文件仍有待处理编辑,重新打开此文件
if (!this.changeSetManager.isFileFullyProcessed(afterFileId)) {
const set = this.changeSetManager.getCurrentSet();
const same = set?.changes.find(c => c.id === afterFileId);
if (same && same.status === 'pending') {
setTimeout(() => this.showFileDiff(same), 150);
return;
}
}
// 否则推进到下一个 pending 文件
const next = this.changeSetManager.findNextPending(afterFileId);
if (next) {
setTimeout(() => this.showFileDiff(next), 150);
} else {
setTimeout(() => {
vscode.window.showInformationMessage('AI Diff: 🎉 所有变更已处理完毕!');
}, 200);
}
}
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';
// ---- 关闭 ----
async closeDiff(): Promise<void> {
if (this.activeSession) {
try { await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); } catch { /* ok */ }
}
this.clearSession();
}
// ---- 内部 ----
private makeTmpFile(name: string, content: string): string {
const dir = path.join(os.tmpdir(), 'ai-diff-preview');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${uuidv4().slice(0, 8)}-${name}`);
fs.writeFileSync(filePath, content, 'utf-8');
this.tmpFiles.push(filePath);
return filePath;
}
private setSession(session: DiffSession): void {
this.clearSession();
this.activeSession = session;
this.closeListener = vscode.window.onDidChangeVisibleTextEditors(() => {
if (!this.activeSession) return;
const stillOpen = vscode.window.visibleTextEditors.some(
e => e.document.uri.toString() === this.activeSession!.beforeUri.toString() ||
e.document.uri.toString() === this.activeSession!.afterUri.toString()
);
if (!stillOpen) this.clearSession();
});
}
private clearSession(): void {
if (this.activeSession) {
[this.activeSession.beforeTmpPath, this.activeSession.afterTmpPath].forEach(p => {
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* ok */ }
});
this.tmpFiles = this.tmpFiles.filter(
f => f !== this.activeSession!.beforeTmpPath && f !== this.activeSession!.afterTmpPath
);
}
this.activeSession = null;
if (this.closeListener) { this.closeListener.dispose(); this.closeListener = null; }
}
cleanupTmpFiles(): void {
for (const p of this.tmpFiles) {
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* ok */ }
}
this.tmpFiles = [];
}
}
+58 -63
View File
@@ -1,133 +1,128 @@
/**
* 内联装饰器 - 渲染 Diff 高亮和按钮
* 内联装饰器 - 在编辑器中渲染 Diff 红绿高亮
*
* 基于 FileChange.diffLines 在编辑器内显示:
* - 绿色背景 + gutter 圆点: 新增行
* - 红色背景 + 删除线: 删除行
* - 行末浮动标签: ✓ Accept 本次编辑 · ✗ Reject
*/
import * as vscode from 'vscode';
import { DiffChunk, ChunkType } from '../models/types';
import { COLORS, DECORATION_TYPES } from '../models/constants';
import { FileChange, DiffLine } from '../models/types';
import { COLORS } 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;
private acceptLabelType: vscode.TextEditorDecorationType;
constructor() {
// 新增行: 绿色背景
this.addDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: COLORS.ADD_BACKGROUND,
isWholeLine: true,
gutterIconPath: this.createGutterIconPath(COLORS.ADD_GUTTER),
gutterIconPath: this.gutterIcon(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),
gutterIconPath: this.gutterIcon(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({
// 浮动标签: 在新增行的末尾显示 ✓ Accept
this.acceptLabelType = 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',
contentText: ' ← AI 变更',
color: '#888',
fontStyle: 'italic',
margin: '0 0 0 16px',
},
isWholeLine: true,
});
}
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 {
renderFileChange(editor: vscode.TextEditor, fileChange: FileChange): void {
if (fileChange.status !== 'pending') {
this.clearEditor(editor);
return;
}
const addRanges: vscode.Range[] = [];
const deleteRanges: vscode.Range[] = [];
const modifyRanges: vscode.Range[] = [];
const conflictRanges: vscode.Range[] = [];
const buttonRanges: vscode.Range[] = [];
const labelRanges: vscode.Range[] = [];
chunks.forEach(chunk => {
if (chunk.status === 'accepted' || chunk.status === 'rejected') {
return; // 跳过已处理的块
}
for (const d of fileChange.diffLines) {
if (d.type === 'context') continue;
const range = new vscode.Range(
new vscode.Position(chunk.startLine, 0),
new vscode.Position(chunk.endLine, 0)
);
// 使用 newLineNum(编辑器当前行号)
const line = d.newLineNum > 0 ? d.newLineNum - 1 : d.oldLineNum - 1;
if (line < 0) continue;
switch (chunk.type) {
const range = new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER);
switch (d.type) {
case 'add':
addRanges.push(range);
buttonRanges.push(range);
labelRanges.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);
editor.setDecorations(this.acceptLabelType, labelRanges);
}
/**
* 清除所有装饰器
* 清除编辑器的所有装饰
*/
clearDecorations(editor: vscode.TextEditor): void {
clearEditor(editor: vscode.TextEditor): void {
editor.setDecorations(this.addDecorationType, []);
editor.setDecorations(this.deleteDecorationType, []);
editor.setDecorations(this.modifyDecorationType, []);
editor.setDecorations(this.conflictDecorationType, []);
editor.setDecorations(this.acceptButtonDecorationType, []);
editor.setDecorations(this.acceptLabelType, []);
}
/**
* 释放资源
* 清除所有活跃编辑器的装饰
*/
clearAll(): void {
for (const editor of vscode.window.visibleTextEditors) {
this.clearEditor(editor);
}
}
dispose(): void {
this.addDecorationType.dispose();
this.deleteDecorationType.dispose();
this.modifyDecorationType.dispose();
this.conflictDecorationType.dispose();
this.acceptButtonDecorationType.dispose();
this.acceptLabelType.dispose();
}
private gutterIcon(color: string): vscode.Uri {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<rect x="2" y="2" width="12" height="12" rx="3" fill="${color}" opacity="0.8"/>
</svg>`;
return vscode.Uri.parse(`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`);
}
}
+164 -220
View File
@@ -1,39 +1,47 @@
/**
* Review 面板 - 内嵌 Webview 悬浮窗
* ★ 总览面板 — 展示所有变更文件的详细 Diff
*
* ★ v0.2.0: 支持 per-edit 和 per-file 两级 Accept/Reject
* - 每个文件卡片展开后显示各次编辑记录
* - 每次编辑有独立的 ✓/✗ 按钮
* - 文件级 Accept/Reject 一键处理所有编辑
* 功能:
* - 每个文件一个可折叠的卡片,默认展开
* - 卡片内: 双栏 Diff(原始 ↔ 变更后)
* - 文件级 Accept/Reject 按钮
* - 顶部全局 Accept All / Reject All
* - 全部处理后显示完成提示
*/
import * as vscode from 'vscode';
import { ChangeSetManager } from '../snapshot/snapshotManager';
import { ChangeSet, FileChange, EditRecord, ChangeStatus } from '../models/types';
import { DiffViewer } from './diffViewer';
import { ChangeSet } from '../models/types';
export class ReviewPanel {
private panel: vscode.WebviewPanel | undefined;
private changeSet: ChangeSet | undefined;
constructor(
private changeSetManager: ChangeSetManager
private changeSetManager: ChangeSetManager,
private diffViewer?: DiffViewer
) {}
/** 显示总览面板 */
show(): void {
const changeSet = this.changeSetManager.getCurrentSet();
if (!changeSet || changeSet.changes.length === 0) {
const cs = this.changeSetManager.getCurrentSet();
if (!cs || cs.changes.length === 0) {
vscode.window.showInformationMessage('AI Diff: 无变更');
return;
}
this.changeSet = cs;
if (this.panel) {
this.panel.reveal(vscode.ViewColumn.Active);
this.sendData(changeSet);
this.panel.reveal(vscode.ViewColumn.Beside);
this.sendData(cs);
return;
}
this.panel = vscode.window.createWebviewPanel(
'aiDiffReview',
'AI Diff Review',
{ viewColumn: vscode.ViewColumn.Active, preserveFocus: false },
'aiDiffOverview',
'AI Diff 总览',
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
{ enableScripts: true, retainContextWhenHidden: true }
);
@@ -42,30 +50,23 @@ export class ReviewPanel {
this.panel.webview.onDidReceiveMessage((msg: any) => {
switch (msg.type) {
case 'ready':
this.sendData(changeSet);
this.sendData(this.changeSet!);
break;
// 单次编辑 Accept/Reject
case 'acceptEdit':
this.changeSetManager.acceptEditRecord(msg.fileId, msg.editId);
this.refresh();
break;
case 'rejectEdit':
this.changeSetManager.rejectEditRecord(msg.fileId, msg.editId);
this.refresh();
break;
// 整个文件 Accept/Reject
case 'acceptFile':
this.changeSetManager.acceptFile(msg.fileId);
this.refresh();
// 如果有 DiffViewer 且与此文件匹配,关闭它
if (this.diffViewer?.getActiveSession()?.fileId === msg.fileId) {
this.diffViewer.closeDiff();
}
break;
case 'rejectFile':
this.changeSetManager.rejectFile(msg.fileId);
this.refresh();
if (this.diffViewer?.getActiveSession()?.fileId === msg.fileId) {
this.diffViewer.closeDiff();
}
break;
// 全局操作
case 'acceptAll':
this.changeSetManager.acceptAll();
this.refresh();
@@ -80,15 +81,16 @@ export class ReviewPanel {
this.panel.onDidDispose(() => { this.panel = undefined; });
}
private refresh(): void {
const changeSet = this.changeSetManager.getCurrentSet();
if (changeSet && this.panel) {
this.sendData(changeSet);
refresh(): void {
const cs = this.changeSetManager.getCurrentSet();
if (cs && this.panel) {
this.changeSet = cs;
this.sendData(cs);
}
}
private sendData(changeSet: ChangeSet): void {
this.panel?.webview.postMessage({ type: 'init', changeSet });
private sendData(cs: ChangeSet): void {
this.panel?.webview.postMessage({ type: 'init', changeSet: cs });
}
dispose(): void {
@@ -96,101 +98,90 @@ export class ReviewPanel {
this.panel = undefined;
}
// ---- HTML ----
private getHtml(): string {
return /*html*/`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';">
<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); }
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--vscode-font-family);font-size:13px;color:var(--vscode-foreground);background:var(--vscode-editor-background);padding:0}
.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;
.header{
display:flex;align-items:center;justify-content:space-between;
padding:12px 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;
.header h2{font-size:15px;font-weight:600}
.header .btn-row{display:flex;gap:8px}
.btn{
padding:6px 14px;border:none;border-radius:4px;cursor:pointer;
font-size:12px;font-weight:600;transition:opacity .15s
}
.btn:hover { opacity:.85; }
.btn-accept { background:#2ea043; color:#fff; }
.btn-reject { background:#da3633; color:#fff; }
.btn:hover{opacity:.85}
.btn-accept{background:#2ea043;color:#fff}
.btn-reject{background:#da3633;color:#fff}
.files { padding:8px 0; }
.files{padding:8px 0 24px}
.file-card {
margin:6px 12px; border:1px solid var(--vscode-panel-border);
border-radius:6px; overflow:hidden;
.file-card{
margin:8px 10px;border:1px solid var(--vscode-panel-border);
border-radius:8px;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-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; }
.file-card.accepted{opacity:.6;border-color:#2ea043}
.file-card.rejected{opacity:.5;border-color:#da3633;text-decoration:line-through}
/* Diff 双栏 */
.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);
.file-header{
display:flex;align-items:center;justify-content:space-between;
padding:10px 14px;background:var(--vscode-sideBar-background);
cursor:pointer;user-select:none
}
.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; }
.file-header:hover{background:var(--vscode-list-hoverBackground)}
.file-title{display:flex;align-items:center;gap:8px;font-weight:600}
.badge{
font-size:10px;padding:2px 7px;border-radius:3px;font-weight:700
}
.badge-mod{background:rgba(210,153,34,.2);color:#d29922}
.badge-new{background:rgba(46,160,67,.2);color:#3fb950}
.badge-del{background:rgba(248,81,73,.2);color:#f85149}
.badge-ok{background:rgba(46,160,67,.3);color:#3fb950}
.badge-no{background:rgba(248,81,73,.3);color:#f85149}
.file-stats{font-size:11px;display:flex;gap:8px;color:var(--vscode-descriptionForeground)}
.stat-add{color:#3fb950}
.stat-del{color:#f85149}
.file-actions{display:flex;gap:6px}
.btn-sm{padding:3px 10px;border:none;border-radius:3px;cursor:pointer;font-size:11px;font-weight:600}
/* ★ 编辑记录列表 */
.edits-list { padding:0; }
.edit-item {
border-bottom:1px solid var(--vscode-panel-border);
.diff-wrap{display:grid;grid-template-columns:1fr 1fr}
.diff-table{font-family:'Cascadia Code',Consolas,monospace;font-size:12px;line-height:20px;border-collapse:collapse;width:100%}
.diff-table td{padding:0 8px;white-space:pre;vertical-align:top}
.diff-table td.ln{text-align:right;color:var(--vscode-editorLineNumber-foreground);min-width:36px;user-select:none}
.diff-table tr.add{background:rgba(46,160,67,.15)}
.diff-table tr.del{background:rgba(248,81,73,.15)}
.diff-table tr.ctx{color:var(--vscode-descriptionForeground)}
.diff-table tr.empty{height:20px}
.diff-side-title{
padding:6px 12px;font-size:11px;font-weight:600;
border-bottom:1px solid var(--vscode-panel-border)
}
.edit-item:last-child { border-bottom:none; }
.edit-header {
display:flex; align-items:center; justify-content:space-between;
padding:6px 12px; background:var(--vscode-editor-background); cursor:pointer;
font-size:12px;
}
.edit-header:hover { background:var(--vscode-list-hoverBackground); }
.edit-label { display:flex; align-items:center; gap:6px; color:var(--vscode-descriptionForeground); }
.edit-label .edit-num { font-weight:600; color:var(--vscode-foreground); }
.edit-status { font-size:10px; }
.edit-actions { display:flex; gap:4px; }
.btn-xs { padding:2px 8px; border:none; border-radius:3px; cursor:pointer; font-size:10px; }
.left-title{background:rgba(248,81,73,.06);color:#f85149}
.right-title{background:rgba(46,160,67,.06);color:#3fb950}
.summary-stats { font-size:11px; color:var(--vscode-descriptionForeground); padding:4px 12px; }
.stat-add { color:#3fb950; }
.stat-del { color:#f85149; }
.empty-state{text-align:center;padding:40px;color:var(--vscode-descriptionForeground)}
.empty-state .icon{font-size:32px}
.empty-state h3{margin:12px 0 4px}
</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 class="header">
<h2>🔍 AI Diff 总览</h2>
<div class="btn-row">
<button class="btn btn-accept" onclick="acceptAll()">$(check-all) Accept All</button>
<button class="btn btn-reject" onclick="rejectAll()">$(trash) Reject All</button>
</div>
</div>
<div class="files" id="files"></div>
@@ -198,7 +189,6 @@ export class ReviewPanel {
<script>
const vscode = acquireVsCodeApi();
let cs = null;
vscode.postMessage({type:'ready'});
window.addEventListener('message', e => {
@@ -206,146 +196,100 @@ window.addEventListener('message', e => {
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 acceptEdit(fid, eid){ vscode.postMessage({type:'acceptEdit',fileId:fid,editId:eid}); }
function rejectEdit(fid, eid){ vscode.postMessage({type:'rejectEdit',fileId:fid,editId:eid}); }
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)));
const pending = cs.changes.filter(c => c.status === 'pending');
const done = cs.changes.filter(c => c.status !== 'pending');
if(cs.changes.length === 0){
el.innerHTML = '<div class="empty-state"><div class="icon">📭</div><h3>无变更</h3><p>所有文件均已处理</p></div>';
return;
}
if(pending.length === 0){
el.innerHTML = '<div class="empty-state"><div class="icon">🎉</div><h3>全部处理完毕</h3><p>' + cs.changes.length + ' 个文件已处理</p></div>';
return;
}
let html = '';
for(const c of cs.changes){
html += renderCard(c);
}
el.innerHTML = html;
}
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;
function renderCard(c){
const classes = c.status === 'accepted' ? 'file-card accepted' : c.status === 'rejected' ? 'file-card rejected' : 'file-card';
const name = c.filePath.split(/[\\\\/]/).pop();
const typeMap = {modify:['修改','badge-mod'],create:['新增','badge-new'],delete:['删除','badge-del']};
const [tlabel,tcls] = typeMap[c.type] || [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>';
if(c.status==='accepted') statusBadge = '<span class="badge badge-ok">已接受</span>';
if(c.status==='rejected') statusBadge = '<span class="badge badge-no">已拒绝</span>';
// 统计增减行数
let adds = 0, dels = 0;
c.diffLines.forEach(l => { if(l.type==='add') adds++; if(l.type==='delete') dels++; });
let adds=0,dels=0;
c.diffLines.forEach(l => { if(l.type==='add')adds++; if(l.type==='delete')dels++; });
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>' +
'<span class="summary-stats"><span class="stat-add">+' + adds + '</span> <span class="stat-del">-' + dels + '</span></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>' : ''
) +
const actions = c.status === 'pending' ?
'<span class="file-actions" onclick="event.stopPropagation()">' +
'<button class="btn-sm btn-accept" onclick="acceptFile(\'' + c.id + '\')">✔ 接受</button>' +
'<button class="btn-sm btn-reject" onclick="rejectFile(\'' + c.id + '\')">✘ 拒绝</button>' +
'</span>' : '';
return '<div class="' + classes + '">' +
'<div class="file-header" onclick="this.nextElementSibling.classList.toggle(\'hidden\')">' +
'<span class="file-title">📄 ' + esc(name) + ' <span class="badge ' + tcls + '">' + tlabel + '</span>' + statusBadge + '</span>' +
'<span class="file-stats"><span class="stat-add">+' + adds + '</span> <span class="stat-del">-' + dels + '</span> · ' + (c.edits||[]).length + ' 编辑</span>' +
actions +
'</div>' +
'<div class="diff-body">' +
buildEdits(c) +
'<div style="padding:6px 12px;font-size:11px;color:var(--vscode-descriptionForeground);border-top:1px solid var(--vscode-panel-border);">📊 聚合 Diff(原始 → 最新)</div>' +
buildDiff(c) +
'</div>';
return d;
}
/** ★ 构建编辑记录列表 */
function buildEdits(c){
if(!c.edits || c.edits.length === 0) return '';
const toolMap = {Edit:'✏️ 编辑', Write:'📝 写入'};
let h = '<div class="edits-list">';
c.edits.forEach((edit, i) => {
let statusCls = '', statusText = '';
if(edit.status==='accepted'){ statusCls='badge-accepted'; statusText='已接受'; }
if(edit.status==='rejected'){ statusCls='badge-rejected'; statusText='已拒绝'; }
let editAdds = 0, editDels = 0;
(edit.diffLines||[]).forEach(l => { if(l.type==='add') editAdds++; if(l.type==='delete') editDels++; });
h += '<div class="edit-item">' +
'<div class="edit-header" onclick="event.stopPropagation();this.nextElementSibling.classList.toggle(' + "'" + 'collapsed' + "'" + ')">' +
'<span class="edit-label">' +
'<span class="edit-num">#' + (i+1) + '</span> ' + (toolMap[edit.toolName]||edit.toolName) +
' <span class="stat-add">+' + editAdds + '</span> <span class="stat-del">-' + editDels + '</span>' +
(statusText ? ' <span class="badge ' + statusCls + ' edit-status">' + statusText + '</span>' : '') +
'</span>' +
(edit.status==='pending' ?
'<span class="edit-actions">' +
'<button class="btn-xs btn-accept" onclick="event.stopPropagation();acceptEdit(' + "'" + c.id + "','" + edit.id + "'" + ')">✓</button>' +
'<button class="btn-xs btn-reject" onclick="event.stopPropagation();rejectEdit(' + "'" + c.id + "','" + edit.id + "'" + ')">✗</button>' +
'</span>' : ''
) +
'</div>' +
'<div class="">' + buildEditDiff(edit) + '</div>' +
'</div>';
});
h += '</div>';
return h;
}
/** 单次编辑的 Diff */
function buildEditDiff(edit){
let lh='', rh='';
(edit.diffLines||[]).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 class="">' + buildDiff(c) + '</div>' +
'</div>';
}
function buildDiff(c){
if(c.type==='create') return buildSingle(c.latestContent.split('\\n'),'right','新文件');
if(c.type==='delete') return buildSingle(c.originalContent.split('\\n'),'left','已删除');
return buildDual(c.diffLines||[]);
if(c.type === 'create') return buildSingle(c.latestContent, '新文件内容 (右侧)');
if(c.type === 'delete') return buildSingle(c.originalContent, '已删除 (原始内容)');
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,i) => {
h += '<div class="diff-line add"><span class="ln">' + (i+1) + '</span><span class="lc">' + esc(l) + '</span></div>';
});
return h + '</div></div>';
function buildSingle(text, label){
const lines = text.split('\\n');
let rows = '';
lines.forEach((l,i) => { rows += '<tr class="add"><td class="ln">'+(i+1)+'</td><td>'+esc(l)+'</td></tr>'; });
return '<div style="padding:6px 12px;font-size:11px;color:var(--vscode-descriptionForeground);border-bottom:1px solid var(--vscode-panel-border)">📄 '+label+'</div>' +
'<table class="diff-table">'+rows+'</table>';
}
function buildDual(dlines){
let lh='', rh='';
let left='',right='';
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>';
left += '<tr class="ctx"><td class="ln">'+l.oldLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
right += '<tr class="ctx"><td class="ln">'+l.newLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
} 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>';
left += '<tr class="del"><td class="ln">'+l.oldLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
right += '<tr class="empty"><td></td><td></td></tr>';
} 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>';
left += '<tr class="empty"><td></td><td></td></tr>';
right += '<tr class="add"><td class="ln">'+l.newLineNum+'</td><td>'+esc(l.content)+'</td></tr>';
}
});
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><div class="diff-side-title left-title">— 原始代码</div><table class="diff-table">'+left+'</table></div>' +
'<div><div class="diff-side-title right-title">+ 变更后</div><table class="diff-table">'+right+'</table></div>' +
'</div>';
}
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
</script>
<style>.collapsed .diff-body{display:none;} .collapsed{display:none;}</style>
<style>.hidden{display:none !important}</style>
</body>
</html>`;
}
+106 -49
View File
@@ -1,70 +1,127 @@
/**
* 状态栏管理器 - 显示 Diff 状态信息
* 状态栏管理器 — 常驻显示
*
* 始终在左侧状态栏显示 AI Diff 状态:
* - 有 pending 变更: $(diff) AI Diff: N文件 M编辑 (黄色) — 点击打开文件列表
* - Diff 活跃: 右侧额外显示 Accept/Reject 按钮
* - 无变更: $(diff) AI Diff (暗色) — 点击打开文件列表
*/
import * as vscode from 'vscode';
import { Snapshot } from '../models/types';
import { STATUS_BAR_PRIORITY } from '../models/constants';
import { ChangeSetManager } from '../snapshot/snapshotManager';
import { DiffViewer, DiffSession } from './diffViewer';
import { COMMANDS } from '../models/constants';
export class StatusBarManager {
private statusBarItem: vscode.StatusBarItem;
private chunkCountItem: vscode.StatusBarItem;
/** ★ 常驻摘要按钮 */
private summaryItem: vscode.StatusBarItem;
/** Diff 模式 Accept 按钮 */
private acceptBtn: vscode.StatusBarItem;
/** Diff 模式 Reject 按钮 */
private rejectBtn: vscode.StatusBarItem;
/** Diff 模式 Accept All 按钮 */
private acceptAllBtn: vscode.StatusBarItem;
constructor() {
this.statusBarItem = vscode.window.createStatusBarItem(
constructor(
private changeSetManager: ChangeSetManager,
private diffViewer: DiffViewer
) {
// ★ 常驻摘要 — 始终显示
this.summaryItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
STATUS_BAR_PRIORITY.DIFF_STATUS
100
);
this.summaryItem.command = COMMANDS.SHOW_DIFF_PANEL;
this.summaryItem.text = '$(diff) AI Diff';
this.summaryItem.tooltip = 'AI Diff — 点击查看变更列表';
this.summaryItem.show();
this.chunkCountItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
STATUS_BAR_PRIORITY.CHUNK_COUNT
// Accept 按钮
this.acceptBtn = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Right,
100
);
this.acceptBtn.command = 'aiDiffPreview.acceptCurrentDiff';
// Reject 按钮
this.rejectBtn = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Right,
99
);
this.rejectBtn.command = 'aiDiffPreview.rejectCurrentDiff';
// Accept All 按钮
this.acceptAllBtn = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Right,
98
);
this.acceptAllBtn.command = 'aiDiffPreview.acceptAllFromDiff';
}
/**
* 更新状态栏
*/
update(snapshot: Snapshot): void {
const pendingChunks = snapshot.chunks.filter(c => c.status === 'pending');
const conflictChunks = snapshot.chunks.filter(c => c.status === 'conflict');
enterDiffMode(session: DiffSession): void {
const label = session.editId ? '编辑' : '文件';
this.acceptBtn.text = `$(check) Accept 本次${label}变更`;
this.acceptBtn.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
this.acceptBtn.tooltip = `接受此${label}的变更并跳转到下一个`;
this.acceptBtn.show();
// 更新主状态
this.statusBarItem.text = '$(diff) AI Diff';
this.statusBarItem.tooltip = 'AI Diff Preview 活跃';
this.statusBarItem.show();
this.rejectBtn.text = `$(close) Reject 本次${label}变更`;
this.rejectBtn.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
this.rejectBtn.tooltip = `拒绝此${label}的变更并跳转到下一个`;
this.rejectBtn.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();
this.acceptAllBtn.text = '$(check-all) 接受此文件全部';
this.acceptAllBtn.tooltip = '接受此文件的所有编辑';
this.acceptAllBtn.show();
}
exitDiffMode(): void {
this.acceptBtn.hide();
this.rejectBtn.hide();
this.acceptAllBtn.hide();
}
/** ★ 常驻刷新 */
refresh(): void {
const session = this.diffViewer.getActiveSession();
if (session) {
this.enterDiffMode(session);
} else {
this.chunkCountItem.hide();
this.exitDiffMode();
}
const set = this.changeSetManager.getCurrentSet();
if (!set) {
// ★ 无变更集也常驻显示
this.summaryItem.text = '$(diff) AI Diff';
this.summaryItem.tooltip = 'AI Diff — 等待变更';
this.summaryItem.backgroundColor = undefined;
this.summaryItem.show();
return;
}
const pendingFiles = set.changes.filter(c => c.status === 'pending');
const totalEdits = pendingFiles.reduce(
(sum, c) => sum + c.edits.filter(e => e.status === 'pending').length, 0
);
if (pendingFiles.length === 0 && set.changes.length > 0) {
this.summaryItem.text = '$(diff) AI Diff: 已完成';
this.summaryItem.tooltip = '所有变更已处理';
this.summaryItem.backgroundColor = undefined;
} else if (pendingFiles.length > 0) {
this.summaryItem.text = `$(diff) AI Diff: ${pendingFiles.length}文件 ${totalEdits}编辑`;
this.summaryItem.tooltip = `点击查看变更列表\n${pendingFiles.map(c => `• ${c.filePath} (${c.edits.length}次)`).join('\n')}`;
this.summaryItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
}
this.summaryItem.show();
}
/**
* 清除状态栏
*/
clear(): void {
this.statusBarItem.hide();
this.chunkCountItem.hide();
}
/**
* 释放资源
*/
dispose(): void {
this.statusBarItem.dispose();
this.chunkCountItem.dispose();
this.summaryItem.dispose();
this.acceptBtn.dispose();
this.rejectBtn.dispose();
this.acceptAllBtn.dispose();
}
}
}