fix: prevent malformed tool diffs from breaking chat
Safely falls back to raw patch text for invalid diffs Protects individual tool parts from crashing the chat Covers malformed apply_patch and edit diff cases
This commit is contained in:
@@ -44,6 +44,7 @@ import { useDurationTickerNow } from './useDurationTicker';
|
|||||||
import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
|
import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
|
||||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { getDiffPatchEntries, getPatchText } from './toolDiffUtils';
|
||||||
|
|
||||||
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-4 sm:!leading-6 tracking-normal';
|
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-4 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);
|
||||||
@@ -308,21 +309,6 @@ const extractFirstChangedLineFromDiff = (diffText: string): number | undefined =
|
|||||||
return firstHunkStart;
|
return firstHunkStart;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPatchText = (value: unknown): string | undefined => {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return /\S/.test(value) ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
const patch = (value as { patch?: unknown }).patch;
|
|
||||||
if (typeof patch === 'string') {
|
|
||||||
return /\S/.test(patch) ? patch : undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
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()) {
|
||||||
@@ -1298,70 +1284,6 @@ const TOOL_NORMAL_ICON_STYLE: React.CSSProperties = { color: 'var(--tools-icon)'
|
|||||||
const TOOL_ERROR_TITLE_STYLE: React.CSSProperties = { color: 'var(--status-error)' };
|
const TOOL_ERROR_TITLE_STYLE: React.CSSProperties = { color: 'var(--status-error)' };
|
||||||
const TOOL_NORMAL_TITLE_STYLE: React.CSSProperties = { color: 'var(--tools-title)' };
|
const TOOL_NORMAL_TITLE_STYLE: React.CSSProperties = { color: 'var(--tools-title)' };
|
||||||
|
|
||||||
type DiffPatchEntry = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
patch: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasUnifiedDiffHunk = (patch: string): boolean => /^@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@/m.test(patch);
|
|
||||||
|
|
||||||
const isUnifiedFileHeaderPair = (minusLine: string, plusLine: string): boolean => {
|
|
||||||
if (!minusLine.startsWith('--- ') || !plusLine.startsWith('+++ ')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldPath = minusLine.slice(4).trim();
|
|
||||||
const newPath = plusLine.slice(4).trim();
|
|
||||||
return oldPath.length > 0 && newPath.length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUnifiedDiffPath = (patch: string, fallbackTitle: string): string => {
|
|
||||||
const plusHeader = patch.match(/^\+\+\+\s+(?:[ab]\/(.+)|(.+))$/m);
|
|
||||||
const rawPath = plusHeader?.[1] ?? plusHeader?.[2];
|
|
||||||
if (!rawPath || rawPath === '/dev/null') {
|
|
||||||
return fallbackTitle;
|
|
||||||
}
|
|
||||||
return rawPath;
|
|
||||||
};
|
|
||||||
|
|
||||||
const splitUnifiedDiffPatch = (patch: string): DiffPatchEntry[] => {
|
|
||||||
const normalized = patch.replace(/\r\n/g, '\n').trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = normalized.split('\n');
|
|
||||||
const starts: number[] = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < lines.length; index += 1) {
|
|
||||||
const line = lines[index] ?? '';
|
|
||||||
const nextLine = lines[index + 1] ?? '';
|
|
||||||
if (line.startsWith('diff --git ') || line.startsWith('Index: ') || isUnifiedFileHeaderPair(line, nextLine)) {
|
|
||||||
starts.push(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks = starts.length > 0
|
|
||||||
? starts.map((start, index) => lines.slice(start, starts[index + 1] ?? lines.length).join('\n').trim())
|
|
||||||
: [normalized];
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
.map((chunk, index) => {
|
|
||||||
if (!hasUnifiedDiffHunk(chunk)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = getUnifiedDiffPath(chunk, `Diff ${index + 1}`);
|
|
||||||
return {
|
|
||||||
id: `${title}-${index}`,
|
|
||||||
title,
|
|
||||||
patch: chunk,
|
|
||||||
} satisfies DiffPatchEntry;
|
|
||||||
})
|
|
||||||
.filter((entry): entry is DiffPatchEntry => entry !== null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPathLikeGitChanges = (path: string, grow = true) => {
|
const renderPathLikeGitChanges = (path: string, grow = true) => {
|
||||||
const lastSlash = path.lastIndexOf('/');
|
const lastSlash = path.lastIndexOf('/');
|
||||||
if (lastSlash === -1) {
|
if (lastSlash === -1) {
|
||||||
@@ -1447,60 +1369,48 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDiffPatchEntries = (
|
const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => (
|
||||||
metadata: Record<string, unknown> | undefined,
|
<pre
|
||||||
fallbackDiff: string,
|
className="m-0 overflow-auto whitespace-pre-wrap break-words rounded-lg p-2 typography-code"
|
||||||
currentDirectory: string,
|
style={{
|
||||||
): DiffPatchEntry[] => {
|
backgroundColor: 'var(--syntax-base-background)',
|
||||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
color: 'var(--syntax-base-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{diff}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
|
||||||
const entries = files
|
class DiffPreviewErrorBoundary extends React.Component<{
|
||||||
.map((file, index) => {
|
resetKey: string;
|
||||||
if (!file || typeof file !== 'object') {
|
fallback: React.ReactNode;
|
||||||
return null;
|
children: React.ReactNode;
|
||||||
}
|
}, { hasError: boolean }> {
|
||||||
|
state = { hasError: false };
|
||||||
|
|
||||||
const record = file as { relativePath?: unknown; filePath?: unknown; patch?: unknown; diff?: unknown };
|
static getDerivedStateFromError(): { hasError: boolean } {
|
||||||
const patch = getPatchText(record.patch) ?? getPatchText(record.diff) ?? '';
|
return { hasError: true };
|
||||||
const splitPatchEntries = splitUnifiedDiffPatch(patch);
|
|
||||||
if (!patch || splitPatchEntries.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPath = typeof record.relativePath === 'string'
|
|
||||||
? record.relativePath
|
|
||||||
: typeof record.filePath === 'string'
|
|
||||||
? record.filePath
|
|
||||||
: `File ${index + 1}`;
|
|
||||||
|
|
||||||
const title = typeof rawPath === 'string'
|
|
||||||
? getRelativePath(rawPath, currentDirectory)
|
|
||||||
: `File ${index + 1}`;
|
|
||||||
|
|
||||||
return splitPatchEntries.map((entry, splitIndex) => ({
|
|
||||||
id: `${title}-${index}-${splitIndex}`,
|
|
||||||
title: splitPatchEntries.length === 1 ? title : getRelativePath(entry.title, currentDirectory),
|
|
||||||
patch: entry.patch,
|
|
||||||
} satisfies DiffPatchEntry));
|
|
||||||
})
|
|
||||||
.flat()
|
|
||||||
.filter((entry): entry is DiffPatchEntry => entry !== null);
|
|
||||||
|
|
||||||
if (entries.length > 0) {
|
|
||||||
return entries;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const splitEntries = splitUnifiedDiffPatch(fallbackDiff).map((entry) => ({
|
componentDidUpdate(prevProps: { resetKey: string }) {
|
||||||
...entry,
|
if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
|
||||||
title: getRelativePath(entry.title, currentDirectory),
|
this.setState({ hasError: false });
|
||||||
}));
|
}
|
||||||
|
|
||||||
if (splitEntries.length > 0) {
|
|
||||||
return splitEntries;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
componentDidCatch(error: Error) {
|
||||||
};
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.warn('Tool diff preview failed; rendering raw patch instead.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {
|
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {
|
||||||
const options = React.useMemo(
|
const options = React.useMemo(
|
||||||
@@ -1520,14 +1430,18 @@ const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme,
|
|||||||
[diffViewMode, pierreTheme, pierreThemeType]
|
[diffViewMode, pierreTheme, pierreThemeType]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fallback = <PlainDiffFallback diff={diff} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="typography-code px-1 pb-1 pt-0">
|
<div className="typography-code px-1 pb-1 pt-0">
|
||||||
<PatchDiff
|
<DiffPreviewErrorBoundary resetKey={diff} fallback={fallback}>
|
||||||
patch={diff}
|
<PatchDiff
|
||||||
metrics={TOOL_DIFF_METRICS}
|
patch={diff}
|
||||||
options={options}
|
metrics={TOOL_DIFF_METRICS}
|
||||||
className="block w-full"
|
options={options}
|
||||||
/>
|
className="block w-full"
|
||||||
|
/>
|
||||||
|
</DiffPreviewErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1559,13 +1473,17 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
|
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
|
||||||
const outputString = typeof rawOutput === 'string' ? rawOutput : '';
|
const outputString = typeof rawOutput === 'string' ? rawOutput : '';
|
||||||
|
|
||||||
|
const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined;
|
||||||
const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
|
const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
|
||||||
?? getPatchText(metadata?.diff)
|
?? getPatchText(metadata?.diff)
|
||||||
|
?? getPatchText(fileDiff?.patch)
|
||||||
|
?? getPatchText(fileDiff?.diff)
|
||||||
?? null;
|
?? null;
|
||||||
const diffEntries = React.useMemo(
|
const diffEntries = React.useMemo(
|
||||||
() => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []),
|
() => getDiffPatchEntries(metadata, diffContent ?? undefined, (path) => getRelativePath(path, currentDirectory)),
|
||||||
[currentDirectory, diffContent, metadata]
|
[currentDirectory, diffContent, metadata]
|
||||||
);
|
);
|
||||||
|
const hasVisualDiffEntry = diffEntries.some((entry) => entry.renderMode === 'diff');
|
||||||
const hideToolInputPreview = part.tool === 'apply_patch'
|
const hideToolInputPreview = part.tool === 'apply_patch'
|
||||||
|| part.tool === 'edit'
|
|| part.tool === 'edit'
|
||||||
|| part.tool === 'multiedit';
|
|| part.tool === 'multiedit';
|
||||||
@@ -1752,12 +1670,16 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
{renderPathLikeGitChanges(entry.title)}
|
{renderPathLikeGitChanges(entry.title)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<DiffPreview
|
{entry.renderMode === 'diff' ? (
|
||||||
diff={entry.patch}
|
<DiffPreview
|
||||||
pierreTheme={pierreTheme}
|
diff={entry.patch}
|
||||||
pierreThemeType={pierreThemeType}
|
pierreTheme={pierreTheme}
|
||||||
diffViewMode={diffViewMode}
|
pierreThemeType={pierreThemeType}
|
||||||
/>
|
diffViewMode={diffViewMode}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PlainDiffFallback diff={entry.patch} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{renderDiagnosticsSection()}
|
{renderDiagnosticsSection()}
|
||||||
@@ -1840,7 +1762,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
|
|
||||||
{state.status === 'completed' && 'output' in state && (
|
{state.status === 'completed' && 'output' in state && (
|
||||||
<div>
|
<div>
|
||||||
{(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch' || part.tool === 'write') && diffContent ? (
|
{(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch' || part.tool === 'write') && hasVisualDiffEntry ? (
|
||||||
<div className="mb-1 flex items-center justify-end gap-2">
|
<div className="mb-1 flex items-center justify-end gap-2">
|
||||||
<DiffViewToggle
|
<DiffViewToggle
|
||||||
mode={diffViewMode}
|
mode={diffViewMode}
|
||||||
@@ -1873,7 +1795,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
|
|
||||||
ToolExpandedContent.displayName = 'ToolExpandedContent';
|
ToolExpandedContent.displayName = 'ToolExpandedContent';
|
||||||
|
|
||||||
const ToolPart: React.FC<ToolPartProps> = ({
|
const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||||
part,
|
part,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -2831,6 +2753,72 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class ToolPartErrorBoundary extends React.Component<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
displayName: string;
|
||||||
|
errorLabel: string;
|
||||||
|
resetKey: unknown;
|
||||||
|
toolName: string;
|
||||||
|
}, { hasError: boolean; error?: Error }> {
|
||||||
|
state: { hasError: boolean; error?: Error } = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): { hasError: boolean; error: Error } {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: { resetKey: unknown }) {
|
||||||
|
if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
|
||||||
|
this.setState({ hasError: false, error: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.warn('Tool part failed to render; showing safe fallback.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.hasError) {
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = this.state.error?.message;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 pr-2 pl-px py-1.5 rounded-xl min-w-0">
|
||||||
|
<div className="h-3.5 w-3.5 flex-shrink-0" style={TOOL_ERROR_ICON_STYLE}>
|
||||||
|
{getToolIcon(this.props.toolName)}
|
||||||
|
</div>
|
||||||
|
<span className={cn(TOOL_ROW_TITLE_CLASS, 'flex-shrink-0')} style={TOOL_ERROR_TITLE_STYLE}>
|
||||||
|
{this.props.displayName}
|
||||||
|
</span>
|
||||||
|
{message ? (
|
||||||
|
<span className={cn(TOOL_ROW_DESCRIPTION_CLASS, 'min-w-0 truncate')} style={{ color: 'var(--tools-description)' }} title={message}>
|
||||||
|
{this.props.errorLabel}: {message}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolPart: React.FC<ToolPartProps> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const toolName = normalizeToolName(props.part.tool) || 'tool';
|
||||||
|
const displayName = getToolMetadata(toolName).displayName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolPartErrorBoundary
|
||||||
|
displayName={displayName}
|
||||||
|
errorLabel={t('chat.toolPart.error')}
|
||||||
|
resetKey={props.part}
|
||||||
|
toolName={toolName}
|
||||||
|
>
|
||||||
|
<ToolPartContent {...props} />
|
||||||
|
</ToolPartErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default React.memo(ToolPart, (prev, next) => {
|
export default React.memo(ToolPart, (prev, next) => {
|
||||||
return areRenderRelevantPartsEqual([prev.part], [next.part])
|
return areRenderRelevantPartsEqual([prev.part], [next.part])
|
||||||
&& prev.isExpanded === next.isExpanded
|
&& prev.isExpanded === next.isExpanded
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { getDiffPatchEntries, getRenderablePatchInfo } from './toolDiffUtils';
|
||||||
|
|
||||||
|
const identity = (path: string) => path;
|
||||||
|
|
||||||
|
describe('toolDiffUtils', () => {
|
||||||
|
test('treats raw apply_patch envelopes as text, not visual diffs', () => {
|
||||||
|
const entries = getDiffPatchEntries(undefined, [
|
||||||
|
'*** Begin Patch',
|
||||||
|
'*** Update File: src/app.ts',
|
||||||
|
'@@ -1 +1 @@',
|
||||||
|
'-old',
|
||||||
|
'+new',
|
||||||
|
'*** End Patch',
|
||||||
|
].join('\n'), identity);
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]?.renderMode).toBe('text');
|
||||||
|
expect(entries[0]?.patch).toContain('*** Begin Patch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splits multi-file unified patches into one renderable entry per file', () => {
|
||||||
|
const entries = getDiffPatchEntries(undefined, [
|
||||||
|
'--- a/src/a.ts',
|
||||||
|
'+++ b/src/a.ts',
|
||||||
|
'@@ -1 +1 @@',
|
||||||
|
'-old',
|
||||||
|
'+new',
|
||||||
|
'--- a/src/b.ts',
|
||||||
|
'+++ b/src/b.ts',
|
||||||
|
'@@ -1 +1 @@',
|
||||||
|
'-left',
|
||||||
|
'+right',
|
||||||
|
].join('\n'), identity);
|
||||||
|
|
||||||
|
expect(entries.map((entry) => entry.renderMode)).toEqual(['diff', 'diff']);
|
||||||
|
expect(entries.map((entry) => entry.title)).toEqual(['src/a.ts', 'src/b.ts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses metadata.files patches before top-level fallback diffs', () => {
|
||||||
|
const entries = getDiffPatchEntries({
|
||||||
|
files: [{
|
||||||
|
relativePath: 'src/file.ts',
|
||||||
|
patch: [
|
||||||
|
'--- a/src/file.ts',
|
||||||
|
'+++ b/src/file.ts',
|
||||||
|
'@@ -1 +1 @@',
|
||||||
|
'-old',
|
||||||
|
'+new',
|
||||||
|
].join('\n'),
|
||||||
|
}],
|
||||||
|
}, 'not a diff', identity);
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]?.renderMode).toBe('diff');
|
||||||
|
expect(entries[0]?.title).toBe('src/file.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('synthesizes headers for valid headerless hunks', () => {
|
||||||
|
const entries = getDiffPatchEntries(undefined, [
|
||||||
|
'@@ -1 +1 @@',
|
||||||
|
'-old',
|
||||||
|
'+new',
|
||||||
|
].join('\n'), identity);
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]?.renderMode).toBe('diff');
|
||||||
|
expect(getRenderablePatchInfo(entries[0]?.patch ?? '')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps malformed unified patches as text fallbacks', () => {
|
||||||
|
const entries = getDiffPatchEntries(undefined, [
|
||||||
|
'--- a/src/file.ts',
|
||||||
|
'+++ b/src/file.ts',
|
||||||
|
'@@',
|
||||||
|
'-old',
|
||||||
|
'+new',
|
||||||
|
].join('\n'), identity);
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]?.renderMode).toBe('text');
|
||||||
|
expect(entries[0]?.patch).toContain('@@');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { parsePatchFiles } from '@pierre/diffs';
|
||||||
|
|
||||||
|
export type DiffPatchEntry = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
patch: string;
|
||||||
|
renderMode: 'diff' | 'text';
|
||||||
|
};
|
||||||
|
|
||||||
|
const APPLY_PATCH_ENVELOPE_PATTERN = /^\*\*\*\s+(?:Begin Patch|End Patch|Add File:|Update File:|Delete File:|Move to:)/m;
|
||||||
|
const HUNK_HEADER_PATTERN = /^@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@/m;
|
||||||
|
const GIT_DIFF_FILE_BREAK_PATTERN = /(?=^diff --git\s+)/gm;
|
||||||
|
const GIT_DIFF_FILE_BREAK_TEST = /^diff --git\s+/m;
|
||||||
|
const UNIFIED_DIFF_FILE_BREAK_PATTERN = /(?=^---\s+\S)/gm;
|
||||||
|
const UNIFIED_DIFF_FILE_BREAK_TEST = /^---\s+\S/m;
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizePatchText = (patch: string): string => {
|
||||||
|
return patch.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPatchText = (value: unknown): string | undefined => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return /\S/.test(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const patch = value.patch;
|
||||||
|
if (typeof patch === 'string') {
|
||||||
|
return /\S/.test(patch) ? patch : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeParsedPath = (path: string | undefined): string => {
|
||||||
|
const trimmed = (path ?? '').trim().replace(/\t.*$/, '');
|
||||||
|
if (!trimmed || trimmed === '/dev/null') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return trimmed.replace(/^[ab]\//, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeSyntheticPath = (title: string): string => {
|
||||||
|
const normalized = title.trim().replace(/\s+/g, '-');
|
||||||
|
return normalized.length > 0 ? normalized : 'file';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasOnlyUnifiedDiffBodyLines = (patch: string): boolean => {
|
||||||
|
let inHunk = false;
|
||||||
|
for (const line of patch.split('\n')) {
|
||||||
|
if (line.startsWith('@@')) {
|
||||||
|
if (!HUNK_HEADER_PATTERN.test(line)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
inHunk = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inHunk || line.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = line[0];
|
||||||
|
if (first !== ' ' && first !== '+' && first !== '-' && first !== '\\') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRenderablePatchInfo = (patch: string): { patch: string; title?: string } | null => {
|
||||||
|
const normalized = normalizePatchText(patch);
|
||||||
|
if (
|
||||||
|
!normalized
|
||||||
|
|| APPLY_PATCH_ENVELOPE_PATTERN.test(normalized)
|
||||||
|
|| !HUNK_HEADER_PATTERN.test(normalized)
|
||||||
|
|| !hasOnlyUnifiedDiffBodyLines(normalized)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedPatches = parsePatchFiles(normalized, undefined, true);
|
||||||
|
if (parsedPatches.length !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = parsedPatches[0]?.files ?? [];
|
||||||
|
const file = files[0];
|
||||||
|
if (files.length !== 1 || !file || file.hunks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
patch: normalized,
|
||||||
|
title: normalizeParsedPath(file.name),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPatchChunks = (patch: string): string[] => {
|
||||||
|
const isGitDiff = GIT_DIFF_FILE_BREAK_TEST.test(patch);
|
||||||
|
const hasUnifiedDiff = UNIFIED_DIFF_FILE_BREAK_TEST.test(patch);
|
||||||
|
if (!isGitDiff && !hasUnifiedDiff) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch
|
||||||
|
.split(isGitDiff ? GIT_DIFF_FILE_BREAK_PATTERN : UNIFIED_DIFF_FILE_BREAK_PATTERN)
|
||||||
|
.map((chunk) => chunk.trim())
|
||||||
|
.filter((chunk) => chunk.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPatchEntriesFromText = (
|
||||||
|
patch: string,
|
||||||
|
fallbackTitle: string,
|
||||||
|
idPrefix: string,
|
||||||
|
resolveTitle: (path: string) => string,
|
||||||
|
): DiffPatchEntry[] => {
|
||||||
|
const normalized = normalizePatchText(patch);
|
||||||
|
if (!normalized) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = getRenderablePatchInfo(normalized);
|
||||||
|
if (direct) {
|
||||||
|
const title = direct.title ? resolveTitle(direct.title) : resolveTitle(fallbackTitle);
|
||||||
|
return [{ id: `${idPrefix}-0`, title, patch: direct.patch, renderMode: 'diff' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkEntries: DiffPatchEntry[] = [];
|
||||||
|
for (const chunk of getPatchChunks(normalized)) {
|
||||||
|
const info = getRenderablePatchInfo(chunk);
|
||||||
|
const title = info?.title ? resolveTitle(info.title) : resolveTitle(fallbackTitle);
|
||||||
|
if (!info) {
|
||||||
|
if (HUNK_HEADER_PATTERN.test(chunk) || GIT_DIFF_FILE_BREAK_TEST.test(chunk) || UNIFIED_DIFF_FILE_BREAK_TEST.test(chunk)) {
|
||||||
|
chunkEntries.push({
|
||||||
|
id: `${idPrefix}-${chunkEntries.length}`,
|
||||||
|
title,
|
||||||
|
patch: chunk,
|
||||||
|
renderMode: 'text',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunkEntries.push({
|
||||||
|
id: `${idPrefix}-${chunkEntries.length}`,
|
||||||
|
title,
|
||||||
|
patch: info.patch,
|
||||||
|
renderMode: 'diff',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkEntries.length > 0) {
|
||||||
|
return chunkEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!APPLY_PATCH_ENVELOPE_PATTERN.test(normalized) && HUNK_HEADER_PATTERN.test(normalized)) {
|
||||||
|
const syntheticPath = makeSyntheticPath(fallbackTitle);
|
||||||
|
const synthetic = getRenderablePatchInfo(`--- ${syntheticPath}\n+++ ${syntheticPath}\n${normalized}`);
|
||||||
|
if (synthetic) {
|
||||||
|
return [{
|
||||||
|
id: `${idPrefix}-0`,
|
||||||
|
title: resolveTitle(fallbackTitle),
|
||||||
|
patch: synthetic.patch,
|
||||||
|
renderMode: 'diff',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
id: `${idPrefix}-0`,
|
||||||
|
title: resolveTitle(fallbackTitle),
|
||||||
|
patch: normalized,
|
||||||
|
renderMode: 'text',
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilePatch = (file: unknown): { patch: string; title: string } | null => {
|
||||||
|
if (!isRecord(file)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = getPatchText(file.patch) ?? getPatchText(file.diff);
|
||||||
|
if (!patch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPath = typeof file.relativePath === 'string'
|
||||||
|
? file.relativePath
|
||||||
|
: typeof file.filePath === 'string'
|
||||||
|
? file.filePath
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
patch,
|
||||||
|
title: rawPath,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDiffPatchEntries = (
|
||||||
|
metadata: Record<string, unknown> | undefined,
|
||||||
|
fallbackDiff: string | undefined,
|
||||||
|
resolveTitle: (path: string) => string,
|
||||||
|
): DiffPatchEntry[] => {
|
||||||
|
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||||
|
const fileEntries = files.flatMap((file, index) => {
|
||||||
|
const filePatch = getFilePatch(file);
|
||||||
|
if (!filePatch) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return getPatchEntriesFromText(
|
||||||
|
filePatch.patch,
|
||||||
|
filePatch.title || `File ${index + 1}`,
|
||||||
|
`file-${index}`,
|
||||||
|
resolveTitle,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fileEntries.length > 0) {
|
||||||
|
return fileEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = typeof fallbackDiff === 'string' ? fallbackDiff : '';
|
||||||
|
return getPatchEntriesFromText(diff, 'Diff', 'fallback', resolveTitle);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user