bb471a0917
Replace the raw single-blob diff dump (Monaco, language=diff) and the plain <pre> file diffs in chat with @git-diff-view/react: - Parse the unified git diff into structured per-file entries (status, +/- counts, binary detection) via parseDiffFiles() - Workspace Diff tab: collapsible per-file cards with status badges, line counts, syntax highlighting, and a Unified/Split toggle - Agent chat: render each change's diff highlighted instead of plain text - Theme follows next-themes resolvedTheme (light/dark)
197 lines
6.2 KiB
TypeScript
197 lines
6.2 KiB
TypeScript
'use client';
|
||
|
||
import { useMemo, useState } from 'react';
|
||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||
|
||
import { Button } from '@spoon/ui';
|
||
|
||
import type { DiffMode } from './diff-file-view';
|
||
import type { DiffFileStatus, ParsedDiffFile } from './diff-utils';
|
||
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
||
import { parseDiffFiles } from './diff-utils';
|
||
|
||
const statusBadge: Record<
|
||
DiffFileStatus,
|
||
{ label: string; className: string }
|
||
> = {
|
||
added: { label: 'Added', className: 'bg-emerald-500/15 text-emerald-500' },
|
||
deleted: { label: 'Deleted', className: 'bg-red-500/15 text-red-500' },
|
||
modified: { label: 'Modified', className: 'bg-amber-500/15 text-amber-500' },
|
||
renamed: { label: 'Renamed', className: 'bg-sky-500/15 text-sky-500' },
|
||
};
|
||
|
||
const totals = (files: ParsedDiffFile[]) =>
|
||
files.reduce(
|
||
(acc, file) => ({
|
||
additions: acc.additions + file.additions,
|
||
deletions: acc.deletions + file.deletions,
|
||
}),
|
||
{ additions: 0, deletions: 0 },
|
||
);
|
||
|
||
const FileCard = ({
|
||
file,
|
||
mode,
|
||
theme,
|
||
defaultOpen,
|
||
}: {
|
||
file: ParsedDiffFile;
|
||
mode: DiffMode;
|
||
theme: 'light' | 'dark';
|
||
defaultOpen: boolean;
|
||
}) => {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
const badge = statusBadge[file.status];
|
||
return (
|
||
<div className='border-border overflow-hidden rounded-md border'>
|
||
<button
|
||
type='button'
|
||
onClick={() => setOpen((value) => !value)}
|
||
className='bg-muted/40 hover:bg-muted/70 flex w-full items-center gap-2 px-3 py-2 text-left transition-colors'
|
||
>
|
||
{open ? (
|
||
<ChevronDown className='text-muted-foreground size-4 flex-none' />
|
||
) : (
|
||
<ChevronRight className='text-muted-foreground size-4 flex-none' />
|
||
)}
|
||
<span className='min-w-0 flex-1 truncate font-mono text-xs'>
|
||
{file.status === 'renamed' && file.oldPath !== file.newPath ? (
|
||
<>
|
||
<span className='text-muted-foreground'>{file.oldPath} → </span>
|
||
{file.newPath}
|
||
</>
|
||
) : (
|
||
file.displayPath
|
||
)}
|
||
</span>
|
||
<span
|
||
className={`flex-none rounded px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
|
||
>
|
||
{badge.label}
|
||
</span>
|
||
<span className='flex-none font-mono text-xs'>
|
||
{file.additions > 0 ? (
|
||
<span className='text-emerald-500'>+{file.additions}</span>
|
||
) : null}{' '}
|
||
{file.deletions > 0 ? (
|
||
<span className='text-red-500'>−{file.deletions}</span>
|
||
) : null}
|
||
</span>
|
||
</button>
|
||
{open ? (
|
||
<div className='overflow-x-auto'>
|
||
<DiffFileView file={file} mode={mode} theme={theme} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const DiffViewer = ({
|
||
diff,
|
||
focusedPath,
|
||
onRefresh,
|
||
onClearFocusedPath,
|
||
}: {
|
||
diff: string;
|
||
focusedPath?: string;
|
||
onRefresh: () => Promise<void>;
|
||
onClearFocusedPath?: () => void;
|
||
}) => {
|
||
const [mode, setMode] = useState<DiffMode>('unified');
|
||
const theme = useDiffTheme();
|
||
|
||
const files = useMemo(() => parseDiffFiles(diff), [diff]);
|
||
const normalizedFocus = focusedPath?.replace(/^\.\/+/, '');
|
||
const visibleFiles = useMemo(
|
||
() =>
|
||
normalizedFocus
|
||
? files.filter(
|
||
(file) =>
|
||
file.displayPath === normalizedFocus ||
|
||
file.newPath === normalizedFocus ||
|
||
file.oldPath === normalizedFocus,
|
||
)
|
||
: files,
|
||
[files, normalizedFocus],
|
||
);
|
||
const stats = totals(visibleFiles);
|
||
|
||
return (
|
||
<div className='flex h-full min-h-0 flex-col'>
|
||
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
|
||
<div className='min-w-0'>
|
||
<p className='truncate text-sm font-medium'>
|
||
{focusedPath ? `Diff: ${focusedPath}` : 'Diff viewer'}
|
||
</p>
|
||
<p className='text-muted-foreground truncate text-xs'>
|
||
{visibleFiles.length > 0
|
||
? `${visibleFiles.length} ${visibleFiles.length === 1 ? 'file' : 'files'}, `
|
||
: ''}
|
||
<span className='text-emerald-500'>+{stats.additions}</span>{' '}
|
||
<span className='text-red-500'>−{stats.deletions}</span>
|
||
</p>
|
||
</div>
|
||
<div className='flex flex-none items-center gap-2'>
|
||
<div className='border-border flex items-center rounded-md border p-0.5'>
|
||
<button
|
||
type='button'
|
||
onClick={() => setMode('unified')}
|
||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||
mode === 'unified'
|
||
? 'bg-muted text-foreground'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
>
|
||
Unified
|
||
</button>
|
||
<button
|
||
type='button'
|
||
onClick={() => setMode('split')}
|
||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||
mode === 'split'
|
||
? 'bg-muted text-foreground'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
>
|
||
Split
|
||
</button>
|
||
</div>
|
||
{focusedPath ? (
|
||
<Button
|
||
type='button'
|
||
variant='ghost'
|
||
size='sm'
|
||
onClick={onClearFocusedPath}
|
||
>
|
||
Show all
|
||
</Button>
|
||
) : null}
|
||
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||
Refresh
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{visibleFiles.length > 0 ? (
|
||
<div className='flex flex-1 flex-col gap-3 overflow-y-auto p-3'>
|
||
{visibleFiles.map((file, index) => (
|
||
<FileCard
|
||
key={file.id}
|
||
file={file}
|
||
mode={mode}
|
||
theme={theme}
|
||
defaultOpen={visibleFiles.length <= 10 || index < 5}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
|
||
{focusedPath
|
||
? 'No diff is recorded for this file yet.'
|
||
: 'No workspace diff yet.'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|