Display edit diffs (#469)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
32
frontend/src/styles/edit-diff-overrides.css
Normal file
32
frontend/src/styles/edit-diff-overrides.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user