fix:修复diff范围问题

This commit is contained in:
2026-06-18 07:58:54 +08:00
parent a7c992a804
commit f06914994d
5 changed files with 1122 additions and 594 deletions
+171
View File
@@ -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
View File
@@ -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
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
</script>
<style>.collapsed .diff-body{display:none;}</style>
<style>.collapsed .diff-body{display:none;} .collapsed{display:none;}</style>
</body>
</html>`;
}
+268 -82
View File
@@ -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 };