mirror of
https://github.com/Gmaker689/ai-diff-preview.git
synced 2026-09-30 13:11:52 +08:00
fix:修复diff范围问题
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* ChangeSetManager 测试 — Bug 回归 + EditRecord 粒度验证
|
||||
*
|
||||
* 重要:模拟真实 Claude 流程
|
||||
* 1. Claude 修改文件 → 文件在磁盘上已变更
|
||||
* 2. pending.json 写入 → recordEdit() 被调用
|
||||
* 3. recordEdit() 读取已修改的文件 → 反推原始内容
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ChangeSetManager } from '../snapshot/snapshotManager';
|
||||
|
||||
describe('ChangeSetManager', () => {
|
||||
let manager: ChangeSetManager;
|
||||
let tmpDir: string;
|
||||
let testFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new ChangeSetManager();
|
||||
manager.startNewSet();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-diff-test-'));
|
||||
testFile = path.join(tmpDir, 'test.ts');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeFile(content: string): void {
|
||||
fs.writeFileSync(testFile, content, 'utf-8');
|
||||
}
|
||||
|
||||
function readFile(): string {
|
||||
return fs.readFileSync(testFile, 'utf-8');
|
||||
}
|
||||
|
||||
describe('EditRecord 独立记录', () => {
|
||||
it('每次 Edit 应创建独立 EditRecord', () => {
|
||||
// 模拟真实流程:文件已在磁盘上被 Claude 修改
|
||||
writeFile('line1\nline2-modified\nline3\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'line2', 'line2-modified', '');
|
||||
|
||||
// 第二次修改
|
||||
writeFile('line1\nline2-modified\nline3-modified\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'line3', 'line3-modified', '');
|
||||
|
||||
const set = manager.getCurrentSet()!;
|
||||
expect(set.changes).toHaveLength(1);
|
||||
|
||||
const fileChange = set.changes[0];
|
||||
expect(fileChange.edits).toHaveLength(2);
|
||||
expect(fileChange.edits[0].status).toBe('pending');
|
||||
expect(fileChange.edits[1].status).toBe('pending');
|
||||
|
||||
// 第一次编辑的 Diff 应该包含 line2 的变更
|
||||
const edit1Diffs = fileChange.edits[0].diffLines;
|
||||
expect(edit1Diffs.some(d => d.type === 'add' && d.content.includes('line2-modified'))).toBe(true);
|
||||
|
||||
// 第二次编辑的 Diff 应该包含 line3 的变更
|
||||
const edit2Diffs = fileChange.edits[1].diffLines;
|
||||
expect(edit2Diffs.some(d => d.type === 'add' && d.content.includes('line3-modified'))).toBe(true);
|
||||
|
||||
// FileChange 聚合 Diff 应包含所有变更
|
||||
expect(fileChange.diffLines.some(d => d.content.includes('line2-modified'))).toBe(true);
|
||||
expect(fileChange.diffLines.some(d => d.content.includes('line3-modified'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 修复: Accept 后再编辑', () => {
|
||||
it('Accept 后再次编辑应创建新的 FileChange', () => {
|
||||
// 第一次编辑(文件已在磁盘上被 Claude 修改)
|
||||
writeFile('line1\nline2-changed\nline3\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'line2', 'line2-changed', '');
|
||||
|
||||
const set1 = manager.getCurrentSet()!;
|
||||
const fileId1 = set1.changes[0].id;
|
||||
manager.acceptFile(fileId1);
|
||||
expect(set1.changes[0].status).toBe('accepted');
|
||||
|
||||
// ★ 第二次编辑(同一文件,再次被 Claude 修改)
|
||||
writeFile('line1\nline2-changed\nline3-changed\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'line3', 'line3-changed', '');
|
||||
|
||||
const set2 = manager.getCurrentSet()!;
|
||||
// ★ 应该有 2 个 FileChange(旧的 accepted + 新的 pending)
|
||||
expect(set2.changes).toHaveLength(2);
|
||||
expect(set2.changes[0].status).toBe('accepted');
|
||||
expect(set2.changes[1].status).toBe('pending');
|
||||
expect(set2.changes[1].edits).toHaveLength(1);
|
||||
expect(set2.changes[1].edits[0].newString).toBe('line3-changed');
|
||||
});
|
||||
|
||||
it('Accept All 后再次编辑应独立追踪', () => {
|
||||
writeFile('line1-new\nline2\nline3\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'line1', 'line1-new', '');
|
||||
|
||||
manager.acceptAll();
|
||||
expect(manager.getCurrentSet()!.changes[0].status).toBe('accepted');
|
||||
|
||||
writeFile('line1-new\nline2-new\nline3\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'line2', 'line2-new', '');
|
||||
|
||||
const set = manager.getCurrentSet()!;
|
||||
expect(set.changes).toHaveLength(2);
|
||||
expect(set.changes[1].status).toBe('pending');
|
||||
expect(set.changes[1].edits[0].newString).toBe('line2-new');
|
||||
});
|
||||
|
||||
it('Reject 后再次编辑应创建新 FileChange', () => {
|
||||
writeFile('line1\nline2-bad\nline3\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'line2', 'line2-bad', '');
|
||||
|
||||
const fileId = manager.getCurrentSet()!.changes[0].id;
|
||||
manager.rejectFile(fileId);
|
||||
|
||||
// Reject 后文件应恢复到原始内容
|
||||
expect(readFile()).toBe('line1\nline2\nline3\n');
|
||||
|
||||
// 第二次编辑
|
||||
writeFile('line1\nline2\nline3-good\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'line3', 'line3-good', '');
|
||||
|
||||
const set = manager.getCurrentSet()!;
|
||||
expect(set.changes).toHaveLength(2);
|
||||
expect(set.changes[0].status).toBe('rejected');
|
||||
expect(set.changes[1].status).toBe('pending');
|
||||
expect(set.changes[1].edits[0].newString).toBe('line3-good');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Per-edit Accept/Reject', () => {
|
||||
it('acceptEditRecord 应只接受单个编辑', () => {
|
||||
writeFile('aaa\nBBB\nCCC\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'AAA', 'aaa', '');
|
||||
|
||||
writeFile('aaa\nBBB\nccc\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'CCC', 'ccc', '');
|
||||
|
||||
const fileChange = manager.getCurrentSet()!.changes[0];
|
||||
const edit1Id = fileChange.edits[0].id;
|
||||
const edit2Id = fileChange.edits[1].id;
|
||||
|
||||
// 只接受第一个编辑
|
||||
manager.acceptEditRecord(fileChange.id, edit1Id);
|
||||
|
||||
const updated = manager.getCurrentSet()!.changes[0];
|
||||
expect(updated.edits[0].status).toBe('accepted');
|
||||
expect(updated.edits[1].status).toBe('pending');
|
||||
expect(updated.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('全部编辑接受后文件状态应变为 accepted', () => {
|
||||
writeFile('new-line1\nline2\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'line1', 'new-line1', '');
|
||||
|
||||
writeFile('new-line1\nnew-line2\n');
|
||||
manager.recordEdit(testFile, 'Edit', 'line2', 'new-line2', '');
|
||||
|
||||
const fc = manager.getCurrentSet()!.changes[0];
|
||||
manager.acceptEditRecord(fc.id, fc.edits[0].id);
|
||||
manager.acceptEditRecord(fc.id, fc.edits[1].id);
|
||||
|
||||
const updated = manager.getCurrentSet()!.changes[0];
|
||||
expect(updated.status).toBe('accepted');
|
||||
expect(updated.edits.every(e => e.status === 'accepted')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+74
-8
@@ -15,6 +15,11 @@ export interface ClaudeHookEvent {
|
||||
toolName: string; // Edit | Write | Stop
|
||||
}
|
||||
|
||||
/**
|
||||
* 变更状态(编辑记录和文件变更共用)
|
||||
*/
|
||||
export type ChangeStatus = 'pending' | 'accepted' | 'rejected';
|
||||
|
||||
/**
|
||||
* 文件变更类型
|
||||
*/
|
||||
@@ -23,23 +28,45 @@ export type FileChangeType = 'create' | 'modify' | 'delete';
|
||||
/**
|
||||
* 文件变更状态
|
||||
*/
|
||||
export type FileChangeStatus = 'pending' | 'accepted' | 'rejected';
|
||||
export type FileChangeStatus = ChangeStatus;
|
||||
|
||||
/**
|
||||
* 单个文件的变更
|
||||
* ★ 单次编辑记录(每次 Edit/Write 独立记录,不合并)
|
||||
*/
|
||||
export interface EditRecord {
|
||||
id: string;
|
||||
filePath: string;
|
||||
toolName: 'Edit' | 'Write';
|
||||
/** 编辑前的文件完整快照 */
|
||||
beforeContent: string;
|
||||
/** 编辑后的文件完整快照 */
|
||||
afterContent: string;
|
||||
/** Edit: 被替换的文本片段; Write: 空字符串 */
|
||||
oldString: string;
|
||||
/** Edit: 替换后的文本片段; Write: 写入的完整内容 */
|
||||
newString: string;
|
||||
/** 本次编辑的独立 Diff */
|
||||
diffLines: DiffLine[];
|
||||
status: ChangeStatus;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个文件的变更(聚合一个或多个 EditRecord)
|
||||
*/
|
||||
export interface FileChange {
|
||||
id: string;
|
||||
filePath: string;
|
||||
type: FileChangeType;
|
||||
/** 修改前的内容(modify/delete 时有值) */
|
||||
oldContent: string;
|
||||
/** 修改后的内容(modify/create 时有值) */
|
||||
newContent: string;
|
||||
/** Diff 行 */
|
||||
/** ★ 该文件的所有编辑记录(不合并) */
|
||||
edits: EditRecord[];
|
||||
/** 第一次编辑前的原始内容 */
|
||||
originalContent: string;
|
||||
/** 最后一次编辑后的最新内容 */
|
||||
latestContent: string;
|
||||
/** 聚合 Diff(originalContent → latestContent) */
|
||||
diffLines: DiffLine[];
|
||||
status: FileChangeStatus;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +100,7 @@ export interface ChangeSet {
|
||||
export type ReviewPanelMessageToWebview =
|
||||
| { type: 'init'; changeSet: ChangeSet }
|
||||
| { type: 'updateFileStatus'; fileId: string; status: FileChangeStatus }
|
||||
| { type: 'updateEditStatus'; fileId: string; editId: string; status: ChangeStatus }
|
||||
| { type: 'updateAllStatus'; status: FileChangeStatus };
|
||||
|
||||
/**
|
||||
@@ -81,6 +109,44 @@ export type ReviewPanelMessageToWebview =
|
||||
export type ReviewPanelMessageFromWebview =
|
||||
| { type: 'acceptFile'; fileId: string }
|
||||
| { type: 'rejectFile'; fileId: string }
|
||||
| { type: 'acceptEdit'; fileId: string; editId: string }
|
||||
| { type: 'rejectEdit'; fileId: string; editId: string }
|
||||
| { type: 'acceptAll' }
|
||||
| { type: 'rejectAll' }
|
||||
| { type: 'ready' };
|
||||
|
||||
// ---- 以下为遗留类型,供未迁移的模块使用 ----
|
||||
|
||||
/** @legacy Diff 变更块类型 */
|
||||
export type ChunkType = 'add' | 'delete' | 'modify';
|
||||
|
||||
/** @legacy Diff 变更块 */
|
||||
export interface DiffChunk {
|
||||
id: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
type: ChunkType;
|
||||
content: string;
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'conflict';
|
||||
conflictWith?: string;
|
||||
}
|
||||
|
||||
/** @legacy 快照类型 */
|
||||
export interface Snapshot {
|
||||
id: string;
|
||||
filePath: string;
|
||||
baseContent: string;
|
||||
suggestContent: string;
|
||||
timestamp: number;
|
||||
chunks: DiffChunk[];
|
||||
status: 'active' | 'completed' | 'cancelled';
|
||||
}
|
||||
|
||||
/** @legacy 事务结果 */
|
||||
export interface TransactionResult {
|
||||
success: boolean;
|
||||
snapshotId: string;
|
||||
chunkId?: string;
|
||||
action: 'accept' | 'reject';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
+120
-18
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Review 面板 - 内嵌 Webview 悬浮窗
|
||||
*
|
||||
* 双栏 Diff + Accept/Reject 按钮,不弹系统窗口
|
||||
* ★ v0.2.0: 支持 per-edit 和 per-file 两级 Accept/Reject
|
||||
* - 每个文件卡片展开后显示各次编辑记录
|
||||
* - 每次编辑有独立的 ✓/✗ 按钮
|
||||
* - 文件级 Accept/Reject 一键处理所有编辑
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { ChangeSetManager } from '../snapshot/snapshotManager';
|
||||
import { ChangeSet, FileChange, FileChangeStatus } from '../models/types';
|
||||
import { ChangeSet, FileChange, EditRecord, ChangeStatus } from '../models/types';
|
||||
|
||||
export class ReviewPanel {
|
||||
private panel: vscode.WebviewPanel | undefined;
|
||||
@@ -41,23 +44,33 @@ export class ReviewPanel {
|
||||
case 'ready':
|
||||
this.sendData(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.updateFileStatus(msg.fileId, 'accepted');
|
||||
this.changeSetManager.applyFileChange(msg.fileId);
|
||||
this.changeSetManager.acceptFile(msg.fileId);
|
||||
this.refresh();
|
||||
break;
|
||||
case 'rejectFile':
|
||||
this.changeSetManager.updateFileStatus(msg.fileId, 'rejected');
|
||||
this.changeSetManager.rejectFileChange(msg.fileId);
|
||||
this.changeSetManager.rejectFile(msg.fileId);
|
||||
this.refresh();
|
||||
break;
|
||||
|
||||
// 全局操作
|
||||
case 'acceptAll':
|
||||
this.changeSetManager.updateAllStatus('accepted');
|
||||
this.changeSetManager.applyAllAccepted();
|
||||
this.changeSetManager.acceptAll();
|
||||
this.refresh();
|
||||
break;
|
||||
case 'rejectAll':
|
||||
this.changeSetManager.updateAllStatus('rejected');
|
||||
this.changeSetManager.rejectAll();
|
||||
this.refresh();
|
||||
break;
|
||||
@@ -124,12 +137,12 @@ export class ReviewPanel {
|
||||
}
|
||||
.badge-modify { background:rgba(210,153,34,.2); color:#d29922; }
|
||||
.badge-create { background:rgba(46,160,67,.2); color:#3fb950; }
|
||||
.badge-delete { background:rgba(248,81,73,.2); color:#f85149; }
|
||||
.badge-accepted { background:rgba(46,160,67,.3); color:#3fb950; }
|
||||
.badge-rejected { background:rgba(248,81,73,.3); color:#f85149; text-decoration:line-through; }
|
||||
.file-actions { display:flex; gap:6px; }
|
||||
.btn-sm { padding:3px 10px; border:none; border-radius:3px; cursor:pointer; font-size:11px; }
|
||||
|
||||
/* Diff 双栏 */
|
||||
.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); }
|
||||
@@ -148,6 +161,28 @@ export class ReviewPanel {
|
||||
.diff-side.left .diff-line.add { background:transparent; }
|
||||
.diff-side.right .diff-line.del { background:transparent; }
|
||||
.empty-line { min-height:20px; }
|
||||
|
||||
/* ★ 编辑记录列表 */
|
||||
.edits-list { padding:0; }
|
||||
.edit-item {
|
||||
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; }
|
||||
|
||||
.summary-stats { font-size:11px; color:var(--vscode-descriptionForeground); padding:4px 12px; }
|
||||
.stat-add { color:#3fb950; }
|
||||
.stat-del { color:#f85149; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -175,6 +210,8 @@ 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 render(){
|
||||
if(!cs) return;
|
||||
@@ -189,34 +226,99 @@ function card(c){
|
||||
const name = c.filePath.split(/[/\\\\]/).pop();
|
||||
const typeMap = {modify:'修改',create:'新增',delete:'删除'};
|
||||
const badgeCls = 'badge badge-' + c.type;
|
||||
|
||||
let statusBadge = '';
|
||||
if(c.status==='accepted') statusBadge = '<span class="badge badge-accepted">已接受</span>';
|
||||
if(c.status==='rejected') statusBadge = '<span class="badge badge-rejected">已拒绝</span>';
|
||||
|
||||
// 统计增减行数
|
||||
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>' +
|
||||
'<button class="btn-sm btn-accept" onclick="event.stopPropagation();acceptFile(' + "'" + c.id + "'" + ')">✓ 全部接受</button>' +
|
||||
'<button class="btn-sm btn-reject" onclick="event.stopPropagation();rejectFile(' + "'" + c.id + "'" + ')">✗ 全部拒绝</button>' +
|
||||
'</span>' : ''
|
||||
) +
|
||||
'</div>' +
|
||||
'<div class="diff-body">' + buildDiff(c) + '</div>';
|
||||
'<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>';
|
||||
}
|
||||
|
||||
function buildDiff(c){
|
||||
if(c.type==='create') return buildSingle(c.newLines||[],'right','新文件');
|
||||
if(c.type==='delete') return buildSingle(c.oldLines||[],'left','已删除');
|
||||
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||[]);
|
||||
}
|
||||
|
||||
function buildSingle(lines,side,title){
|
||||
let h = '<div class="diff-wrap"><div class="diff-side ' + side + '"><div class="diff-title">' + title + '</div>';
|
||||
lines.forEach(l => {
|
||||
h += '<div class="diff-line add"><span class="ln">' + (l.num||'') + '</span><span class="lc">' + esc(l.content||l) + '</span></div>';
|
||||
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>';
|
||||
}
|
||||
@@ -243,7 +345,7 @@ function buildDual(dlines){
|
||||
|
||||
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
</script>
|
||||
<style>.collapsed .diff-body{display:none;}</style>
|
||||
<style>.collapsed .diff-body{display:none;} .collapsed{display:none;}</style>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
+268
-82
@@ -1,11 +1,16 @@
|
||||
/**
|
||||
* 变更集管理器 - 收集一轮对话中的所有文件变更
|
||||
*
|
||||
* ★ v0.2.0: 引入 EditRecord 粒度
|
||||
* - 每次 Edit/Write 创建独立 EditRecord(不合并)
|
||||
* - FileChange 作为聚合视图,持有 edits: EditRecord[]
|
||||
* - Accept 后再编辑 → 自动创建新 FileChange(修复 Bug)
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ChangeSet, FileChange, FileChangeType, FileChangeStatus, DiffLine } from '../models/types';
|
||||
import { ChangeSet, FileChange, EditRecord, FileChangeType, ChangeStatus, DiffLine } from '../models/types';
|
||||
import { diffLines, Change } from 'diff';
|
||||
|
||||
function norm(p: string): string {
|
||||
@@ -14,7 +19,7 @@ function norm(p: string): string {
|
||||
|
||||
export class ChangeSetManager {
|
||||
private currentSet: ChangeSet | null = null;
|
||||
/** 原始文件内容快照(首次编辑前捕获) */
|
||||
/** 原始文件内容快照(每个文件第一次编辑前捕获) */
|
||||
private originalContents: Map<string, string> = new Map();
|
||||
|
||||
startNewSet(): string {
|
||||
@@ -44,14 +49,13 @@ export class ChangeSetManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次文件编辑
|
||||
* ★ 记录一次文件编辑(重构后)
|
||||
*
|
||||
* Edit: oldString=修改前的代码片段, newString=修改后的代码片段
|
||||
* → 文件当前内容 = 原始内容中 oldString 被替换为 newString 后的结果
|
||||
* → 原始内容 = 当前内容中首次出现的 newString 替换回 oldString
|
||||
*
|
||||
* Write: content=写入的完整内容
|
||||
* → 原始内容无法获取(已覆写),标记为 create
|
||||
* 核心逻辑:
|
||||
* 1. 每次 Edit/Write 创建独立 EditRecord
|
||||
* 2. 如果该文件已有 pending 的 FileChange → 追加 EditRecord
|
||||
* 3. 如果该文件的 FileChange 已 accepted/rejected → 创建新 FileChange(修复 Bug)
|
||||
* 4. 如果该文件没有 FileChange → 创建新 FileChange
|
||||
*/
|
||||
recordEdit(filePath: string, toolName: string, oldString: string, newString: string, _content: string): void {
|
||||
if (!this.currentSet) {
|
||||
@@ -59,46 +63,68 @@ export class ChangeSetManager {
|
||||
}
|
||||
|
||||
filePath = norm(filePath);
|
||||
const currentContent = this.readFile(filePath) || '';
|
||||
const now = Date.now();
|
||||
|
||||
console.log(`[AI Diff] recordEdit: ${toolName} → ${filePath}`);
|
||||
|
||||
// 首次编辑该文件时,捕获原始内容
|
||||
if (!this.originalContents.has(filePath)) {
|
||||
if (toolName === 'Write') {
|
||||
// Write: 无法获取旧内容
|
||||
this.originalContents.set(filePath, '');
|
||||
} else if (toolName === 'Edit' && oldString) {
|
||||
// Edit: 当前文件 = oldString 被替换为 newString 后的结果
|
||||
// 反推: 当前文件中找 newString 替换回 oldString = 原始内容
|
||||
const currentContent = this.readFile(filePath);
|
||||
if (currentContent !== null) {
|
||||
const idx = currentContent.indexOf(newString);
|
||||
if (idx !== -1) {
|
||||
const original = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length);
|
||||
this.originalContents.set(filePath, original);
|
||||
console.log(`[AI Diff] 捕获原始内容: ${filePath} (${original.length} 字符)`);
|
||||
} else {
|
||||
// newString 不在文件中,可能已经被之前的编辑影响
|
||||
// 用 oldString 作为最小化的原始内容
|
||||
this.originalContents.set(filePath, oldString);
|
||||
console.log(`[AI Diff] newString 未找到,用 oldString 作为原始内容`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Step 1: 查找该文件是否已有变更记录
|
||||
const existingChange = this.currentSet!.changes.find(c => norm(c.filePath) === filePath);
|
||||
|
||||
// ★ 如果已有变更记录且已 accepted/rejected,需要重置缓存以重新捕获
|
||||
if (existingChange && existingChange.status !== 'pending') {
|
||||
this.originalContents.delete(filePath);
|
||||
}
|
||||
|
||||
// 更新文件变更记录
|
||||
const existing = this.currentSet.changes.find(c => norm(c.filePath) === filePath);
|
||||
const currentContent = this.readFile(filePath) || '';
|
||||
const originalContent = this.originalContents.get(filePath) || '';
|
||||
// Step 2: 确保已捕获原始内容
|
||||
const capturedOriginal = this.captureOriginalContent(filePath, toolName, oldString, newString, currentContent);
|
||||
|
||||
if (existing) {
|
||||
// 多次编辑同一文件:更新 newContent 和 diff
|
||||
existing.newContent = currentContent;
|
||||
existing.diffLines = this.computeDiffLines(originalContent, currentContent);
|
||||
console.log(`[AI Diff] 更新变更记录: ${filePath}, diff=${existing.diffLines.length} 行`);
|
||||
// Step 3: 确定本次编辑的 beforeContent
|
||||
let beforeContent: string;
|
||||
|
||||
if (existingChange) {
|
||||
const lastEdit = existingChange.edits[existingChange.edits.length - 1];
|
||||
if (existingChange.status === 'pending') {
|
||||
// 还在收集中,before 使用上一次编辑的 afterContent
|
||||
beforeContent = lastEdit.afterContent;
|
||||
} else {
|
||||
// ★ Bug 修复:已 accepted/rejected,需要新的 beforeContent
|
||||
// 重新捕获:当前文件内容 = 本次编辑后的结果
|
||||
beforeContent = this.reverseEdit(currentContent, oldString, newString);
|
||||
}
|
||||
} else {
|
||||
// 新建变更记录
|
||||
const changeType: FileChangeType = (toolName === 'Write' && !originalContent)
|
||||
// 首次编辑该文件
|
||||
beforeContent = capturedOriginal;
|
||||
}
|
||||
|
||||
// Step 3: 计算本次编辑的独立 Diff
|
||||
const afterContent = currentContent;
|
||||
const editDiffLines = this.computeDiffLines(beforeContent, afterContent);
|
||||
|
||||
// Step 4: 创建 EditRecord
|
||||
const editRecord: EditRecord = {
|
||||
id: uuidv4(),
|
||||
filePath,
|
||||
toolName: toolName as 'Edit' | 'Write',
|
||||
beforeContent,
|
||||
afterContent,
|
||||
oldString: oldString || '',
|
||||
newString: newString || '',
|
||||
diffLines: editDiffLines,
|
||||
status: 'pending',
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
// Step 5: 决定 FileChange 策略
|
||||
if (existingChange && existingChange.status === 'pending') {
|
||||
// 追加到已有 FileChange
|
||||
existingChange.edits.push(editRecord);
|
||||
existingChange.latestContent = afterContent;
|
||||
existingChange.diffLines = this.computeDiffLines(existingChange.originalContent, afterContent);
|
||||
console.log(`[AI Diff] 追加 EditRecord 到已有 FileChange: ${filePath}, edits=${existingChange.edits.length}`);
|
||||
} else {
|
||||
// 创建新 FileChange(首次编辑 或 之前的已 accepted/rejected)
|
||||
const changeType: FileChangeType = (toolName === 'Write' && !capturedOriginal)
|
||||
? 'create'
|
||||
: 'modify';
|
||||
|
||||
@@ -106,53 +132,186 @@ export class ChangeSetManager {
|
||||
id: uuidv4(),
|
||||
filePath,
|
||||
type: changeType,
|
||||
oldContent: originalContent,
|
||||
newContent: currentContent,
|
||||
diffLines: this.computeDiffLines(originalContent, currentContent),
|
||||
edits: [editRecord],
|
||||
originalContent: capturedOriginal,
|
||||
latestContent: afterContent,
|
||||
diffLines: editDiffLines,
|
||||
status: 'pending',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.currentSet.changes.push(fileChange);
|
||||
console.log(`[AI Diff] 新增变更记录: ${filePath}, type=${changeType}, diff=${fileChange.diffLines.length} 行`);
|
||||
this.currentSet!.changes.push(fileChange);
|
||||
if (existingChange) {
|
||||
console.log(`[AI Diff] ★ 新建 FileChange(旧 FileChange 已 ${existingChange.status}): ${filePath}`);
|
||||
} else {
|
||||
console.log(`[AI Diff] 新建 FileChange: ${filePath}, type=${changeType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateFileStatus(fileId: string, status: FileChangeStatus): void {
|
||||
if (!this.currentSet) return;
|
||||
const change = this.currentSet.changes.find(c => c.id === fileId);
|
||||
if (change) change.status = status;
|
||||
}
|
||||
/**
|
||||
* 捕获文件的原始内容
|
||||
*/
|
||||
private captureOriginalContent(
|
||||
filePath: string,
|
||||
toolName: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
currentContent: string
|
||||
): string {
|
||||
if (this.originalContents.has(filePath)) {
|
||||
return this.originalContents.get(filePath)!;
|
||||
}
|
||||
|
||||
updateAllStatus(status: FileChangeStatus): void {
|
||||
if (!this.currentSet) return;
|
||||
this.currentSet.changes.forEach(c => { c.status = status; });
|
||||
let original: string;
|
||||
|
||||
if (toolName === 'Write') {
|
||||
// Write: 无法获取旧内容
|
||||
original = '';
|
||||
} else if (toolName === 'Edit' && oldString) {
|
||||
// Edit: 从当前文件中反推原始内容
|
||||
const idx = currentContent.indexOf(newString);
|
||||
if (idx !== -1) {
|
||||
original = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length);
|
||||
} else {
|
||||
// newString 不在文件中,可能已被之前的编辑影响
|
||||
original = oldString;
|
||||
}
|
||||
} else {
|
||||
original = currentContent;
|
||||
}
|
||||
|
||||
this.originalContents.set(filePath, original);
|
||||
console.log(`[AI Diff] 捕获原始内容: ${filePath} (${original.length} 字符)`);
|
||||
return original;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受文件变更(把 newContent 写入磁盘)
|
||||
* 反推编辑前的内容(用于已 accepted/rejected 后新编辑的场景)
|
||||
* currentContent = 编辑后内容,reverseEdit = 从后往前还原
|
||||
*/
|
||||
applyFileChange(fileId: string): boolean {
|
||||
if (!this.currentSet) return false;
|
||||
private reverseEdit(currentContent: string, oldString: string, newString: string): string {
|
||||
if (!newString) return currentContent;
|
||||
const idx = currentContent.indexOf(newString);
|
||||
if (idx !== -1) {
|
||||
const before = currentContent.substring(0, idx) + oldString + currentContent.substring(idx + newString.length);
|
||||
return before;
|
||||
}
|
||||
return currentContent;
|
||||
}
|
||||
|
||||
// ---- 状态更新 ----
|
||||
|
||||
/** 更新单个编辑记录的状态 */
|
||||
updateEditStatus(fileId: string, editId: string, status: ChangeStatus): void {
|
||||
if (!this.currentSet) return;
|
||||
const change = this.currentSet.changes.find(c => c.id === fileId);
|
||||
if (!change || change.status !== 'accepted') return false;
|
||||
if (!change) return;
|
||||
const edit = change.edits.find(e => e.id === editId);
|
||||
if (edit) edit.status = status;
|
||||
|
||||
// 如果该文件所有编辑都已处理,更新文件状态
|
||||
if (change.edits.every(e => e.status !== 'pending')) {
|
||||
const allAccepted = change.edits.every(e => e.status === 'accepted');
|
||||
change.status = allAccepted ? 'accepted' : 'rejected';
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新整个文件的状态(同时更新所有编辑) */
|
||||
updateFileStatus(fileId: string, status: ChangeStatus): void {
|
||||
if (!this.currentSet) return;
|
||||
const change = this.currentSet.changes.find(c => c.id === fileId);
|
||||
if (change) {
|
||||
change.status = status;
|
||||
change.edits.forEach(e => { e.status = status; });
|
||||
}
|
||||
}
|
||||
|
||||
updateAllStatus(status: ChangeStatus): void {
|
||||
if (!this.currentSet) return;
|
||||
this.currentSet.changes.forEach(c => {
|
||||
c.status = status;
|
||||
c.edits.forEach(e => { e.status = status; });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Accept(应用到磁盘) ----
|
||||
|
||||
/**
|
||||
* 接受单个编辑 — 内容已在磁盘上,只需标记状态
|
||||
*/
|
||||
acceptEditRecord(fileId: string, editId: string): boolean {
|
||||
this.updateEditStatus(fileId, editId, 'accepted');
|
||||
const change = this.findChange(fileId);
|
||||
if (!change) return false;
|
||||
|
||||
// 如果整个文件所有编辑都已接受,写入最终的 latestContent
|
||||
if (change.edits.every(e => e.status === 'accepted')) {
|
||||
return this.writeFile(change.filePath, change.latestContent);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受整个文件的所有编辑
|
||||
*/
|
||||
acceptFile(fileId: string): boolean {
|
||||
this.updateFileStatus(fileId, 'accepted');
|
||||
const change = this.findChange(fileId);
|
||||
if (!change) return false;
|
||||
return this.writeFile(change.filePath, change.latestContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受所有变更
|
||||
*/
|
||||
acceptAll(): number {
|
||||
if (!this.currentSet) return 0;
|
||||
let count = 0;
|
||||
for (const change of this.currentSet.changes) {
|
||||
if (change.status === 'pending' && this.acceptFile(change.id)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---- Reject(恢复原始内容) ----
|
||||
|
||||
/**
|
||||
* 拒绝单个编辑 — 从磁盘文件中还原该编辑的变更
|
||||
*/
|
||||
rejectEditRecord(fileId: string, editId: string): boolean {
|
||||
const change = this.findChange(fileId);
|
||||
if (!change) return false;
|
||||
|
||||
const edit = change.edits.find(e => e.id === editId);
|
||||
if (!edit) return false;
|
||||
|
||||
// 从当前文件中还原该编辑:将 newString 替换回 oldString
|
||||
try {
|
||||
fs.writeFileSync(change.filePath, change.newContent, 'utf-8');
|
||||
console.log(`[AI Diff] 已接受: ${change.filePath}`);
|
||||
return true;
|
||||
const currentContent = this.readFile(change.filePath);
|
||||
if (currentContent !== null) {
|
||||
const revertedContent = this.revertEditInContent(currentContent, edit);
|
||||
this.writeFile(change.filePath, revertedContent);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[AI Diff] 写入文件失败: ${change.filePath}`, e);
|
||||
console.error(`[AI Diff] 还原编辑失败: ${change.filePath}`, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.updateEditStatus(fileId, editId, 'rejected');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝文件变更(把 oldContent 写回磁盘,恢复原始状态)
|
||||
* 拒绝整个文件的所有编辑 — 恢复 originalContent
|
||||
*/
|
||||
rejectFileChange(fileId: string): boolean {
|
||||
if (!this.currentSet) return false;
|
||||
const change = this.currentSet.changes.find(c => c.id === fileId);
|
||||
if (!change || change.status !== 'rejected') return false;
|
||||
rejectFile(fileId: string): boolean {
|
||||
const change = this.findChange(fileId);
|
||||
if (!change) return false;
|
||||
|
||||
this.updateFileStatus(fileId, 'rejected');
|
||||
|
||||
try {
|
||||
if (change.type === 'create') {
|
||||
// 新建的文件 → 删除
|
||||
@@ -160,8 +319,8 @@ export class ChangeSetManager {
|
||||
fs.unlinkSync(change.filePath);
|
||||
}
|
||||
} else {
|
||||
// 修改/删除的文件 → 恢复原始内容
|
||||
fs.writeFileSync(change.filePath, change.oldContent, 'utf-8');
|
||||
// 修改的文件 → 恢复原始内容
|
||||
this.writeFile(change.filePath, change.originalContent);
|
||||
}
|
||||
console.log(`[AI Diff] 已拒绝: ${change.filePath}`);
|
||||
return true;
|
||||
@@ -171,26 +330,34 @@ export class ChangeSetManager {
|
||||
}
|
||||
}
|
||||
|
||||
applyAllAccepted(): number {
|
||||
/**
|
||||
* 拒绝所有变更
|
||||
*/
|
||||
rejectAll(): number {
|
||||
if (!this.currentSet) return 0;
|
||||
let count = 0;
|
||||
for (const change of this.currentSet.changes) {
|
||||
if (change.status === 'accepted' && this.applyFileChange(change.id)) {
|
||||
if (change.status === 'pending' && this.rejectFile(change.id)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
rejectAll(): number {
|
||||
if (!this.currentSet) return 0;
|
||||
let count = 0;
|
||||
for (const change of this.currentSet.changes) {
|
||||
if (change.status === 'rejected' && this.rejectFileChange(change.id)) {
|
||||
count++;
|
||||
}
|
||||
// ---- 工具方法 ----
|
||||
|
||||
/**
|
||||
* 在内容中还原单个编辑
|
||||
*/
|
||||
private revertEditInContent(content: string, edit: EditRecord): string {
|
||||
// 找到 newString 并替换回 oldString
|
||||
const idx = content.indexOf(edit.newString);
|
||||
if (idx !== -1) {
|
||||
return content.substring(0, idx) + edit.oldString + content.substring(idx + edit.newString.length);
|
||||
}
|
||||
return count;
|
||||
// 找不到精确匹配,回退:使用 beforeContent
|
||||
console.warn(`[AI Diff] 无法精确定位编辑替换位置,回退到 beforeContent`);
|
||||
return edit.beforeContent;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
@@ -204,6 +371,22 @@ export class ChangeSetManager {
|
||||
this.currentSet.changes.some(c => c.status === 'pending');
|
||||
}
|
||||
|
||||
private findChange(fileId: string): FileChange | undefined {
|
||||
if (!this.currentSet) return undefined;
|
||||
return this.currentSet.changes.find(c => c.id === fileId);
|
||||
}
|
||||
|
||||
private writeFile(filePath: string, content: string): boolean {
|
||||
try {
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
console.log(`[AI Diff] 写入文件: ${filePath}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[AI Diff] 写入文件失败: ${filePath}`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private computeDiffLines(oldContent: string, newContent: string): DiffLine[] {
|
||||
const changes: Change[] = diffLines(oldContent, newContent);
|
||||
const lines: DiffLine[] = [];
|
||||
@@ -242,3 +425,6 @@ export class ChangeSetManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @legacy 向后兼容别名,供未迁移的模块使用 */
|
||||
export { ChangeSetManager as SnapshotManager };
|
||||
|
||||
Reference in New Issue
Block a user