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)
This commit is contained in:
Gabriel Brown
2026-06-24 07:08:43 -04:00
parent 40a6dd78e4
commit bb471a0917
6 changed files with 420 additions and 91 deletions
@@ -1,25 +1,90 @@
'use client';
import dynamic from 'next/dynamic';
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Button } from '@spoon/ui';
import { extractFileDiff } from './diff-utils';
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 MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
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 diffStats = (diff: string) => {
const files = new Set<string>();
let additions = 0;
let removals = 0;
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) files.add(line);
if (line.startsWith('+') && !line.startsWith('+++')) additions += 1;
if (line.startsWith('-') && !line.startsWith('---')) removals += 1;
}
return { files: files.size, additions, removals };
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 = ({
@@ -33,25 +98,65 @@ export const DiffViewer = ({
onRefresh: () => Promise<void>;
onClearFocusedPath?: () => void;
}) => {
const focusedDiff = focusedPath ? extractFileDiff(diff, focusedPath) : '';
const visibleDiff = focusedPath ? focusedDiff : diff;
const stats = diffStats(visibleDiff);
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 viewer: ${focusedPath}` : 'Diff viewer'}
{focusedPath ? `Diff: ${focusedPath}` : 'Diff viewer'}
</p>
<p className='text-muted-foreground truncate text-xs'>
{visibleDiff.trim()
? `${stats.files} files, +${stats.additions} -${stats.removals}`
: focusedPath
? 'No diff for this file'
: 'Current git diff'}
{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'
@@ -67,22 +172,18 @@ export const DiffViewer = ({
</Button>
</div>
</div>
{visibleDiff.trim() ? (
<MonacoEditor
height='100%'
width='100%'
language='diff'
theme='vs-dark'
value={visibleDiff}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
}}
/>
{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