Skip to content

Commit 19dc63e

Browse files
committed
feat(diff): add inline diff editor for file edits and enhance symbol diffing logic
1 parent 1725c29 commit 19dc63e

4 files changed

Lines changed: 356 additions & 50 deletions

File tree

src/client-pipe.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,24 @@ export function fileHighlightReadRange(
879879
});
880880
}
881881

882+
/**
883+
* Open an inline diff editor showing old vs new content after an edit.
884+
* Old content was pre-captured by the extension's handleFileApplyEdit.
885+
* Fire-and-forget — does not block the tool response.
886+
*/
887+
export function fileShowEditDiff(
888+
filePath: string,
889+
editStartLine: number,
890+
): void {
891+
sendClientRequest(
892+
'file.showEditDiff',
893+
{filePath, editStartLine},
894+
10_000,
895+
).catch(() => {
896+
// Best-effort — don't let diff viewer failures affect tool responses
897+
});
898+
}
899+
882900
/**
883901
* Apply a text replacement (range → new content) and save.
884902
*/

src/tools/file/safety-layer.ts

Lines changed: 222 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77
import {
88
fileGetSymbols,
99
fileApplyEdit,
10+
fileReadContent,
1011
fileGetDiagnostics,
1112
fileExecuteRename,
1213
fileFindReferences,
1314
fileGetCodeActions,
1415
fileApplyCodeAction,
16+
fileShowEditDiff,
1517
type NativeDocumentSymbol,
1618
} from '../../client-pipe.js';
17-
import {diffSymbols, extractNewName} from './symbol-diff.js';
19+
import {diffSymbols, type EditInfo} from './symbol-diff.js';
1820
import type {
1921
DetectedIntent,
2022
PropagatedChange,
@@ -27,11 +29,63 @@ const DIAGNOSTIC_SETTLE_DELAY_MS = 800;
2729
const MAX_AUTO_FIX_ATTEMPTS = 5;
2830

2931
/**
30-
* Execute a file edit with the full safety layer:
32+
* Spelling-related Code Action titles that can corrupt semantics after deletions.
33+
* These "fix" missing references by renaming to similarly-named symbols,
34+
* which changes program behavior rather than fixing an actual typo.
35+
*/
36+
const HARMFUL_FIX_PATTERNS = [
37+
/change spelling/i,
38+
/did you mean/i,
39+
];
40+
41+
/**
42+
* Check whether a Code Action is potentially harmful given the edit context.
43+
* Spelling corrections after deliberate deletions can silently corrupt semantics —
44+
* e.g. renaming `level15()` to `level5()` after the user deleted `level15`.
45+
*/
46+
function isHarmfulAutoFix(title: string, hasDeleteIntents: boolean): boolean {
47+
if (!hasDeleteIntents) return false;
48+
return HARMFUL_FIX_PATTERNS.some(pattern => pattern.test(title));
49+
}
50+
51+
/**
52+
* Find a symbol by qualified name (e.g. `ParentName.childName`).
53+
* For top-level symbols, the name is matched directly.
54+
* For child symbols, splits on `.` and walks the hierarchy.
55+
*/
56+
function findSymbolByQualifiedName(
57+
symbols: NativeDocumentSymbol[],
58+
qualifiedName: string,
59+
): NativeDocumentSymbol | undefined {
60+
const parts = qualifiedName.split('.');
61+
if (parts.length === 1) {
62+
return symbols.find(s => s.name === qualifiedName);
63+
}
64+
const parent = symbols.find(s => s.name === parts[0]);
65+
if (!parent?.children) return undefined;
66+
const childName = parts.slice(1).join('.');
67+
return findSymbolByQualifiedName(parent.children, childName);
68+
}
69+
70+
/**
71+
* Normalize file paths for comparison (forward slashes, lowercase on Windows).
72+
*/
73+
function normalizePath(p: string): string {
74+
return p.replace(/\\/g, '/').toLowerCase();
75+
}
76+
77+
/**
78+
* Execute a file edit with the full safety layer.
3179
*
32-
* Phase 1: Virtual pre-check — compare old vs new DocumentSymbols to detect intents
33-
* Phase 2: Atomic apply — apply the edit + propagate renames
34-
* Phase 3: Auto-fix — apply Code Actions for new errors
80+
* All symbol detection uses VS Code's DocumentSymbol provider — zero regex.
81+
*
82+
* Flow:
83+
* Phase 0: Snapshot old symbols + old content
84+
* Phase 1: Tentatively apply the edit
85+
* Phase 2: Semantic intent detection via diffSymbols (DocumentSymbol diff)
86+
* Phase 3: If deletes with external refs → revert and block
87+
* Phase 4: If renames → revert, execute VS Code rename provider, re-apply body
88+
* Phase 5: Auto-fix via Code Actions, final diagnostics
3589
*/
3690
export async function executeEditWithSafetyLayer(
3791
filePath: string,
@@ -43,25 +97,25 @@ export async function executeEditWithSafetyLayer(
4397
const propagated: PropagatedChange[] = [];
4498
const autoFixed: AutoFix[] = [];
4599

46-
// ── Phase 1: Pre-check — Get symbols before edit ────────────────
100+
// ── Phase 0: Snapshot ───────────────────────────────────────────
47101
let oldSymbols: NativeDocumentSymbol[] = [];
48102
try {
49103
const beforeResult = await fileGetSymbols(filePath);
50104
oldSymbols = beforeResult.symbols;
51105
} catch {
52-
// No symbol provider available — proceed without intent detection
106+
// No symbol provider — proceed without safety checks
53107
}
54108

55-
// Get pre-existing diagnostics to compare after edit
56-
let preExistingErrors = 0;
109+
// Read old content of the edit range for potential revert
110+
let oldContent: string | undefined;
57111
try {
58-
const preDiags = await fileGetDiagnostics(filePath);
59-
preExistingErrors = preDiags.diagnostics.filter(d => d.severity.toLowerCase() === 'error').length;
112+
const contentResult = await fileReadContent(filePath, startLine, endLine);
113+
oldContent = contentResult.content;
60114
} catch {
61-
// Best-effort
115+
// Best-effort — revert won't be possible
62116
}
63117

64-
// ── Phase 2: Apply the edit ─────────────────────────────────────
118+
// ── Phase 1: Tentatively apply the edit ─────────────────────────
65119
try {
66120
await fileApplyEdit(filePath, startLine, endLine, newContent);
67121
} catch (err) {
@@ -77,38 +131,100 @@ export async function executeEditWithSafetyLayer(
77131
};
78132
}
79133

80-
// ── Phase 1b: Post-apply intent detection ───────────────────────
81-
// Get symbols after apply for comparison
82-
let newSymbols: NativeDocumentSymbol[] = [];
134+
// ── Phase 2: Semantic intent detection ──────────────────────────
135+
// Get new symbols via VS Code's DocumentSymbol provider — the only source of truth.
136+
let allIntents: DetectedIntent[] = [];
137+
83138
if (oldSymbols.length > 0) {
84139
try {
85140
const afterResult = await fileGetSymbols(filePath);
86-
newSymbols = afterResult.symbols;
87-
const intents = diffSymbols(oldSymbols, newSymbols);
88-
detectedIntents.push(...intents);
141+
const newLineCount = newContent.split('\n').length;
142+
const oldLineCount = endLine - startLine + 1;
143+
const editInfoForDiff: EditInfo = {
144+
newContentEndLine: startLine + newLineCount - 1,
145+
linesDelta: newLineCount - oldLineCount,
146+
};
147+
148+
allIntents = diffSymbols(oldSymbols, afterResult.symbols, editInfoForDiff);
89149
} catch {
90150
// Proceed without intent detection
91151
}
92152
}
93153

94-
// ── Phase 2b: Rename propagation ───────────────────────────────
95-
const renames = detectedIntents.filter(i => i.type === 'rename');
96-
for (const renameIntent of renames) {
97-
const newName = extractNewName(renameIntent);
98-
if (!newName) continue;
154+
const renames = allIntents.filter(i => i.type === 'rename' && i.newName);
155+
const deletes = allIntents.filter(i => i.type === 'delete');
99156

157+
// ── Phase 3: Revert + delete protection + rename propagation ────
158+
if ((renames.length > 0 || deletes.length > 0) && oldContent !== undefined) {
159+
// Revert to original state so we can check refs / execute renames
160+
const tentativeEndLine = startLine + newContent.split('\n').length - 1;
100161
try {
101-
// Find a reference to the OLD name in another file to trigger rename
102-
const refs = await fileFindReferences(filePath, 0, 0);
103-
// Search for a reference in a DIFFERENT file to propagate cross-file
104-
const externalRef = refs.references.find(r => !r.file.endsWith(filePath.replace(/\\/g, '/')));
162+
await fileApplyEdit(filePath, startLine, tentativeEndLine, oldContent);
163+
} catch {
164+
// Can't revert — skip protection and propagation, edit stays applied
165+
detectedIntents.push(...allIntents);
166+
return finalize(filePath, detectedIntents, propagated, autoFixed, startLine);
167+
}
168+
169+
// Check deletes for external references
170+
for (const del of deletes) {
171+
const oldSym = oldSymbols.find(s => s.name === del.symbol);
172+
if (!oldSym) continue;
173+
174+
try {
175+
const refs = await fileFindReferences(
176+
filePath,
177+
oldSym.selectionRange.startLine,
178+
oldSym.selectionRange.startChar,
179+
);
180+
const normalizedFilePath = normalizePath(filePath);
181+
const externalRefs = refs.references.filter(r => {
182+
const refPath = normalizePath(r.file);
183+
return (
184+
refPath !== normalizedFilePath &&
185+
!refPath.endsWith(normalizedFilePath) &&
186+
!normalizedFilePath.endsWith(refPath)
187+
);
188+
});
189+
190+
if (externalRefs.length > 0) {
191+
const refFiles = [...new Set(externalRefs.map(r => r.file))];
192+
return {
193+
success: false,
194+
file: filePath,
195+
detectedIntents: [{type: 'delete', symbol: del.symbol}],
196+
propagated: [],
197+
autoFixed: [],
198+
remainingErrors: [],
199+
summary:
200+
`Blocked: Cannot delete '${del.symbol}' — it has ${externalRefs.length} ` +
201+
`reference(s) in ${refFiles.length} other file(s): ` +
202+
`${refFiles.slice(0, 5).join(', ')}` +
203+
`${refFiles.length > 5 ? ` and ${refFiles.length - 5} more` : ''}. ` +
204+
`Resolve or remove these references first.`,
205+
};
206+
}
207+
} catch {
208+
// Can't check references — allow
209+
}
210+
}
105211

106-
if (externalRef) {
212+
// Execute VS Code rename provider for each rename.
213+
// File is in ORIGINAL state — old names exist, so the provider can resolve all refs.
214+
for (const renameIntent of renames) {
215+
if (!renameIntent.newName) continue;
216+
217+
// Find the old symbol — supports both top-level and child renames.
218+
// Child renames use `ParentName.childName` notation.
219+
const oldSym = findSymbolByQualifiedName(oldSymbols, renameIntent.symbol);
220+
if (!oldSym) continue;
221+
222+
try {
107223
const renameResult = await fileExecuteRename(
108-
externalRef.file,
109-
externalRef.line - 1,
110-
externalRef.character,
111-
newName,
224+
filePath,
225+
oldSym.selectionRange.startLine,
226+
oldSym.selectionRange.startChar,
227+
renameIntent.newName,
112228
);
113229
if (renameResult.success) {
114230
propagated.push({
@@ -117,15 +233,84 @@ export async function executeEditWithSafetyLayer(
117233
totalEdits: renameResult.totalEdits,
118234
});
119235
}
236+
} catch {
237+
// Rename provider failed — body edit will still be applied
120238
}
121-
} catch {
122-
// Rename propagation is best-effort
239+
240+
detectedIntents.push(renameIntent);
241+
}
242+
243+
// After renames, re-resolve the edit range (rename may have changed positions)
244+
let editStartLine = startLine;
245+
let editEndLine = endLine;
246+
247+
if (propagated.length > 0) {
248+
try {
249+
const refreshed = await fileGetSymbols(filePath);
250+
for (const renameIntent of renames) {
251+
if (!renameIntent.newName) continue;
252+
const renamedSym = refreshed.symbols.find(s => s.name === renameIntent.newName);
253+
if (renamedSym) {
254+
editStartLine = renamedSym.range.startLine;
255+
editEndLine = renamedSym.range.endLine;
256+
}
257+
}
258+
// Update oldSymbols so post-hoc diff sees the renamed state
259+
oldSymbols = refreshed.symbols;
260+
} catch {
261+
// Fall back to original range
262+
}
263+
}
264+
265+
// Re-apply the body edit (newContent already contains the new name + new body)
266+
try {
267+
await fileApplyEdit(filePath, editStartLine, editEndLine, newContent);
268+
} catch (err) {
269+
const msg = err instanceof Error ? err.message : String(err);
270+
return {
271+
success: false,
272+
file: filePath,
273+
detectedIntents,
274+
propagated,
275+
autoFixed: [],
276+
remainingErrors: [],
277+
summary: `Edit failed on re-apply after rename: ${msg}`,
278+
};
279+
}
280+
}
281+
282+
// Add non-rename/non-delete intents (body_change, add)
283+
for (const intent of allIntents) {
284+
if (intent.type !== 'rename' && intent.type !== 'delete') {
285+
detectedIntents.push(intent);
123286
}
124287
}
288+
// Add delete intents that weren't blocked (no external refs)
289+
for (const del of deletes) {
290+
detectedIntents.push(del);
291+
}
292+
293+
return finalize(filePath, detectedIntents, propagated, autoFixed, startLine);
294+
}
125295

126-
// ── Phase 3: Auto-fix via Code Actions ─────────────────────────
296+
/**
297+
* Post-edit finalization: show diff, run auto-fix, collect diagnostics.
298+
*/
299+
async function finalize(
300+
filePath: string,
301+
detectedIntents: DetectedIntent[],
302+
propagated: PropagatedChange[],
303+
autoFixed: AutoFix[],
304+
editStartLine: number,
305+
): Promise<FileEditResult> {
306+
// Show inline diff editor (fire-and-forget)
307+
fileShowEditDiff(filePath, editStartLine);
308+
309+
// ── Auto-fix via Code Actions ──────────────────────────────────
127310
await delay(DIAGNOSTIC_SETTLE_DELAY_MS);
128311

312+
const hasDeletes = detectedIntents.some(i => i.type === 'delete');
313+
129314
try {
130315
const postDiags = await fileGetDiagnostics(filePath);
131316
const newErrors = postDiags.diagnostics.filter(d => d.severity.toLowerCase() === 'error');
@@ -137,7 +322,7 @@ export async function executeEditWithSafetyLayer(
137322
try {
138323
const actions = await fileGetCodeActions(filePath, error.line - 1, error.endLine - 1);
139324
const preferred = actions.actions.find(a => a.isPreferred);
140-
if (preferred) {
325+
if (preferred && !isHarmfulAutoFix(preferred.title, hasDeletes)) {
141326
const applyResult = await fileApplyCodeAction(
142327
filePath,
143328
error.line - 1,
@@ -174,7 +359,6 @@ export async function executeEditWithSafetyLayer(
174359
// Best-effort
175360
}
176361

177-
// ── Build summary ──────────────────────────────────────────────
178362
const summary = buildSummary(detectedIntents, propagated, autoFixed, remainingErrors);
179363

180364
return {

0 commit comments

Comments
 (0)