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;
|
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 => {
|
const getRelativePath = (absolutePath: string, currentDirectory: string): string => {
|
||||||
if (absolutePath.startsWith(currentDirectory)) {
|
if (absolutePath.startsWith(currentDirectory)) {
|
||||||
const relativePath = absolutePath.substring(currentDirectory.length);
|
const relativePath = absolutePath.substring(currentDirectory.length);
|
||||||
@@ -1713,14 +1752,21 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
|
|
||||||
let filePath: unknown;
|
let filePath: unknown;
|
||||||
let targetLine: number | undefined;
|
let targetLine: number | undefined;
|
||||||
|
let toolDiff: string | undefined;
|
||||||
if (part.tool === 'edit' || part.tool === 'multiedit') {
|
if (part.tool === 'edit' || part.tool === 'multiedit') {
|
||||||
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
|
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
|
||||||
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
|
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
|
||||||
|
if (typeof filePath === 'string') {
|
||||||
|
toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath);
|
||||||
|
}
|
||||||
} else if (part.tool === 'apply_patch') {
|
} else if (part.tool === 'apply_patch') {
|
||||||
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
||||||
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
|
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
|
||||||
filePath = firstFile?.relativePath || firstFile?.filePath;
|
filePath = firstFile?.relativePath || firstFile?.filePath;
|
||||||
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
|
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)) {
|
} 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;
|
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('/')) {
|
if (!filePath.startsWith('/')) {
|
||||||
absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath;
|
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);
|
runtime.editor.openFile(absolutePath, targetLine);
|
||||||
} else {
|
} else {
|
||||||
onToggle(part.id);
|
onToggle(part.id);
|
||||||
|
|||||||
@@ -598,7 +598,12 @@ export interface ToolsAPI {
|
|||||||
|
|
||||||
export interface EditorAPI {
|
export interface EditorAPI {
|
||||||
openFile(path: string, line?: number, column?: number): Promise<void>;
|
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 {
|
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 isPathInside = (candidatePath: string, parentPath: string): boolean => {
|
||||||
const normalizedCandidate = path.resolve(candidatePath);
|
const normalizedCandidate = path.resolve(candidatePath);
|
||||||
const normalizedParent = path.resolve(parentPath);
|
const normalizedParent = path.resolve(parentPath);
|
||||||
@@ -2783,16 +2907,44 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'editor:openDiff': {
|
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 {
|
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 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);
|
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 };
|
return { id, type, success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(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) => {
|
openFile: async (path: string, line?: number, column?: number) => {
|
||||||
await sendBridgeMessage('editor:openFile', { path, line, column });
|
await sendBridgeMessage('editor:openFile', { path, line, column });
|
||||||
},
|
},
|
||||||
openDiff: async (original: string, modified: string, label?: string) => {
|
openDiff: async (original: string, modified: string, label?: string, options?: { line?: number; patch?: string }) => {
|
||||||
await sendBridgeMessage('editor:openDiff', { original, modified, label });
|
await sendBridgeMessage('editor:openDiff', {
|
||||||
|
original,
|
||||||
|
modified,
|
||||||
|
label,
|
||||||
|
line: options?.line,
|
||||||
|
patch: options?.patch,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user