feat: open edit tool changes in VS Code diff view with line focus

Tool-card clicks for edit-like actions now open a VS Code diff instead of only the file
Diff view focuses the first changed line for faster review
Uses a virtual readonly original buffer to avoid opening unsaved draft tabs
This commit is contained in:
Bohdan Triapitsyn
2026-03-04 11:22:36 +02:00
parent 11ce80b7ab
commit 7d7890daac
4 changed files with 224 additions and 10 deletions
+159 -7
View File
@@ -169,6 +169,130 @@ const readUriAsAttachment = async (
}
};
type ParsedDiffHunk = {
newStart: number;
oldLines: string[];
newLines: string[];
};
const VIRTUAL_DIFF_SCHEME = 'openchamber-diff';
const virtualDiffContents = new Map<string, string>();
let virtualDiffCounter = 0;
let virtualDiffProviderDisposable: vscode.Disposable | null = null;
const ensureVirtualDiffProviderRegistered = (ctx?: BridgeContext): void => {
if (virtualDiffProviderDisposable) {
return;
}
virtualDiffProviderDisposable = vscode.workspace.registerTextDocumentContentProvider(
VIRTUAL_DIFF_SCHEME,
{
provideTextDocumentContent: (uri: vscode.Uri) => {
const key = new URLSearchParams(uri.query).get('key') || '';
return virtualDiffContents.get(key) ?? '';
},
},
);
if (ctx?.context) {
ctx.context.subscriptions.push(virtualDiffProviderDisposable);
}
};
const createVirtualOriginalDiffUri = (modifiedPath: string, content: string): vscode.Uri => {
const key = `${Date.now()}-${++virtualDiffCounter}`;
virtualDiffContents.set(key, content);
if (virtualDiffContents.size > 100) {
const firstKey = virtualDiffContents.keys().next().value;
if (typeof firstKey === 'string') {
virtualDiffContents.delete(firstKey);
}
}
const fileName = path.basename(modifiedPath) || 'file';
return vscode.Uri.from({
scheme: VIRTUAL_DIFF_SCHEME,
path: `/${fileName} (before)`,
query: `key=${encodeURIComponent(key)}`,
});
};
const parseUnifiedDiffHunks = (patch: string): ParsedDiffHunk[] => {
if (typeof patch !== 'string' || patch.trim().length === 0) {
return [];
}
const lines = patch.split('\n');
const hunks: ParsedDiffHunk[] = [];
let current: ParsedDiffHunk | null = null;
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, '');
const headerMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
if (headerMatch) {
if (current) {
hunks.push(current);
}
const newStart = Number.parseInt(headerMatch[1] ?? '', 10);
current = {
newStart: Number.isFinite(newStart) ? Math.max(1, newStart) : 1,
oldLines: [],
newLines: [],
};
continue;
}
if (!current) {
continue;
}
if (line.startsWith(' ')) {
const text = line.slice(1);
current.oldLines.push(text);
current.newLines.push(text);
continue;
}
if (line.startsWith('+') && !line.startsWith('+++')) {
current.newLines.push(line.slice(1));
continue;
}
if (line.startsWith('-') && !line.startsWith('---')) {
current.oldLines.push(line.slice(1));
continue;
}
}
if (current) {
hunks.push(current);
}
return hunks;
};
const reconstructOriginalContentFromPatch = (modifiedContent: string, patch: string): string | null => {
const hunks = parseUnifiedDiffHunks(patch);
if (hunks.length === 0) {
return null;
}
const lines = modifiedContent.split('\n');
for (let index = hunks.length - 1; index >= 0; index -= 1) {
const hunk = hunks[index];
if (!hunk) {
continue;
}
const startIndex = Math.max(0, hunk.newStart - 1);
const replaceCount = hunk.newLines.length;
lines.splice(startIndex, replaceCount, ...hunk.oldLines);
}
return lines.join('\n');
};
const isPathInside = (candidatePath: string, parentPath: string): boolean => {
const normalizedCandidate = path.resolve(candidatePath);
const normalizedParent = path.resolve(parentPath);
@@ -2783,16 +2907,44 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
}
case 'editor:openDiff': {
const { original, modified, label } = payload as { original: string; modified: string; label?: string };
const { original, modified, label, line, patch } = payload as {
original: string;
modified: string;
label?: string;
line?: number;
patch?: string;
};
try {
// If the paths are just content, we need to create virtual documents or temp files.
// However, 'editor:openDiff' usually implies comparing two URIs.
// If the payload contains file paths:
const originalUri = vscode.Uri.file(original);
const modifiedUri = vscode.Uri.file(modified);
const title = label || `${path.basename(original)} ↔ ${path.basename(modified)}`;
const modifiedDoc = await vscode.workspace.openTextDocument(modifiedUri);
let originalUri = original ? vscode.Uri.file(original) : modifiedUri;
if (typeof patch === 'string' && patch.trim().length > 0) {
const originalContent = reconstructOriginalContentFromPatch(modifiedDoc.getText(), patch);
if (typeof originalContent === 'string') {
ensureVirtualDiffProviderRegistered(ctx);
originalUri = createVirtualOriginalDiffUri(modified, originalContent);
}
}
const leftLabel = original ? path.basename(original) : `${path.basename(modified)} (before)`;
const title = label || `${leftLabel} ↔ ${path.basename(modified)}`;
await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title);
if (typeof line === 'number' && Number.isFinite(line)) {
const targetLine = Math.max(0, Math.trunc(line) - 1);
await new Promise((resolve) => setTimeout(resolve, 0));
const targetEditor = vscode.window.visibleTextEditors.find(
(editor) => editor.document.uri.toString() === modifiedUri.toString(),
);
if (targetEditor) {
const target = new vscode.Position(targetLine, 0);
targetEditor.selection = new vscode.Selection(target, target);
targetEditor.revealRange(new vscode.Range(target, target), vscode.TextEditorRevealType.InCenter);
}
}
return { id, type, success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);