mirror of
https://github.com/Gmaker689/ai-diff-preview.git
synced 2026-09-30 17:11:52 +08:00
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:
@@ -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
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') }
|
||||
</script>
|
||||
<style>.collapsed .diff-body{display:none;} .collapsed{display:none;}</style>
|
||||
<style>.hidden{display:none !important}</style>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
+106
-49
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user