Display edit diffs (#469)

This commit is contained in:
Solomon
2025-08-15 11:18:24 +01:00
committed by GitHub
parent 6d65ea18af
commit ca9504f84b
16 changed files with 842 additions and 143 deletions

View File

@@ -7,7 +7,7 @@ import {
CheckSquare,
ChevronRight,
ChevronUp,
// Edit,
Edit,
Eye,
Globe,
Plus,
@@ -16,7 +16,12 @@ import {
Terminal,
User,
} from 'lucide-react';
import { NormalizedEntry, type NormalizedEntryType } from 'shared/types.ts';
import {
NormalizedEntry,
type NormalizedEntryType,
type ActionType,
} from 'shared/types.ts';
import FileChangeRenderer from './FileChangeRenderer';
type Props = {
entry: NormalizedEntry;
@@ -58,8 +63,8 @@ const getEntryIcon = (entryType: NormalizedEntryType) => {
if (action_type.action === 'file_read') {
return <Eye className="h-4 w-4 text-orange-600" />;
// } else if (action_type.action === 'file_edit') {
// return <Edit className="h-4 w-4 text-red-600" />;
} else if (action_type.action === 'file_edit') {
return <Edit className="h-4 w-4 text-red-600" />;
} else if (action_type.action === 'command_run') {
return <Terminal className="h-4 w-4 text-yellow-600" />;
} else if (action_type.action === 'search') {
@@ -146,6 +151,15 @@ function DisplayConversationEntry({ entry, index }: Props) {
const isExpanded = expandedErrors.has(index);
const hasMultipleLines = isErrorMessage && entry.content.includes('\n');
const fileEdit =
entry.entry_type.type === 'tool_use' &&
entry.entry_type.action_type.action === 'file_edit'
? (entry.entry_type.action_type as Extract<
ActionType,
{ action: 'file_edit' }
>)
: null;
return (
<div key={index} className="px-4 py-1">
<div className="flex items-start gap-3">
@@ -209,6 +223,16 @@ function DisplayConversationEntry({ entry, index }: Props) {
)}
</div>
)}
{fileEdit &&
Array.isArray(fileEdit.changes) &&
fileEdit.changes.map((change, idx) => (
<FileChangeRenderer
key={idx}
path={fileEdit.path}
change={change}
/>
))}
</div>
</div>
</div>

View File

@@ -0,0 +1,131 @@
import { useMemo, useState } from 'react';
import {
DiffView,
DiffModeEnum,
DiffLineType,
parseInstance,
} from '@git-diff-view/react';
import { ThemeMode } from 'shared/types';
import { ChevronRight, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useConfig } from '@/components/config-provider';
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
import '@/styles/diff-style-overrides.css';
import '@/styles/edit-diff-overrides.css';
type Props = {
path: string;
unifiedDiff: string;
hasLineNumbers: boolean;
};
/**
* Process hunks for @git-diff-view/react
* - Extract additions/deletions for display
* - Decide whether to hide line numbers based on backend data
*/
function processUnifiedDiff(unifiedDiff: string, hasLineNumbers: boolean) {
const totalHunks = unifiedDiff
.split('\n')
.filter((line) => line.startsWith('@@ ')).length;
// Hide line numbers when backend says they are unreliable
const hideNums = !hasLineNumbers;
// Pre-compute additions/deletions using the library parser so counts are available while collapsed
let additions = 0;
let deletions = 0;
try {
const parsed = parseInstance.parse(unifiedDiff);
for (const h of parsed.hunks) {
for (const line of h.lines) {
if (line.type === DiffLineType.Add) additions++;
else if (line.type === DiffLineType.Delete) deletions++;
}
}
} catch (err) {
console.error('Failed to parse diff hunks:', err);
}
return {
hunks: [unifiedDiff],
hideLineNumbers: hideNums,
totalHunks,
additions,
deletions,
};
}
function EditDiffRenderer({ path, unifiedDiff, hasLineNumbers }: Props) {
const { config } = useConfig();
const [expanded, setExpanded] = useState(false);
let theme: 'light' | 'dark' | undefined = 'light';
if (config?.theme === ThemeMode.DARK) {
theme = 'dark';
}
const { hunks, hideLineNumbers, totalHunks, additions, deletions } = useMemo(
() => processUnifiedDiff(unifiedDiff, hasLineNumbers),
[path, unifiedDiff, hasLineNumbers]
);
const hideLineNumbersClass = hideLineNumbers ? ' edit-diff-hide-nums' : '';
const diffData = useMemo(() => {
const lang = getHighLightLanguageFromPath(path) || 'plaintext';
return {
hunks,
oldFile: { fileName: path, fileLang: lang },
newFile: { fileName: path, fileLang: lang },
};
}, [hunks, path]);
return (
<div className="my-4 border">
<div className="flex items-center px-4 py-2">
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((e) => !e)}
className="h-6 w-6 p-0 mr-2"
title={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}
>
{expanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
<p
className="text-xs font-mono overflow-x-auto flex-1"
style={{ color: 'hsl(var(--muted-foreground) / 0.7)' }}
>
{path}{' '}
<span style={{ color: 'hsl(var(--console-success))' }}>
+{additions}
</span>{' '}
<span style={{ color: 'hsl(var(--console-error))' }}>
-{deletions}
</span>
</p>
</div>
{expanded && totalHunks > 0 && (
<div className={'mt-2' + hideLineNumbersClass}>
<DiffView
data={diffData}
diffViewWrap={false}
diffViewTheme={theme}
diffViewHighlight
diffViewMode={DiffModeEnum.Unified}
diffViewFontSize={12}
/>
</div>
)}
</div>
);
}
export default EditDiffRenderer;

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { ThemeMode, type FileChange } from 'shared/types';
import { useConfig } from '@/components/config-provider';
import { Button } from '@/components/ui/button';
import {
ChevronRight,
ChevronUp,
Trash2,
ArrowLeftRight,
ArrowRight,
} from 'lucide-react';
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
import EditDiffRenderer from './EditDiffRenderer';
import FileContentView from './FileContentView';
import '@/styles/diff-style-overrides.css';
type Props = {
path: string;
change: FileChange;
};
function isWrite(
change: FileChange
): change is Extract<FileChange, { action: 'write'; content: string }> {
return change?.action === 'write';
}
function isDelete(
change: FileChange
): change is Extract<FileChange, { action: 'delete' }> {
return change?.action === 'delete';
}
function isRename(
change: FileChange
): change is Extract<FileChange, { action: 'rename'; new_path: string }> {
return change?.action === 'rename';
}
function isEdit(
change: FileChange
): change is Extract<FileChange, { action: 'edit' }> {
return change?.action === 'edit';
}
const FileChangeRenderer = ({ path, change }: Props) => {
const { config } = useConfig();
const [expanded, setExpanded] = useState(false);
let theme: 'light' | 'dark' | undefined = 'light';
if (config?.theme === ThemeMode.DARK) theme = 'dark';
// Edit: delegate to EditDiffRenderer for identical styling and behavior
if (isEdit(change)) {
return (
<EditDiffRenderer
path={path}
unifiedDiff={change.unified_diff}
hasLineNumbers={change.has_line_numbers}
/>
);
}
// Title row content and whether the row is expandable
const { titleNode, expandable } = (() => {
const commonTitleClass = 'text-xs font-mono overflow-x-auto flex-1';
const commonTitleStyle = {
color: 'hsl(var(--muted-foreground) / 0.7)',
};
if (isDelete(change)) {
return {
titleNode: (
<p className={commonTitleClass} style={commonTitleStyle}>
<Trash2 className="h-3 w-3 inline mr-1.5" aria-hidden />
Delete <span className="ml-1">{path}</span>
</p>
),
expandable: false,
};
}
if (isRename(change)) {
return {
titleNode: (
<p className={commonTitleClass} style={commonTitleStyle}>
<ArrowLeftRight className="h-3 w-3 inline mr-1.5" aria-hidden />
Rename <span className="ml-1">{path}</span>{' '}
<ArrowRight className="h-3 w-3 inline mx-1" aria-hidden />{' '}
<span>{change.new_path}</span>
</p>
),
expandable: false,
};
}
if (isWrite(change)) {
return {
titleNode: (
<p className={commonTitleClass} style={commonTitleStyle}>
Write to <span className="ml-1">{path}</span>
</p>
),
expandable: true,
};
}
// No fallback: render nothing for unknown change types
return {
titleNode: null,
expandable: false,
};
})();
// nothing to display
if (!titleNode) {
return null;
}
return (
<div className="my-4 border">
<div className="flex items-center px-4 py-2">
{expandable && (
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((e) => !e)}
className="h-6 w-6 p-0 mr-2"
title={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}
>
{expanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
)}
{titleNode}
</div>
{/* Body */}
{isWrite(change) && expanded && (
<FileContentView
content={change.content}
lang={getHighLightLanguageFromPath(path)}
theme={theme}
/>
)}
</div>
);
};
export default FileChangeRenderer;

View File

@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { DiffView, DiffModeEnum } from '@git-diff-view/react';
import { generateDiffFile } from '@git-diff-view/file';
import '@/styles/diff-style-overrides.css';
import '@/styles/edit-diff-overrides.css';
type Props = {
content: string;
lang: string | null;
theme?: 'light' | 'dark';
className?: string;
};
/**
* View syntax highlighted file content.
*/
function FileContentView({ content, lang, theme, className }: Props) {
// Uses the syntax highlighter from @git-diff-view/react without any diff-related features.
// This allows uniform styling with EditDiffRenderer.
const diffFile = useMemo(() => {
try {
const instance = generateDiffFile(
'', // old file
'', // old content (empty)
'', // new file
content, // new content
'', // old lang
lang || 'plaintext' // new lang
);
instance.initRaw();
return instance;
} catch {
return null;
}
}, [content, lang]);
return (
<div
className={['plain-file-content edit-diff-hide-nums', className]
.filter(Boolean)
.join(' ')}
>
<div className="px-4 py-2">
{diffFile ? (
<DiffView
diffFile={diffFile}
diffViewWrap={false}
diffViewTheme={theme}
diffViewHighlight
diffViewMode={DiffModeEnum.Unified}
diffViewFontSize={12}
/>
) : (
<pre className="text-xs font-mono overflow-x-auto whitespace-pre">
{content}
</pre>
)}
</div>
</div>
);
}
export default FileContentView;

View File

@@ -0,0 +1,32 @@
/* Hide line numbers for replace (old/new) diffs rendered via DiffView */
.edit-diff-hide-nums .diff-line-old-num,
.edit-diff-hide-nums .diff-line-new-num,
.edit-diff-hide-nums .diff-line-num {
display: none !important;
}
/* Ensure number gutters don't consume space when hidden */
.edit-diff-hide-nums .diff-line-old-num + .diff-line-old-content,
.edit-diff-hide-nums .diff-line-new-num + .diff-line-new-content,
.edit-diff-hide-nums .diff-line-num + .diff-line-content {
padding-left: 0 !important;
}
.plain-file-content .diff-style-root {
/* neutralize addition backgrounds */
--diff-add-content--: hsl(var(--background));
--diff-add-content-highlight--: hsl(var(--background));
}
.plain-file-content .diff-line-content-operator {
display: none !important; /* hide leading '+' operator column */
}
.plain-file-content .diff-line-content-item {
padding-left: 0 !important; /* remove indent left by operator column */
}
/* hide unified hunk header rows (e.g. @@ -1,+n @@) */
.plain-file-content .diff-line-hunk-content {
display: none !important;
}