Improve diff display for New, Deleted, and renamed files in Diffs tab (#552)

This commit is contained in:
Solomon
2025-08-21 17:47:42 +01:00
committed by GitHub
parent ac69b20bba
commit c0808a6d76
7 changed files with 295 additions and 128 deletions

View File

@@ -1,72 +1,154 @@
import { DiffFile, DiffModeEnum, DiffView } from '@git-diff-view/react';
import { ThemeMode } from 'shared/types';
import '../styles/diff-style-overrides.css';
import { useConfig } from './config-provider';
import { useContext } from 'react';
import { TaskSelectedAttemptContext } from './context/taskDetailsContext';
import { Button } from './ui/button';
import { FolderOpen, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Diff as Diff, ThemeMode } from 'shared/types';
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
import { generateDiffFile } from '@git-diff-view/file';
import { useMemo, useContext } from 'react';
import { useConfig } from '@/components/config-provider';
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
import { Button } from '@/components/ui/button';
import {
ChevronRight,
ChevronUp,
Trash2,
ArrowLeftRight,
FilePlus2,
PencilLine,
Copy,
Key,
} from 'lucide-react';
import '@/styles/diff-style-overrides.css';
import { TaskSelectedAttemptContext } from '@/components/context/taskDetailsContext';
import { attemptsApi } from '@/lib/api';
type Props = {
diffFile: DiffFile;
key: any;
isCollapsed: boolean;
diff: Diff;
expanded: boolean;
onToggle: () => void;
};
const DiffCard = ({ diffFile, key, isCollapsed, onToggle }: Props) => {
function labelAndIcon(diff: Diff) {
const c = diff.change;
if (c === 'deleted') return { label: 'Deleted', Icon: Trash2 };
if (c === 'renamed') return { label: 'Renamed', Icon: ArrowLeftRight };
if (c === 'added')
return { label: undefined as string | undefined, Icon: FilePlus2 };
if (c === 'copied') return { label: 'Copied', Icon: Copy };
if (c === 'permissionChange')
return { label: 'Permission Changed', Icon: Key };
return { label: undefined as string | undefined, Icon: PencilLine };
}
export default function DiffCard({ diff, expanded, onToggle }: Props) {
const { config } = useConfig();
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
const theme = config?.theme === ThemeMode.DARK ? 'dark' : 'light';
let theme: 'light' | 'dark' | undefined = 'light';
if (config?.theme === ThemeMode.DARK) {
theme = 'dark';
}
const oldName = diff.oldPath || undefined;
const newName = diff.newPath || oldName || 'unknown';
const oldLang =
getHighLightLanguageFromPath(oldName || newName || '') || 'plaintext';
const newLang =
getHighLightLanguageFromPath(newName || oldName || '') || 'plaintext';
const { label, Icon } = labelAndIcon(diff);
// Build a diff from raw contents so the viewer can expand beyond hunks
const oldContentSafe = diff.oldContent || '';
const newContentSafe = diff.newContent || '';
const isContentEqual = oldContentSafe === newContentSafe;
const diffFile = useMemo(() => {
if (isContentEqual) return null;
try {
const oldFileName = oldName || newName || 'unknown';
const newFileName = newName || oldName || 'unknown';
const file = generateDiffFile(
oldFileName,
oldContentSafe,
newFileName,
newContentSafe,
oldLang,
newLang
);
file.initRaw();
return file;
} catch (e) {
console.error('Failed to build diff for view', e);
return null;
}
}, [
isContentEqual,
oldName,
newName,
oldLang,
newLang,
oldContentSafe,
newContentSafe,
]);
const add = diffFile?.additionLength ?? 0;
const del = diffFile?.deletionLength ?? 0;
// Title row
const title = (
<p
className="text-xs font-mono overflow-x-auto flex-1"
style={{ color: 'hsl(var(--muted-foreground) / 0.7)' }}
>
<Icon className="h-3 w-3 inline mr-2" aria-hidden />
{label && <span className="mr-2">{label}</span>}
{diff.change === 'renamed' && oldName ? (
<span className="inline-flex items-center gap-2">
<span>{oldName}</span>
<span aria-hidden></span>
<span>{newName}</span>
</span>
) : (
<span>{newName}</span>
)}
<span className="ml-3" style={{ color: 'hsl(var(--console-success))' }}>
+{add}
</span>
<span className="ml-2" style={{ color: 'hsl(var(--console-error))' }}>
-{del}
</span>
</p>
);
const handleOpenInIDE = async () => {
if (!selectedAttempt?.id) return;
try {
const openPath = newName || oldName;
await attemptsApi.openEditor(
selectedAttempt.id,
undefined,
diffFile._newFileName
openPath || undefined
);
} catch (error) {
console.error('Failed to open file in IDE:', error);
} catch (err) {
console.error('Failed to open file in IDE:', err);
}
};
const expandable = true;
return (
<div className="my-4 border" key={key}>
<div
className="flex items-center justify-between px-4 py-2 cursor-pointer select-none hover:bg-muted/50 transition-colors"
onClick={onToggle}
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggle()}
aria-expanded={!isCollapsed}
>
<div className="flex items-center gap-2 overflow-x-auto flex-1">
<ChevronRight
className={cn('h-4 w-4 transition-transform', {
'rotate-90': !isCollapsed,
})}
/>
<p
className="text-xs font-mono"
style={{ color: 'hsl(var(--muted-foreground) / 0.7)' }}
<div className="my-4 border">
<div className="flex items-center px-4 py-2">
{expandable && (
<Button
variant="ghost"
size="sm"
onClick={onToggle}
className="h-6 w-6 p-0 mr-2"
title={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}
>
{diffFile._newFileName}{' '}
<span style={{ color: 'hsl(var(--console-success))' }}>
+{diffFile.additionLength}
</span>{' '}
<span style={{ color: 'hsl(var(--console-error))' }}>
-{diffFile.deletionLength}
</span>
</p>
</div>
{expanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
)}
{title}
<Button
variant="ghost"
size="sm"
@@ -76,22 +158,52 @@ const DiffCard = ({ diffFile, key, isCollapsed, onToggle }: Props) => {
}}
className="h-6 w-6 p-0 ml-2"
title="Open in IDE"
disabled={diff.change === 'deleted'}
>
<FolderOpen className="h-3 w-3" />
{/* Reuse default icon size */}
<svg
viewBox="0 0 24 24"
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M14 2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
<polyline points="14 2 20 2 20 8" />
<line x1="11" y1="13" x2="20" y2="4" />
</svg>
</Button>
</div>
{!isCollapsed && (
<DiffView
diffFile={diffFile}
diffViewWrap={false}
diffViewTheme={theme}
diffViewHighlight
diffViewMode={DiffModeEnum.Unified}
diffViewFontSize={12}
/>
{expanded && diffFile && (
<div>
<DiffView
diffFile={diffFile}
diffViewWrap={false}
diffViewTheme={theme}
diffViewHighlight
diffViewMode={DiffModeEnum.Unified}
diffViewFontSize={12}
/>
</div>
)}
{expanded && !diffFile && (
<div
className="px-4 pb-4 text-xs font-mono"
style={{ color: 'hsl(var(--muted-foreground) / 0.9)' }}
>
{isContentEqual
? diff.change === 'renamed'
? 'File renamed with no content changes.'
: diff.change === 'permissionChange'
? 'File permission changed.'
: 'No content changes to display.'
: 'Failed to render diff for this file.'}
</div>
)}
</div>
);
};
export default DiffCard;
}

View File

@@ -1,12 +1,11 @@
import { generateDiffFile } from '@git-diff-view/file';
import { useDiffEntries } from '@/hooks/useDiffEntries';
import { useMemo, useContext, useCallback, useState, useEffect } from 'react';
import { TaskSelectedAttemptContext } from '@/components/context/taskDetailsContext.ts';
import { Diff } from 'shared/types';
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
import { Loader } from '@/components/ui/loader';
import { Button } from '@/components/ui/button';
import DiffCard from '@/components/DiffCard';
import { generateDiffFile } from '@git-diff-view/file';
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
function DiffTab() {
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
@@ -20,45 +19,55 @@ function DiffTab() {
}
}, [diffs, loading]);
const createDiffFile = useCallback((diff: Diff) => {
const oldFileName = diff.oldFile?.fileName || 'old';
const newFileName = diff.newFile?.fileName || 'new';
const oldContent = diff.oldFile?.content || '';
const newContent = diff.newFile?.content || '';
// Default-collapse certain change kinds on first load
useEffect(() => {
if (diffs.length === 0) return;
if (collapsedIds.size > 0) return; // preserve user toggles if any
const kindsToCollapse = new Set([
'deleted',
'renamed',
'copied',
'permissionChange',
]);
const initial = new Set(
diffs
.filter((d) => kindsToCollapse.has(d.change))
.map((d, i) => d.newPath || d.oldPath || String(i))
);
if (initial.size > 0) setCollapsedIds(initial);
}, [diffs, collapsedIds.size]);
try {
const instance = generateDiffFile(
oldFileName,
oldContent,
newFileName,
newContent,
getHighLightLanguageFromPath(oldFileName) || 'plaintext',
getHighLightLanguageFromPath(newFileName) || 'plaintext'
);
instance.initRaw();
return instance;
} catch (error) {
console.error('Failed to parse diff:', error);
return null;
}
}, []);
const { files: diffFiles, totals } = useMemo(() => {
const files = diffs
.map((diff) => createDiffFile(diff))
.filter((diffFile) => diffFile !== null);
const totals = files.reduce(
(acc, file) => {
acc.added += file.additionLength ?? 0;
acc.deleted += file.deletionLength ?? 0;
const { totals, ids } = useMemo(() => {
const ids = diffs.map((d, i) => d.newPath || d.oldPath || String(i));
const totals = diffs.reduce(
(acc, d) => {
try {
const oldName = d.oldPath || d.newPath || 'old';
const newName = d.newPath || d.oldPath || 'new';
const oldContent = d.oldContent || '';
const newContent = d.newContent || '';
const oldLang = getHighLightLanguageFromPath(oldName) || 'plaintext';
const newLang = getHighLightLanguageFromPath(newName) || 'plaintext';
const file = generateDiffFile(
oldName,
oldContent,
newName,
newContent,
oldLang,
newLang
);
file.initRaw();
acc.added += file.additionLength ?? 0;
acc.deleted += file.deletionLength ?? 0;
} catch (e) {
console.error('Failed to compute totals for diff', e);
}
return acc;
},
{ added: 0, deleted: 0 }
);
return { files, totals };
}, [diffs, createDiffFile]);
return { totals, ids };
}, [diffs]);
const toggle = useCallback((id: string) => {
setCollapsedIds((prev) => {
@@ -68,14 +77,10 @@ function DiffTab() {
});
}, []);
const allCollapsed = collapsedIds.size === diffFiles.length;
const allCollapsed = collapsedIds.size === diffs.length;
const handleCollapseAll = useCallback(() => {
setCollapsedIds(
allCollapsed
? new Set()
: new Set(diffFiles.map((diffFile) => diffFile._newFileName))
);
}, [allCollapsed, diffFiles]);
setCollapsedIds(allCollapsed ? new Set() : new Set(ids));
}, [allCollapsed, ids]);
if (error) {
return (
@@ -95,7 +100,7 @@ function DiffTab() {
return (
<div className="h-full flex flex-col">
{diffFiles.length > 0 && (
{diffs.length > 0 && (
<div className="sticky top-0 bg-background border-b px-4 py-2 z-10">
<div className="flex items-center justify-between gap-4">
<span
@@ -103,8 +108,7 @@ function DiffTab() {
aria-live="polite"
style={{ color: 'hsl(var(--muted-foreground) / 0.7)' }}
>
{diffFiles.length} file{diffFiles.length === 1 ? '' : 's'}{' '}
changed,{' '}
{diffs.length} file{diffs.length === 1 ? '' : 's'} changed,{' '}
<span style={{ color: 'hsl(var(--console-success))' }}>
+{totals.added}
</span>{' '}
@@ -124,14 +128,17 @@ function DiffTab() {
</div>
)}
<div className="flex-1 overflow-y-auto px-4">
{diffFiles.map((diffFile, idx) => (
<DiffCard
key={idx}
diffFile={diffFile}
isCollapsed={collapsedIds.has(diffFile._newFileName)}
onToggle={() => toggle(diffFile._newFileName)}
/>
))}
{diffs.map((diff, idx) => {
const id = diff.newPath || diff.oldPath || String(idx);
return (
<DiffCard
key={id}
diff={diff}
expanded={!collapsedIds.has(id)}
onToggle={() => toggle(id)}
/>
);
})}
</div>
</div>
);