diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 3ca5d287..54956962 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -232,6 +232,45 @@ const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record, + 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 = ({ 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 = ({ 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); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 9e8c341e..1344d7c7 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -598,7 +598,12 @@ export interface ToolsAPI { export interface EditorAPI { openFile(path: string, line?: number, column?: number): Promise; - openDiff(original: string, modified: string, label?: string): Promise; + openDiff( + original: string, + modified: string, + label?: string, + options?: { line?: number; patch?: string }, + ): Promise; } export interface VSCodeAPI { diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index c145b390..609ab2f0 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -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(); +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); diff --git a/packages/vscode/webview/api/editor.ts b/packages/vscode/webview/api/editor.ts index 680daa6c..4261e2a1 100644 --- a/packages/vscode/webview/api/editor.ts +++ b/packages/vscode/webview/api/editor.ts @@ -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, + }); }, });