diff --git a/packages/ui/src/components/chat/message/parts/ApplyPatchFileButtons.test.tsx b/packages/ui/src/components/chat/message/parts/ApplyPatchFileButtons.test.tsx new file mode 100644 index 00000000..5dad5d58 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/ApplyPatchFileButtons.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import type { EditorAPI } from '@/lib/api/types'; + +import { ApplyPatchFileButtons } from './ApplyPatchFileButtons'; +import { openApplyPatchFileInEditor } from './applyPatchEditorAction'; + +const makePatch = (path: string, line: number, before: string, after: string) => [ + `--- a/${path}`, + `+++ b/${path}`, + `@@ -${line} +${line} @@`, + `-${before}`, + `+${after}`, +].join('\n'); + +const files = [ + { + filePath: '/workspace/project/src/first.ts', + relativePath: 'src/first.ts', + patch: makePatch('src/first.ts', 4, 'first old', 'first new'), + additions: 1, + deletions: 1, + type: 'update', + }, + { + filePath: '/workspace/project/src/second.ts', + relativePath: 'src/second.ts', + patch: makePatch('src/second.ts', 12, 'second old', 'second new'), + additions: 1, + deletions: 1, + type: 'update', + }, +]; + +describe('ApplyPatchFileButtons', () => { + test('renders one labeled button per non-deleted file', () => { + const markup = renderToStaticMarkup( + undefined} + />, + ); + + expect(markup.match(/ + ) : ( + + {content} + + ); + })} + + ); +}; diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index b703c356..f62cf195 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -54,11 +54,22 @@ import { } from './taskToolModel'; import { areRenderRelevantPartsEqual } from '../renderCompare'; import { useI18n } from '@/lib/i18n'; -import { getDiffPatchEntries, getPatchText, type DiffPatchEntry } from './toolDiffUtils'; +import { + extractFirstChangedLineFromDiff, + getDiffPatchEntries, + getFirstChangedLineFromMetadata, + getPatchText, + getPrimaryDiffFromMetadata, + getPrimaryToolPath, + type DiffPatchEntry, +} from './toolDiffUtils'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { getStreamingOutputAppend, getToolOutput } from './toolOutput'; +import { toAbsoluteFilePath } from '@/lib/path-utils'; import { getToolDescriptionFallback } from './toolRenderUtils'; +import { ApplyPatchFileButtons } from './ApplyPatchFileButtons'; +import { openApplyPatchFileInEditor } from './applyPatchEditorAction'; const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal'; const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS); @@ -77,84 +88,6 @@ interface ToolPartProps { animateTailText?: boolean; } -const getMultiFileDescription = ( - metadata: Record | undefined, - animate = true, - showFileIcons = true, -): React.ReactNode => { - const files = Array.isArray(metadata?.files) ? metadata?.files : []; - if (files.length <= 1) return null; - - const parseCount = (value: unknown): number | null => { - if (typeof value === 'number' && Number.isFinite(value)) { - return Math.max(0, Math.trunc(value)); - } - if (typeof value === 'string') { - const parsed = Number.parseInt(value, 10); - if (Number.isFinite(parsed)) { - return Math.max(0, parsed); - } - } - return null; - }; - - const combineCounts = (base: number | null, incoming: number | null): number | null => { - if (base === null) return incoming; - if (incoming === null) return base; - return base + incoming; - }; - - const entriesByPath = new Map(); - - for (const file of files) { - const fileObj = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown }; - const filePath = fileObj.relativePath || fileObj.filePath || ''; - if (!filePath) continue; - const fileName = filePath.split('/').pop() || filePath; - const added = parseCount(fileObj.additions); - const removed = parseCount(fileObj.deletions); - - const existing = entriesByPath.get(filePath); - if (existing) { - existing.added = combineCounts(existing.added, added); - existing.removed = combineCounts(existing.removed, removed); - continue; - } - - entriesByPath.set(filePath, { path: filePath, name: fileName, added, removed }); - } - - const entries = Array.from(entriesByPath.values()); - - return ( - <> - {entries.map((entry) => { - const hasPerFileDiff = entry.added !== null || entry.removed !== null; - return ( - - {showFileIcons ? : null} - - {entry.name} - - {hasPerFileDiff ? ( - - +{entry.added ?? 0} - / - -{entry.removed ?? 0} - - ) : null} - - ); - })} - - ); -}; - const normalizeToolName = (toolName: string | undefined | null): string => { if (typeof toolName !== 'string') { return ''; @@ -306,54 +239,6 @@ const parseWriteLineCount = (input?: Record): number | null => return lines; }; -const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => { - if (!diffText || typeof diffText !== 'string') { - return undefined; - } - - const lines = diffText.split('\n'); - let currentNewLine: number | undefined; - let firstHunkStart: number | undefined; - - for (const rawLine of lines) { - const line = rawLine.replace(/\r$/, ''); - const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); - if (hunkMatch) { - const parsed = Number.parseInt(hunkMatch[1] ?? '', 10); - if (Number.isFinite(parsed)) { - currentNewLine = Math.max(1, parsed); - if (!Number.isFinite(firstHunkStart)) { - firstHunkStart = currentNewLine; - } - } - continue; - } - - if (currentNewLine === undefined || !Number.isFinite(currentNewLine)) { - continue; - } - - if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) { - continue; - } - - if (line.startsWith('+')) { - return currentNewLine; - } - - if (line.startsWith(' ')) { - currentNewLine += 1; - continue; - } - - if (line.startsWith('-') || line.startsWith('\\')) { - continue; - } - } - - return firstHunkStart; -}; - const buildWritePreviewPatch = (filePath: string | undefined, content: string): string | undefined => { const normalizedContent = content.replace(/\r\n/g, '\n'); if (!normalizedContent.trim()) { @@ -380,73 +265,6 @@ const buildWritePreviewPatch = (filePath: string | undefined, content: string): ].join('\n'); }; -const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record): number | undefined => { - if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) { - return undefined; - } - - const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff); - if (topLevelPatch) { - const line = extractFirstChangedLineFromDiff(topLevelPatch); - if (Number.isFinite(line)) { - return line; - } - } - - const files = Array.isArray(metadata.files) ? metadata.files : []; - const firstFile = files[0] as { patch?: unknown; diff?: unknown } | undefined; - const filePatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff); - if (filePatch) { - const line = extractFirstChangedLineFromDiff(filePatch); - if (Number.isFinite(line)) { - return line; - } - } - - return undefined; -}; - -const getPrimaryDiffFromMetadata = ( - 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 = getPatchText((matched as { patch?: unknown; diff?: unknown }).patch) - ?? getPatchText((matched as { patch?: unknown; diff?: unknown }).diff); - if (patch) { - return patch; - } - } - } - - const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff); - if (topLevelPatch) { - return topLevelPatch; - } - - return undefined; -}; - const normalizeDisplayPath = (value: string): string => { const trimmed = value.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/'); if (!trimmed || trimmed === '/') { @@ -526,58 +344,6 @@ const normalizeToolDiagnostic = (value: unknown): ToolDiagnostic | null => { }; }; -const getPrimaryToolPath = ( - toolName: string, - input: Record | undefined, - metadata: Record | undefined, -): string | null => { - if (toolName === 'apply_patch') { - const files = Array.isArray(metadata?.files) ? metadata.files : []; - const first = files.find((entry) => { - if (!isRecord(entry)) { - return false; - } - return entry.type !== 'delete'; - }); - if (!isRecord(first)) { - return null; - } - return typeof first.movePath === 'string' - ? first.movePath - : typeof first.filePath === 'string' - ? first.filePath - : typeof first.relativePath === 'string' - ? first.relativePath - : null; - } - - if (toolName === 'edit' || toolName === 'multiedit') { - const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; - if (isRecord(fileDiff) && typeof fileDiff.file === 'string') { - return fileDiff.file; - } - return typeof input?.filePath === 'string' - ? input.filePath - : typeof input?.file_path === 'string' - ? input.file_path - : typeof input?.path === 'string' - ? input.path - : null; - } - - if (toolName === 'write') { - return typeof input?.filePath === 'string' - ? input.filePath - : typeof input?.file_path === 'string' - ? input.file_path - : typeof input?.path === 'string' - ? input.path - : null; - } - - return null; -}; - const getToolDiagnosticSection = ( toolName: string, input: Record | undefined, @@ -1686,9 +1452,7 @@ const ToolExpandedContent: React.FC = React.memo(({ ); const renderResultContent = () => { - const getEntryAbsolutePath = (entry: DiffPatchEntry) => ( - entry.title.startsWith('/') ? entry.title : `${currentDirectory}/${entry.title}`.replace(/\/+/g, '/') - ); + const getEntryAbsolutePath = (entry: DiffPatchEntry) => toAbsoluteFilePath(currentDirectory, entry.filePath ?? entry.title); const openEntryFile = (entry: DiffPatchEntry, event: React.MouseEvent) => { event.stopPropagation(); const line = extractFirstChangedLineFromDiff(entry.patch); @@ -2054,6 +1818,7 @@ const ToolPartContent: React.FC = ({ onShowPopup, animateTailText = true, }) => { + const { t } = useI18n(); const state = part.state; const showToolFileIcons = useUIStore((s) => s.showToolFileIcons); const currentDirectory = useEffectiveDirectory() ?? ''; @@ -2347,6 +2112,26 @@ const ToolPartContent: React.FC = ({ }, [descriptionPath, normalizedPartTool, stateWithData, input]); const runtime = React.useContext(RuntimeAPIContext); + const openApplyPatchFile = (file: Record, event: React.MouseEvent) => { + if (!runtime?.editor) { + return; + } + + event.stopPropagation(); + const displayPath = typeof file.relativePath === 'string' + ? file.relativePath + : typeof file.filePath === 'string' + ? getRelativePath(file.filePath, currentDirectory) + : ''; + openApplyPatchFileInEditor({ + currentDirectory, + diffLabel: `${displayPath} (changes)`, + editor: runtime.editor, + file, + isVSCode: runtime.runtime.isVSCode, + }); + }; + const handleMainClick = (e: { stopPropagation: () => void }) => { if (isTaskTool || !runtime?.editor) { onToggle(part.id); @@ -2356,23 +2141,21 @@ const ToolPartContent: React.FC = ({ let filePath: unknown; let targetLine: number | undefined; let toolDiff: string | undefined; - if (part.tool === 'edit' || part.tool === 'multiedit') { + if (normalizedPartTool === 'edit' || normalizedPartTool === '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); + toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath); + targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, 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); + } else if (normalizedPartTool === 'apply_patch') { + filePath = getPrimaryToolPath(normalizedPartTool, input, metadata); if (typeof filePath === 'string') { - toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath); + toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath); + targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata, filePath); } - } else if (['write', 'create', 'file_write'].includes(part.tool)) { + } else if (['write', 'create', 'file_write'].includes(normalizedPartTool)) { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; - } else if (part.tool === 'lsp') { + } else if (normalizedPartTool === 'lsp') { filePath = input?.filePath || input?.file_path || input?.path; const line = input?.line; targetLine = typeof line === 'number' && Number.isFinite(line) ? Math.trunc(line) : undefined; @@ -2380,11 +2163,8 @@ const ToolPartContent: React.FC = ({ if (typeof filePath === 'string') { e.stopPropagation(); - let absolutePath = filePath; - 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 absolutePath = toAbsoluteFilePath(currentDirectory, filePath); + if (runtime.runtime.isVSCode && toolDiff && (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')) { const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`; void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch: toolDiff }); return; @@ -2417,60 +2197,76 @@ const ToolPartContent: React.FC = ({ {}
- {} -
{ event.stopPropagation(); onToggle(part.id); }} - > - {} -
- {getToolIcon(normalizedPartTool || part.tool)} -
- {} -
- {isExpanded ? : } -
-
{isMultiFileApplyPatch ? ( <> - onToggle(part.id)} > - {displayName} - - {getMultiFileDescription(metadata, animateTailText, showToolFileIcons)} + {isExpanded + ? + : getToolIcon(normalizedPartTool || part.tool)} + + {displayName} + + + ) : ( <> +
{ event.stopPropagation(); onToggle(part.id); }} + > +
+ {getToolIcon(normalizedPartTool || part.tool)} +
+
+ {isExpanded ? : } +
+
; + isVSCode: boolean; +}): boolean => { + const filePath = getApplyPatchFilePath(file); + if (!filePath || file.type === 'delete') { + return false; + } + + const patch = getPatchText(file.patch) ?? getPatchText(file.diff); + const line = patch ? extractFirstChangedLineFromDiff(patch) : undefined; + const absolutePath = toAbsoluteFilePath(currentDirectory, filePath); + if (isVSCode && patch) { + void editor.openDiff('', absolutePath, diffLabel, { line, patch }); + } else { + void editor.openFile(absolutePath, line); + } + return true; +}; diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts index 58929c5d..cc69096e 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts @@ -1,10 +1,87 @@ import { describe, expect, test } from 'bun:test'; -import { getDiffPatchEntries, getRenderablePatchInfo } from './toolDiffUtils'; +import { + getApplyPatchFilePath, + getDiffPatchEntries, + getFirstChangedLineFromMetadata, + getPrimaryDiffFromMetadata, + getPrimaryToolPath, + getRenderablePatchInfo, +} from './toolDiffUtils'; const identity = (path: string) => path; describe('toolDiffUtils', () => { + test('prefers the absolute apply_patch path over its worktree-relative label', () => { + expect(getPrimaryToolPath('apply_patch', undefined, { + files: [{ + filePath: '/workspace/project/src/file.ts', + relativePath: 'workspace/project/src/file.ts', + type: 'update', + }], + })).toBe('/workspace/project/src/file.ts'); + }); + + test('opens the move destination and skips deleted apply_patch files', () => { + expect(getPrimaryToolPath('apply_patch', undefined, { + files: [ + { filePath: '/workspace/deleted.ts', relativePath: 'deleted.ts', type: 'delete' }, + { + filePath: '/workspace/old.ts', + relativePath: 'new.ts', + movePath: '/workspace/new.ts', + type: 'move', + }, + ], + })).toBe('/workspace/new.ts'); + }); + + test('falls back to the relative apply_patch path for legacy metadata', () => { + expect(getPrimaryToolPath('apply_patch', undefined, { + files: [{ relativePath: 'src/file.ts', type: 'update' }], + })).toBe('src/file.ts'); + }); + + test('resolves each apply_patch file independently', () => { + expect(getApplyPatchFilePath({ + filePath: '/workspace/project/src/first.ts', + relativePath: 'workspace/project/src/first.ts', + })).toBe('/workspace/project/src/first.ts'); + expect(getApplyPatchFilePath({ + filePath: '/workspace/project/src/old.ts', + movePath: '/workspace/project/src/second.ts', + relativePath: 'src/second.ts', + })).toBe('/workspace/project/src/second.ts'); + }); + + test('selects the move patch and line from the same non-deleted file', () => { + const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted'; + const movedPatch = '@@ -42 +42 @@\n-before\n+after'; + const metadata = { + patch: deletedPatch, + files: [ + { + filePath: '/workspace/project/src/deleted.ts', + relativePath: 'src/deleted.ts', + patch: deletedPatch, + type: 'delete', + }, + { + filePath: '/workspace/project/src/old.ts', + movePath: '/workspace/project/src/moved.ts', + relativePath: 'src/moved.ts', + patch: movedPatch, + type: 'move', + }, + ], + }; + + expect(getPrimaryDiffFromMetadata('apply_patch', metadata, '/workspace/project/src/moved.ts')) + .toBe(movedPatch); + expect(getFirstChangedLineFromMetadata('apply_patch', metadata, '/workspace/project/src/moved.ts')) + .toBe(42); + }); + test('treats raw apply_patch envelopes as text, not visual diffs', () => { const entries = getDiffPatchEntries(undefined, [ '*** Begin Patch', @@ -57,6 +134,27 @@ describe('toolDiffUtils', () => { expect(entries[0]?.title).toBe('src/file.ts'); }); + test('keeps the authoritative path for every metadata file entry', () => { + const patch = [ + '--- a/src/file.ts', + '+++ b/src/file.ts', + '@@ -1 +1 @@', + '-old', + '+new', + ].join('\n'); + const entries = getDiffPatchEntries({ + files: [ + { filePath: '/workspace/project/src/first.ts', relativePath: 'src/first.ts', patch }, + { filePath: '/workspace/project/src/second.ts', relativePath: 'src/second.ts', patch }, + ], + }, undefined, identity); + + expect(entries.map((entry) => entry.filePath)).toEqual([ + '/workspace/project/src/first.ts', + '/workspace/project/src/second.ts', + ]); + }); + test('synthesizes headers for valid headerless hunks', () => { const entries = getDiffPatchEntries(undefined, [ '@@ -1 +1 @@', diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts index 23d9aac0..5bbe2efb 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts @@ -3,6 +3,7 @@ import { parsePatchFiles } from '@pierre/diffs'; export type DiffPatchEntry = { id: string; title: string; + filePath?: string; patch: string; renderMode: 'diff' | 'text'; }; @@ -140,6 +141,172 @@ export const getPatchText = (value: unknown): string | undefined => { return undefined; }; +export const getApplyPatchFilePath = (file: unknown): string | null => { + if (!isRecord(file)) { + return null; + } + + return typeof file.movePath === 'string' + ? file.movePath + : typeof file.filePath === 'string' + ? file.filePath + : typeof file.relativePath === 'string' + ? file.relativePath + : null; +}; + +export const getPrimaryToolPath = ( + toolName: string, + input: Record | undefined, + metadata: Record | undefined, +): string | null => { + if (toolName === 'apply_patch') { + const files = Array.isArray(metadata?.files) ? metadata.files : []; + for (const file of files) { + if (isRecord(file) && file.type !== 'delete') { + const filePath = getApplyPatchFilePath(file); + if (filePath) { + return filePath; + } + } + } + return null; + } + + if (toolName === 'edit' || toolName === 'multiedit') { + const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; + if (fileDiff && typeof fileDiff.file === 'string') { + return fileDiff.file; + } + return typeof input?.filePath === 'string' + ? input.filePath + : typeof input?.file_path === 'string' + ? input.file_path + : typeof input?.path === 'string' + ? input.path + : null; + } + + if (toolName === 'write') { + return typeof input?.filePath === 'string' + ? input.filePath + : typeof input?.file_path === 'string' + ? input.file_path + : typeof input?.path === 'string' + ? input.path + : null; + } + + return null; +}; + +const supportsDiffMetadata = (toolName: string): boolean => ( + toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch' +); + +const getMetadataFileForPath = ( + metadata: Record, + preferredPath?: string, +): Record | undefined => { + const files = Array.isArray(metadata.files) ? metadata.files : []; + if (!preferredPath) { + const first = files[0]; + return isRecord(first) ? first : undefined; + } + + return files.find((file): file is Record => ( + isRecord(file) + && (file.relativePath === preferredPath || file.filePath === preferredPath || file.movePath === preferredPath) + )); +}; + +export const getPrimaryDiffFromMetadata = ( + toolName: string, + metadata?: Record, + preferredPath?: string, +): string | undefined => { + if (!metadata || !supportsDiffMetadata(toolName)) { + return undefined; + } + + const matchedFile = getMetadataFileForPath(metadata, preferredPath); + const filePatch = getPatchText(matchedFile?.patch) ?? getPatchText(matchedFile?.diff); + if (filePatch) { + return filePatch; + } + + return getPatchText(metadata.patch) ?? getPatchText(metadata.diff); +}; + +export const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => { + if (!diffText) { + return undefined; + } + + let currentNewLine: number | undefined; + let firstHunkStart: number | undefined; + for (const rawLine of diffText.split('\n')) { + const line = rawLine.replace(/\r$/, ''); + const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); + if (hunkMatch) { + const parsed = Number.parseInt(hunkMatch[1] ?? '', 10); + if (Number.isFinite(parsed)) { + currentNewLine = Math.max(1, parsed); + firstHunkStart ??= currentNewLine; + } + continue; + } + + if (currentNewLine === undefined) { + continue; + } + if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) { + continue; + } + if (line.startsWith('+')) { + return currentNewLine; + } + if (line.startsWith(' ')) { + currentNewLine += 1; + } + } + + return firstHunkStart; +}; + +export const getFirstChangedLineFromMetadata = ( + toolName: string, + metadata?: Record, + preferredPath?: string, +): number | undefined => { + if (!metadata || !supportsDiffMetadata(toolName)) { + return undefined; + } + + if (preferredPath) { + const matchedFile = getMetadataFileForPath(metadata, preferredPath); + const matchedPatch = getPatchText(matchedFile?.patch) ?? getPatchText(matchedFile?.diff); + if (matchedPatch) { + const matchedLine = extractFirstChangedLineFromDiff(matchedPatch); + if (matchedLine !== undefined) { + return matchedLine; + } + } + } + + const topLevelPatch = getPatchText(metadata.patch) ?? getPatchText(metadata.diff); + if (topLevelPatch) { + const topLevelLine = extractFirstChangedLineFromDiff(topLevelPatch); + if (topLevelLine !== undefined) { + return topLevelLine; + } + } + + const firstFile = getMetadataFileForPath(metadata); + const firstPatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff); + return firstPatch ? extractFirstChangedLineFromDiff(firstPatch) : undefined; +}; + const normalizeParsedPath = (path: string | undefined): string => { const trimmed = (path ?? '').trim().replace(/\t.*$/, ''); if (!trimmed || trimmed === '/dev/null') { @@ -287,7 +454,7 @@ const getPatchEntriesFromText = ( }]; }; -const getFilePatch = (file: unknown): { patch: string; title: string } | null => { +const getFilePatch = (file: unknown): { filePath?: string; patch: string; title: string } | null => { if (!isRecord(file)) { return null; } @@ -304,6 +471,7 @@ const getFilePatch = (file: unknown): { patch: string; title: string } | null => : ''; return { + filePath: getApplyPatchFilePath(file) ?? undefined, patch, title: rawPath, }; @@ -325,7 +493,7 @@ export const getDiffPatchEntries = ( filePatch.title || `File ${index + 1}`, `file-${index}`, resolveTitle, - ); + ).map((entry) => ({ ...entry, filePath: filePatch.filePath })); }); if (fileEntries.length > 0) {