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
@@ -232,6 +232,45 @@ const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record<string,
return undefined;
};
const getPrimaryDiffFromMetadata = (
tool: string,
metadata?: Record<string, unknown>,
preferredPath?: string,
): string | undefined => {
if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) {
return undefined;
}
const files = Array.isArray(metadata.files) ? metadata.files : [];
if (files.length > 0) {
const preferred = typeof preferredPath === 'string' && preferredPath.length > 0
? preferredPath
: undefined;
const matched = preferred
? files.find((file) => {
if (!file || typeof file !== 'object') {
return false;
}
const candidate = file as { relativePath?: unknown; filePath?: unknown };
return candidate.relativePath === preferred || candidate.filePath === preferred;
})
: files[0];
if (matched && typeof matched === 'object') {
const patch = (matched as { diff?: unknown }).diff;
if (typeof patch === 'string' && patch.trim().length > 0) {
return patch;
}
}
}
if (typeof metadata.diff === 'string' && metadata.diff.trim().length > 0) {
return metadata.diff;
}
return undefined;
};
const getRelativePath = (absolutePath: string, currentDirectory: string): string => {
if (absolutePath.startsWith(currentDirectory)) {
const relativePath = absolutePath.substring(currentDirectory.length);
@@ -1713,14 +1752,21 @@ const ToolPart: React.FC<ToolPartProps> = ({
let filePath: unknown;
let targetLine: number | undefined;
let toolDiff: string | undefined;
if (part.tool === 'edit' || part.tool === 'multiedit') {
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
if (typeof filePath === 'string') {
toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath);
}
} else if (part.tool === 'apply_patch') {
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
filePath = firstFile?.relativePath || firstFile?.filePath;
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
if (typeof filePath === 'string') {
toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath);
}
} else if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool)) {
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
}
@@ -1731,6 +1777,11 @@ const ToolPart: React.FC<ToolPartProps> = ({
if (!filePath.startsWith('/')) {
absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath;
}
if (runtime.runtime.isVSCode && toolDiff && (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch')) {
const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch: toolDiff });
return;
}
runtime.editor.openFile(absolutePath, targetLine);
} else {
onToggle(part.id);
+6 -1
View File
@@ -598,7 +598,12 @@ export interface ToolsAPI {
export interface EditorAPI {
openFile(path: string, line?: number, column?: number): Promise<void>;
openDiff(original: string, modified: string, label?: string): Promise<void>;
openDiff(
original: string,
modified: string,
label?: string,
options?: { line?: number; patch?: string },
): Promise<void>;
}
export interface VSCodeAPI {
+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);
+8 -2
View File
@@ -6,7 +6,13 @@ export const createVSCodeEditorAPI = (): EditorAPI => ({
openFile: async (path: string, line?: number, column?: number) => {
await sendBridgeMessage('editor:openFile', { path, line, column });
},
openDiff: async (original: string, modified: string, label?: string) => {
await sendBridgeMessage('editor:openDiff', { original, modified, label });
openDiff: async (original: string, modified: string, label?: string, options?: { line?: number; patch?: string }) => {
await sendBridgeMessage('editor:openDiff', {
original,
modified,
label,
line: options?.line,
patch: options?.patch,
});
},
});