fix(vscode): open each apply patch file
This commit is contained in:
@@ -53,7 +53,7 @@ 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, getPrimaryToolPath, type DiffPatchEntry } from './toolDiffUtils';
|
import { getApplyPatchFilePath, getDiffPatchEntries, getPatchText, 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';
|
||||||
@@ -80,6 +80,7 @@ const getMultiFileDescription = (
|
|||||||
metadata: Record<string, unknown> | undefined,
|
metadata: Record<string, unknown> | undefined,
|
||||||
animate = true,
|
animate = true,
|
||||||
showFileIcons = true,
|
showFileIcons = true,
|
||||||
|
onFileClick?: (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => void,
|
||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
||||||
if (files.length <= 1) return null;
|
if (files.length <= 1) return null;
|
||||||
@@ -103,10 +104,11 @@ const getMultiFileDescription = (
|
|||||||
return base + incoming;
|
return base + incoming;
|
||||||
};
|
};
|
||||||
|
|
||||||
const entriesByPath = new Map<string, { path: string; name: string; added: number | null; removed: number | null }>();
|
const entriesByPath = new Map<string, { file: Record<string, unknown>; path: string; name: string; added: number | null; removed: number | null }>();
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const fileObj = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown };
|
if (!file || typeof file !== 'object') continue;
|
||||||
|
const fileObj = file as Record<string, unknown> & { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown };
|
||||||
const filePath = fileObj.relativePath || fileObj.filePath || '';
|
const filePath = fileObj.relativePath || fileObj.filePath || '';
|
||||||
if (!filePath) continue;
|
if (!filePath) continue;
|
||||||
const fileName = filePath.split('/').pop() || filePath;
|
const fileName = filePath.split('/').pop() || filePath;
|
||||||
@@ -120,7 +122,7 @@ const getMultiFileDescription = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
entriesByPath.set(filePath, { path: filePath, name: fileName, added, removed });
|
entriesByPath.set(filePath, { file: fileObj, path: filePath, name: fileName, added, removed });
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = Array.from(entriesByPath.values());
|
const entries = Array.from(entriesByPath.values());
|
||||||
@@ -129,8 +131,8 @@ const getMultiFileDescription = (
|
|||||||
<>
|
<>
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const hasPerFileDiff = entry.added !== null || entry.removed !== null;
|
const hasPerFileDiff = entry.added !== null || entry.removed !== null;
|
||||||
return (
|
const content = (
|
||||||
<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}
|
{showFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null}
|
||||||
<Text
|
<Text
|
||||||
variant={animate ? 'generate-effect' : 'static'}
|
variant={animate ? 'generate-effect' : 'static'}
|
||||||
@@ -147,6 +149,23 @@ const getMultiFileDescription = (
|
|||||||
<span style={{ color: 'var(--status-error)' }}>-{entry.removed ?? 0}</span>
|
<span style={{ color: 'var(--status-error)' }}>-{entry.removed ?? 0}</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const canOpen = onFileClick && entry.file.type !== 'delete' && getApplyPatchFilePath(entry.file);
|
||||||
|
return canOpen ? (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
type="button"
|
||||||
|
className={cn('inline-flex min-w-0 max-w-full items-center gap-1 rounded-sm text-left hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', TOOL_ROW_DESCRIPTION_CLASS)}
|
||||||
|
style={{ color: 'var(--tools-description)' }}
|
||||||
|
onClick={(event) => onFileClick(entry.file, event)}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<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)' }}>
|
||||||
|
{content}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1633,9 +1652,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);
|
||||||
@@ -2291,6 +2308,24 @@ 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>) => {
|
||||||
|
const filePath = getApplyPatchFilePath(file);
|
||||||
|
if (!runtime?.editor || !filePath || file.type === 'delete') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
const patch = getPatchText(file.patch) ?? getPatchText(file.diff);
|
||||||
|
const targetLine = patch ? extractFirstChangedLineFromDiff(patch) : undefined;
|
||||||
|
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
|
||||||
|
if (runtime.runtime.isVSCode && patch) {
|
||||||
|
const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
|
||||||
|
void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runtime.editor.openFile(absolutePath, targetLine);
|
||||||
|
};
|
||||||
|
|
||||||
const handleMainClick = (e: { stopPropagation: () => void }) => {
|
const handleMainClick = (e: { stopPropagation: () => void }) => {
|
||||||
if (isTaskTool || !runtime?.editor) {
|
if (isTaskTool || !runtime?.editor) {
|
||||||
onToggle(part.id);
|
onToggle(part.id);
|
||||||
@@ -2403,7 +2438,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
|||||||
>
|
>
|
||||||
{displayName}
|
{displayName}
|
||||||
</MinDurationShineText>
|
</MinDurationShineText>
|
||||||
{getMultiFileDescription(metadata, animateTailText, showToolFileIcons)}
|
{getMultiFileDescription(metadata, animateTailText, showToolFileIcons, runtime?.editor ? openApplyPatchFile : undefined)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { getDiffPatchEntries, getPrimaryToolPath, getRenderablePatchInfo } from './toolDiffUtils';
|
import { getApplyPatchFilePath, getDiffPatchEntries, getPrimaryToolPath, getRenderablePatchInfo } from './toolDiffUtils';
|
||||||
|
|
||||||
const identity = (path: string) => path;
|
const identity = (path: string) => path;
|
||||||
|
|
||||||
@@ -35,6 +35,18 @@ describe('toolDiffUtils', () => {
|
|||||||
})).toBe('src/file.ts');
|
})).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('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',
|
||||||
@@ -87,6 +99,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,20 @@ 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 = (
|
export const getPrimaryToolPath = (
|
||||||
toolName: string,
|
toolName: string,
|
||||||
input: Record<string, unknown> | undefined,
|
input: Record<string, unknown> | undefined,
|
||||||
@@ -147,17 +162,15 @@ export const getPrimaryToolPath = (
|
|||||||
): string | null => {
|
): string | null => {
|
||||||
if (toolName === 'apply_patch') {
|
if (toolName === 'apply_patch') {
|
||||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||||
const first = files.find((entry) => isRecord(entry) && entry.type !== 'delete');
|
for (const file of files) {
|
||||||
if (!isRecord(first)) {
|
if (isRecord(file) && file.type !== 'delete') {
|
||||||
return null;
|
const filePath = getApplyPatchFilePath(file);
|
||||||
|
if (filePath) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return typeof first.movePath === 'string'
|
return null;
|
||||||
? first.movePath
|
|
||||||
: typeof first.filePath === 'string'
|
|
||||||
? first.filePath
|
|
||||||
: typeof first.relativePath === 'string'
|
|
||||||
? first.relativePath
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolName === 'edit' || toolName === 'multiedit') {
|
if (toolName === 'edit' || toolName === 'multiedit') {
|
||||||
@@ -334,7 +347,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;
|
||||||
}
|
}
|
||||||
@@ -351,6 +364,7 @@ const getFilePatch = (file: unknown): { patch: string; title: string } | null =>
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
filePath: getApplyPatchFilePath(file) ?? undefined,
|
||||||
patch,
|
patch,
|
||||||
title: rawPath,
|
title: rawPath,
|
||||||
};
|
};
|
||||||
@@ -372,7 +386,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