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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user