Files
spoon/apps/next/src/components/agent-workspace/diff-viewer.tsx
T
Gabriel Brown bb471a0917 Improve workspace/chat diff viewer with syntax highlighting & per-file view
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)
2026-06-24 07:08:43 -04:00

197 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
};