Improve diff display for New, Deleted, and renamed files in Diffs tab (#552)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user