fix(vscode): open apply_patch diffs at the correct path #2567
This commit is contained in:
@@ -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(
|
||||||
|
<ApplyPatchFileButtons
|
||||||
|
metadata={{ files }}
|
||||||
|
openDiffLabel="Open file diff"
|
||||||
|
onFileClick={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup.match(/<button/g)).toHaveLength(2);
|
||||||
|
expect(markup).toContain('aria-label="Open file diff: src/first.ts"');
|
||||||
|
expect(markup).toContain('aria-label="Open file diff: src/second.ts"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens each clicked file with its own authoritative path, patch, and line', () => {
|
||||||
|
const openDiffCalls: Parameters<EditorAPI['openDiff']>[] = [];
|
||||||
|
const editor: EditorAPI = {
|
||||||
|
openDiff: async (...args) => { openDiffCalls.push(args); },
|
||||||
|
openFile: async () => undefined,
|
||||||
|
};
|
||||||
|
let propagationStops = 0;
|
||||||
|
const stopPropagation = () => { propagationStops += 1; };
|
||||||
|
const tree = ApplyPatchFileButtons({
|
||||||
|
metadata: { files },
|
||||||
|
openDiffLabel: 'Open file diff',
|
||||||
|
onFileClick: (file, event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const targetPath = typeof file.relativePath === 'string' ? file.relativePath : '';
|
||||||
|
openApplyPatchFileInEditor({
|
||||||
|
currentDirectory: '/workspace/project',
|
||||||
|
diffLabel: `${targetPath} (changes)`,
|
||||||
|
editor,
|
||||||
|
file,
|
||||||
|
isVSCode: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}) as React.ReactElement<{ children: React.ReactNode }>;
|
||||||
|
const buttons = React.Children.toArray(tree.props.children) as React.ReactElement<{
|
||||||
|
onClick: (event: { stopPropagation: () => void }) => void;
|
||||||
|
}>[];
|
||||||
|
|
||||||
|
buttons[0]?.props.onClick({ stopPropagation });
|
||||||
|
buttons[1]?.props.onClick({ stopPropagation });
|
||||||
|
|
||||||
|
expect(propagationStops).toBe(2);
|
||||||
|
expect(openDiffCalls).toEqual([
|
||||||
|
['', '/workspace/project/src/first.ts', 'src/first.ts (changes)', {
|
||||||
|
line: 4,
|
||||||
|
patch: files[0]?.patch,
|
||||||
|
}],
|
||||||
|
['', '/workspace/project/src/second.ts', 'src/second.ts (changes)', {
|
||||||
|
line: 12,
|
||||||
|
patch: files[1]?.patch,
|
||||||
|
}],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Text } from '@/components/ui/text';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { getApplyPatchFilePath } from './toolDiffUtils';
|
||||||
|
|
||||||
|
type ApplyPatchFileEntry = {
|
||||||
|
file: Record<string, unknown>;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
added: number | null;
|
||||||
|
removed: number | 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);
|
||||||
|
return Number.isFinite(parsed) ? Math.max(0, parsed) : null;
|
||||||
|
}
|
||||||
|
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 getApplyPatchFileEntries = (metadata: Record<string, unknown> | undefined): ApplyPatchFileEntry[] => {
|
||||||
|
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||||
|
const entriesByPath = new Map<string, ApplyPatchFileEntry>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file || typeof file !== 'object') continue;
|
||||||
|
const fileRecord = file as Record<string, unknown>;
|
||||||
|
const displayPath = typeof fileRecord.relativePath === 'string'
|
||||||
|
? fileRecord.relativePath
|
||||||
|
: typeof fileRecord.filePath === 'string'
|
||||||
|
? fileRecord.filePath
|
||||||
|
: '';
|
||||||
|
if (!displayPath) continue;
|
||||||
|
|
||||||
|
const added = parseCount(fileRecord.additions);
|
||||||
|
const removed = parseCount(fileRecord.deletions);
|
||||||
|
const existing = entriesByPath.get(displayPath);
|
||||||
|
if (existing) {
|
||||||
|
existing.added = combineCounts(existing.added, added);
|
||||||
|
existing.removed = combineCounts(existing.removed, removed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesByPath.set(displayPath, {
|
||||||
|
file: fileRecord,
|
||||||
|
path: displayPath,
|
||||||
|
name: displayPath.split('/').pop() || displayPath,
|
||||||
|
added,
|
||||||
|
removed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(entriesByPath.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApplyPatchFileButtons = ({
|
||||||
|
animate = true,
|
||||||
|
metadata,
|
||||||
|
onFileClick,
|
||||||
|
openDiffLabel,
|
||||||
|
showFileIcons = true,
|
||||||
|
textClassName,
|
||||||
|
}: {
|
||||||
|
animate?: boolean;
|
||||||
|
metadata: Record<string, unknown> | undefined;
|
||||||
|
onFileClick?: (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
openDiffLabel: string;
|
||||||
|
showFileIcons?: boolean;
|
||||||
|
textClassName?: string;
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const entries = getApplyPatchFileEntries(metadata);
|
||||||
|
if (entries.length <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const hasPerFileDiff = entry.added !== null || entry.removed !== null;
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{showFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null}
|
||||||
|
<Text
|
||||||
|
variant={animate ? 'generate-effect' : 'static'}
|
||||||
|
className={cn('min-w-0 max-w-full truncate', textClassName)}
|
||||||
|
style={{ color: 'var(--tools-description)' }}
|
||||||
|
title={entry.path}
|
||||||
|
>
|
||||||
|
{entry.name}
|
||||||
|
</Text>
|
||||||
|
{hasPerFileDiff ? (
|
||||||
|
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||||
|
<span style={{ color: 'var(--status-success)' }}>+{entry.added ?? 0}</span>
|
||||||
|
<span style={{ color: 'var(--tools-description)' }}>/</span>
|
||||||
|
<span style={{ color: 'var(--status-error)' }}>-{entry.removed ?? 0}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const canOpen = onFileClick && entry.file.type !== 'delete' && getApplyPatchFilePath(entry.file);
|
||||||
|
const actionLabel = `${openDiffLabel}: ${entry.path}`;
|
||||||
|
return canOpen ? (
|
||||||
|
<Button
|
||||||
|
key={entry.path}
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
className={cn('min-w-0 max-w-full gap-1 normal-case font-normal tracking-normal', textClassName)}
|
||||||
|
aria-label={actionLabel}
|
||||||
|
title={actionLabel}
|
||||||
|
onClick={(event) => onFileClick(entry.file, event)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span key={entry.path} className={cn('inline-flex min-w-0 max-w-full items-center gap-1', textClassName)} style={{ color: 'var(--tools-description)' }}>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -54,11 +54,22 @@ import {
|
|||||||
} from './taskToolModel';
|
} from './taskToolModel';
|
||||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||||
import { useI18n } from '@/lib/i18n';
|
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 { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||||
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
|
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
|
||||||
|
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||||
import { getToolDescriptionFallback } from './toolRenderUtils';
|
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_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);
|
const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS);
|
||||||
@@ -77,84 +88,6 @@ interface ToolPartProps {
|
|||||||
animateTailText?: boolean;
|
animateTailText?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMultiFileDescription = (
|
|
||||||
metadata: Record<string, unknown> | 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<string, { path: string; name: string; added: number | null; removed: number | null }>();
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<span key={entry.path} className={cn('inline-flex min-w-0 max-w-full items-center gap-1', TOOL_ROW_DESCRIPTION_CLASS)} style={{ color: 'var(--tools-description)' }}>
|
|
||||||
{showFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null}
|
|
||||||
<Text
|
|
||||||
variant={animate ? 'generate-effect' : 'static'}
|
|
||||||
className={cn('min-w-0 max-w-full truncate', TOOL_ROW_DESCRIPTION_CLASS)}
|
|
||||||
style={{ color: 'var(--tools-description)' }}
|
|
||||||
title={entry.path}
|
|
||||||
>
|
|
||||||
{entry.name}
|
|
||||||
</Text>
|
|
||||||
{hasPerFileDiff ? (
|
|
||||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
|
||||||
<span style={{ color: 'var(--status-success)' }}>+{entry.added ?? 0}</span>
|
|
||||||
<span style={{ color: 'var(--tools-description)' }}>/</span>
|
|
||||||
<span style={{ color: 'var(--status-error)' }}>-{entry.removed ?? 0}</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeToolName = (toolName: string | undefined | null): string => {
|
const normalizeToolName = (toolName: string | undefined | null): string => {
|
||||||
if (typeof toolName !== 'string') {
|
if (typeof toolName !== 'string') {
|
||||||
return '';
|
return '';
|
||||||
@@ -306,54 +239,6 @@ const parseWriteLineCount = (input?: Record<string, unknown>): number | null =>
|
|||||||
return lines;
|
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 buildWritePreviewPatch = (filePath: string | undefined, content: string): string | undefined => {
|
||||||
const normalizedContent = content.replace(/\r\n/g, '\n');
|
const normalizedContent = content.replace(/\r\n/g, '\n');
|
||||||
if (!normalizedContent.trim()) {
|
if (!normalizedContent.trim()) {
|
||||||
@@ -380,73 +265,6 @@ const buildWritePreviewPatch = (filePath: string | undefined, content: string):
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record<string, unknown>): 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<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 = 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 normalizeDisplayPath = (value: string): string => {
|
||||||
const trimmed = value.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/');
|
const trimmed = value.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/');
|
||||||
if (!trimmed || trimmed === '/') {
|
if (!trimmed || trimmed === '/') {
|
||||||
@@ -526,58 +344,6 @@ const normalizeToolDiagnostic = (value: unknown): ToolDiagnostic | null => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPrimaryToolPath = (
|
|
||||||
toolName: string,
|
|
||||||
input: Record<string, unknown> | undefined,
|
|
||||||
metadata: Record<string, unknown> | 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 = (
|
const getToolDiagnosticSection = (
|
||||||
toolName: string,
|
toolName: string,
|
||||||
input: Record<string, unknown> | undefined,
|
input: Record<string, unknown> | undefined,
|
||||||
@@ -1686,9 +1452,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderResultContent = () => {
|
const renderResultContent = () => {
|
||||||
const getEntryAbsolutePath = (entry: DiffPatchEntry) => (
|
const getEntryAbsolutePath = (entry: DiffPatchEntry) => toAbsoluteFilePath(currentDirectory, entry.filePath ?? entry.title);
|
||||||
entry.title.startsWith('/') ? entry.title : `${currentDirectory}/${entry.title}`.replace(/\/+/g, '/')
|
|
||||||
);
|
|
||||||
const openEntryFile = (entry: DiffPatchEntry, event: React.MouseEvent<HTMLButtonElement>) => {
|
const openEntryFile = (entry: DiffPatchEntry, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const line = extractFirstChangedLineFromDiff(entry.patch);
|
const line = extractFirstChangedLineFromDiff(entry.patch);
|
||||||
@@ -2054,6 +1818,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
|||||||
onShowPopup,
|
onShowPopup,
|
||||||
animateTailText = true,
|
animateTailText = true,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const state = part.state;
|
const state = part.state;
|
||||||
const showToolFileIcons = useUIStore((s) => s.showToolFileIcons);
|
const showToolFileIcons = useUIStore((s) => s.showToolFileIcons);
|
||||||
const currentDirectory = useEffectiveDirectory() ?? '';
|
const currentDirectory = useEffectiveDirectory() ?? '';
|
||||||
@@ -2347,6 +2112,26 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
|||||||
}, [descriptionPath, normalizedPartTool, stateWithData, input]);
|
}, [descriptionPath, normalizedPartTool, stateWithData, input]);
|
||||||
const runtime = React.useContext(RuntimeAPIContext);
|
const runtime = React.useContext(RuntimeAPIContext);
|
||||||
|
|
||||||
|
const openApplyPatchFile = (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
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 }) => {
|
const handleMainClick = (e: { stopPropagation: () => void }) => {
|
||||||
if (isTaskTool || !runtime?.editor) {
|
if (isTaskTool || !runtime?.editor) {
|
||||||
onToggle(part.id);
|
onToggle(part.id);
|
||||||
@@ -2356,23 +2141,21 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
|||||||
let filePath: unknown;
|
let filePath: unknown;
|
||||||
let targetLine: number | undefined;
|
let targetLine: number | undefined;
|
||||||
let toolDiff: string | 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;
|
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
|
||||||
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
|
|
||||||
if (typeof filePath === 'string') {
|
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') {
|
} else if (normalizedPartTool === 'apply_patch') {
|
||||||
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
filePath = getPrimaryToolPath(normalizedPartTool, input, metadata);
|
||||||
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
|
|
||||||
filePath = firstFile?.relativePath || firstFile?.filePath;
|
|
||||||
targetLine = getFirstChangedLineFromMetadata(part.tool, metadata);
|
|
||||||
if (typeof filePath === 'string') {
|
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;
|
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;
|
filePath = input?.filePath || input?.file_path || input?.path;
|
||||||
const line = input?.line;
|
const line = input?.line;
|
||||||
targetLine = typeof line === 'number' && Number.isFinite(line) ? Math.trunc(line) : undefined;
|
targetLine = typeof line === 'number' && Number.isFinite(line) ? Math.trunc(line) : undefined;
|
||||||
@@ -2380,11 +2163,8 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
|||||||
|
|
||||||
if (typeof filePath === 'string') {
|
if (typeof filePath === 'string') {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
let absolutePath = filePath;
|
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
|
||||||
if (!filePath.startsWith('/')) {
|
if (runtime.runtime.isVSCode && toolDiff && (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')) {
|
||||||
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)`;
|
const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
|
||||||
void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch: toolDiff });
|
void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch: toolDiff });
|
||||||
return;
|
return;
|
||||||
@@ -2417,60 +2197,76 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
|||||||
{}
|
{}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/tool flex gap-1.5 pr-2 pl-px py-1.5 rounded-xl cursor-pointer',
|
'group/tool flex gap-1.5 pr-2 pl-px py-1.5 rounded-xl',
|
||||||
isMultiFileApplyPatch ? 'flex-wrap items-start' : 'items-center'
|
isMultiFileApplyPatch ? 'flex-wrap items-start' : 'items-center cursor-pointer',
|
||||||
)}
|
)}
|
||||||
onClick={handleMainClick}
|
onClick={isMultiFileApplyPatch ? undefined : handleMainClick}
|
||||||
onKeyDown={handleMainKeyDown}
|
onKeyDown={isMultiFileApplyPatch ? undefined : handleMainKeyDown}
|
||||||
role="button"
|
role={isMultiFileApplyPatch ? undefined : 'button'}
|
||||||
tabIndex={0}
|
tabIndex={isMultiFileApplyPatch ? undefined : 0}
|
||||||
>
|
>
|
||||||
<div className={cn('flex gap-1.5', isMultiFileApplyPatch ? 'w-full min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5' : 'items-center flex-shrink-0')}>
|
<div className={cn('flex gap-1.5', isMultiFileApplyPatch ? 'w-full min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5' : 'items-center flex-shrink-0')}>
|
||||||
{}
|
|
||||||
<div
|
|
||||||
// h-5 matches StaticToolRow's icon column, so expandable
|
|
||||||
// and static rows come out the same height (the 14px
|
|
||||||
// icon alone left these rows ~2px shorter).
|
|
||||||
className="relative h-5 w-3.5 flex-shrink-0 cursor-pointer"
|
|
||||||
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
|
|
||||||
>
|
|
||||||
{}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute inset-0 flex items-center justify-center transition-opacity',
|
|
||||||
isExpanded && 'opacity-0',
|
|
||||||
!isExpanded && 'group-hover/tool:opacity-0'
|
|
||||||
)}
|
|
||||||
style={iconStyle}
|
|
||||||
>
|
|
||||||
{getToolIcon(normalizedPartTool || part.tool)}
|
|
||||||
</div>
|
|
||||||
{}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
|
||||||
isExpanded && 'opacity-100',
|
|
||||||
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isMultiFileApplyPatch ? (
|
{isMultiFileApplyPatch ? (
|
||||||
<>
|
<>
|
||||||
<MinDurationShineText
|
<Button
|
||||||
active={Boolean(isActive && !isError)}
|
variant="ghost"
|
||||||
minDurationMs={300}
|
size="xs"
|
||||||
className={cn(TOOL_ROW_TITLE_CLASS, 'flex-shrink-0')}
|
className="gap-1.5 normal-case"
|
||||||
style={titleStyle}
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={displayName}
|
||||||
title={displayName}
|
title={displayName}
|
||||||
|
onClick={() => onToggle(part.id)}
|
||||||
>
|
>
|
||||||
{displayName}
|
{isExpanded
|
||||||
</MinDurationShineText>
|
? <Icon name="arrow-down-s" className="h-3.5 w-3.5" />
|
||||||
{getMultiFileDescription(metadata, animateTailText, showToolFileIcons)}
|
: getToolIcon(normalizedPartTool || part.tool)}
|
||||||
|
<MinDurationShineText
|
||||||
|
active={Boolean(isActive && !isError)}
|
||||||
|
minDurationMs={300}
|
||||||
|
className={cn(TOOL_ROW_TITLE_CLASS, 'flex-shrink-0')}
|
||||||
|
style={titleStyle}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</MinDurationShineText>
|
||||||
|
</Button>
|
||||||
|
<ApplyPatchFileButtons
|
||||||
|
metadata={metadata}
|
||||||
|
animate={animateTailText}
|
||||||
|
showFileIcons={showToolFileIcons}
|
||||||
|
textClassName={TOOL_ROW_DESCRIPTION_CLASS}
|
||||||
|
openDiffLabel={t('chat.toolPart.openFileDiff')}
|
||||||
|
onFileClick={runtime?.editor ? openApplyPatchFile : undefined}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div
|
||||||
|
// h-5 matches StaticToolRow's icon column, so expandable
|
||||||
|
// and static rows come out the same height (the 14px
|
||||||
|
// icon alone left these rows ~2px shorter).
|
||||||
|
className="relative h-5 w-3.5 flex-shrink-0 cursor-pointer"
|
||||||
|
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 flex items-center justify-center transition-opacity',
|
||||||
|
isExpanded && 'opacity-0',
|
||||||
|
!isExpanded && 'group-hover/tool:opacity-0'
|
||||||
|
)}
|
||||||
|
style={iconStyle}
|
||||||
|
>
|
||||||
|
{getToolIcon(normalizedPartTool || part.tool)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||||
|
isExpanded && 'opacity-100',
|
||||||
|
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<MinDurationShineText
|
<MinDurationShineText
|
||||||
active={Boolean(isActive && !isError)}
|
active={Boolean(isActive && !isError)}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { EditorAPI } from '@/lib/api/types';
|
||||||
|
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||||
|
|
||||||
|
import { extractFirstChangedLineFromDiff, getApplyPatchFilePath, getPatchText } from './toolDiffUtils';
|
||||||
|
|
||||||
|
export const openApplyPatchFileInEditor = ({
|
||||||
|
currentDirectory,
|
||||||
|
diffLabel,
|
||||||
|
editor,
|
||||||
|
file,
|
||||||
|
isVSCode,
|
||||||
|
}: {
|
||||||
|
currentDirectory: string;
|
||||||
|
diffLabel: string;
|
||||||
|
editor: EditorAPI;
|
||||||
|
file: Record<string, unknown>;
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,10 +1,87 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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;
|
const identity = (path: string) => path;
|
||||||
|
|
||||||
describe('toolDiffUtils', () => {
|
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', () => {
|
test('treats raw apply_patch envelopes as text, not visual diffs', () => {
|
||||||
const entries = getDiffPatchEntries(undefined, [
|
const entries = getDiffPatchEntries(undefined, [
|
||||||
'*** Begin Patch',
|
'*** Begin Patch',
|
||||||
@@ -57,6 +134,27 @@ describe('toolDiffUtils', () => {
|
|||||||
expect(entries[0]?.title).toBe('src/file.ts');
|
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', () => {
|
test('synthesizes headers for valid headerless hunks', () => {
|
||||||
const entries = getDiffPatchEntries(undefined, [
|
const entries = getDiffPatchEntries(undefined, [
|
||||||
'@@ -1 +1 @@',
|
'@@ -1 +1 @@',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { parsePatchFiles } from '@pierre/diffs';
|
|||||||
export type DiffPatchEntry = {
|
export type DiffPatchEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
filePath?: string;
|
||||||
patch: string;
|
patch: string;
|
||||||
renderMode: 'diff' | 'text';
|
renderMode: 'diff' | 'text';
|
||||||
};
|
};
|
||||||
@@ -140,6 +141,172 @@ export const getPatchText = (value: unknown): string | undefined => {
|
|||||||
return 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<string, unknown> | undefined,
|
||||||
|
metadata: Record<string, unknown> | 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<string, unknown>,
|
||||||
|
preferredPath?: string,
|
||||||
|
): Record<string, unknown> | 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<string, unknown> => (
|
||||||
|
isRecord(file)
|
||||||
|
&& (file.relativePath === preferredPath || file.filePath === preferredPath || file.movePath === preferredPath)
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPrimaryDiffFromMetadata = (
|
||||||
|
toolName: string,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
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 normalizeParsedPath = (path: string | undefined): string => {
|
||||||
const trimmed = (path ?? '').trim().replace(/\t.*$/, '');
|
const trimmed = (path ?? '').trim().replace(/\t.*$/, '');
|
||||||
if (!trimmed || trimmed === '/dev/null') {
|
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)) {
|
if (!isRecord(file)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -304,6 +471,7 @@ const getFilePatch = (file: unknown): { patch: string; title: string } | null =>
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
filePath: getApplyPatchFilePath(file) ?? undefined,
|
||||||
patch,
|
patch,
|
||||||
title: rawPath,
|
title: rawPath,
|
||||||
};
|
};
|
||||||
@@ -325,7 +493,7 @@ export const getDiffPatchEntries = (
|
|||||||
filePatch.title || `File ${index + 1}`,
|
filePatch.title || `File ${index + 1}`,
|
||||||
`file-${index}`,
|
`file-${index}`,
|
||||||
resolveTitle,
|
resolveTitle,
|
||||||
);
|
).map((entry) => ({ ...entry, filePath: filePatch.filePath }));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fileEntries.length > 0) {
|
if (fileEntries.length > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user