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:
@@ -14,7 +14,8 @@ import { toast } from 'sonner';
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge, Button, Textarea } from '@spoon/ui';
|
||||
|
||||
import { extractFileDiff } from './diff-utils';
|
||||
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
||||
import { parseDiffFileForPath } from './diff-utils';
|
||||
|
||||
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
|
||||
|
||||
@@ -67,6 +68,7 @@ export const AgentThread = ({
|
||||
const [sending, setSending] = useState(false);
|
||||
const [replying, setReplying] = useState<string>();
|
||||
const [filter, setFilter] = useState<ActivityFilter>('all');
|
||||
const diffTheme = useDiffTheme();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const chatMessages = useMemo(
|
||||
() =>
|
||||
@@ -317,58 +319,80 @@ export const AgentThread = ({
|
||||
</pre>
|
||||
</article>
|
||||
))}
|
||||
{visibleChanges.map((change) => (
|
||||
<article
|
||||
key={change._id}
|
||||
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FilePenLine className='text-primary size-4 flex-none' />
|
||||
<span className='truncate font-mono text-xs'>
|
||||
{change.path}
|
||||
</span>
|
||||
{visibleChanges.map((change) => {
|
||||
const changedFile = parseDiffFileForPath(change.diff, change.path);
|
||||
const hasDiff = Boolean(changedFile && !changedFile.isBinary);
|
||||
const hasRenderableHunk = Boolean(
|
||||
changedFile && hasDiff && changedFile.hunkText.includes('@@'),
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={change._id}
|
||||
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FilePenLine className='text-primary size-4 flex-none' />
|
||||
<span className='truncate font-mono text-xs'>
|
||||
{change.path}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs capitalize'>
|
||||
{change.source} {change.changeType}
|
||||
{changedFile ? (
|
||||
<span className='ml-2 font-mono normal-case'>
|
||||
<span className='text-emerald-500'>
|
||||
+{changedFile.additions}
|
||||
</span>{' '}
|
||||
<span className='text-red-500'>
|
||||
−{changedFile.deletions}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-none items-center gap-2'>
|
||||
{hasDiff ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onOpenDiff(change.path)}
|
||||
>
|
||||
View diff
|
||||
</Button>
|
||||
) : null}
|
||||
{change.path !== '.' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onOpenFile(change.path)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs capitalize'>
|
||||
{change.source} {change.changeType}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-none items-center gap-2'>
|
||||
{extractFileDiff(change.diff, change.path) ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onOpenDiff(change.path)}
|
||||
>
|
||||
View diff
|
||||
</Button>
|
||||
) : null}
|
||||
{change.path !== '.' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onOpenFile(change.path)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{extractFileDiff(change.diff, change.path) ? (
|
||||
<details className='mt-3'>
|
||||
<summary className='text-muted-foreground cursor-pointer text-xs'>
|
||||
File diff
|
||||
</summary>
|
||||
<pre className='bg-muted mt-2 max-h-72 overflow-auto rounded p-2 text-xs whitespace-pre-wrap'>
|
||||
{extractFileDiff(change.diff, change.path)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
{hasRenderableHunk && changedFile ? (
|
||||
<details className='mt-3'>
|
||||
<summary className='text-muted-foreground cursor-pointer text-xs'>
|
||||
File diff
|
||||
</summary>
|
||||
<div className='border-border mt-2 max-h-72 overflow-auto rounded border'>
|
||||
<DiffFileView
|
||||
file={changedFile}
|
||||
mode='unified'
|
||||
theme={diffTheme}
|
||||
fontSize={11}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{visibleEvents.slice(-80).map((event) => (
|
||||
<article
|
||||
key={event._id}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import '@git-diff-view/react/styles/diff-view.css';
|
||||
|
||||
import type { ParsedDiffFile } from './diff-utils';
|
||||
|
||||
export type DiffMode = 'unified' | 'split';
|
||||
|
||||
/** Resolves the git-diff-view theme from next-themes, defaulting to dark. */
|
||||
export const useDiffTheme = (): 'light' | 'dark' => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
return resolvedTheme === 'light' ? 'light' : 'dark';
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders one file's diff with syntax highlighting. Falls back to a short note
|
||||
* for binary files and metadata-only changes (pure renames / mode changes) that
|
||||
* have no hunks to display.
|
||||
*/
|
||||
export const DiffFileView = ({
|
||||
file,
|
||||
mode,
|
||||
theme,
|
||||
fontSize = 12,
|
||||
wrap = false,
|
||||
}: {
|
||||
file: ParsedDiffFile;
|
||||
mode: DiffMode;
|
||||
theme: 'light' | 'dark';
|
||||
fontSize?: number;
|
||||
wrap?: boolean;
|
||||
}) => {
|
||||
if (file.isBinary) {
|
||||
return (
|
||||
<div className='text-muted-foreground px-3 py-2 text-xs'>
|
||||
Binary file not shown.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!file.hunkText.includes('@@')) {
|
||||
return (
|
||||
<div className='text-muted-foreground px-3 py-2 text-xs'>
|
||||
{file.status === 'renamed'
|
||||
? 'Renamed with no content changes.'
|
||||
: 'No content changes.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DiffView
|
||||
data={{
|
||||
oldFile: { fileName: file.oldPath || file.displayPath },
|
||||
newFile: { fileName: file.newPath || file.displayPath },
|
||||
hunks: [file.hunkText],
|
||||
}}
|
||||
diffViewMode={
|
||||
mode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified
|
||||
}
|
||||
diffViewTheme={theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={wrap}
|
||||
diffViewFontSize={fontSize}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,112 @@
|
||||
export type DiffFileStatus = 'added' | 'deleted' | 'modified' | 'renamed';
|
||||
|
||||
export type ParsedDiffFile = {
|
||||
id: string;
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
/** Path to show in the UI (new path, or old path for deletions). */
|
||||
displayPath: string;
|
||||
status: DiffFileStatus;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
isBinary: boolean;
|
||||
/** The full per-file unified diff section, fed as-is to the diff renderer. */
|
||||
hunkText: string;
|
||||
};
|
||||
|
||||
const stripABPrefix = (value: string) => value.replace(/^[ab]\//, '');
|
||||
|
||||
/**
|
||||
* Splits a raw unified git diff into structured, per-file entries. Replaces the
|
||||
* "one giant blob" rendering: each file can be shown, counted, and highlighted
|
||||
* independently.
|
||||
*/
|
||||
export const parseDiffFiles = (diff: string | undefined): ParsedDiffFile[] => {
|
||||
if (!diff?.trim()) return [];
|
||||
const sections: string[][] = [];
|
||||
let current: string[] | null = null;
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
if (current) sections.push(current);
|
||||
current = [line];
|
||||
continue;
|
||||
}
|
||||
current?.push(line);
|
||||
}
|
||||
if (current) sections.push(current);
|
||||
|
||||
return sections.map((sectionLines, index) => {
|
||||
const header = sectionLines[0] ?? '';
|
||||
const gitMatch = /^diff --git a\/(.+?) b\/(.+)$/.exec(header);
|
||||
let oldPath = gitMatch?.[1] ?? '';
|
||||
let newPath = gitMatch?.[2] ?? oldPath;
|
||||
let status: DiffFileStatus = 'modified';
|
||||
let isBinary = false;
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let renameFrom = '';
|
||||
let renameTo = '';
|
||||
|
||||
for (const line of sectionLines) {
|
||||
if (line.startsWith('new file mode')) status = 'added';
|
||||
else if (line.startsWith('deleted file mode')) status = 'deleted';
|
||||
else if (line.startsWith('rename from ')) {
|
||||
renameFrom = line.slice('rename from '.length);
|
||||
} else if (line.startsWith('rename to ')) {
|
||||
renameTo = line.slice('rename to '.length);
|
||||
} else if (
|
||||
line.startsWith('Binary files') ||
|
||||
line.startsWith('GIT binary patch')
|
||||
) {
|
||||
isBinary = true;
|
||||
} else if (line.startsWith('--- ')) {
|
||||
const value = line.slice(4).trim();
|
||||
if (value !== '/dev/null') oldPath = stripABPrefix(value);
|
||||
} else if (line.startsWith('+++ ')) {
|
||||
const value = line.slice(4).trim();
|
||||
if (value !== '/dev/null') newPath = stripABPrefix(value);
|
||||
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
additions += 1;
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
deletions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (renameFrom || renameTo) {
|
||||
status = 'renamed';
|
||||
oldPath = renameFrom || oldPath;
|
||||
newPath = renameTo || newPath;
|
||||
}
|
||||
|
||||
const displayPath = status === 'deleted' ? oldPath : newPath;
|
||||
return {
|
||||
id: `${index}-${displayPath}`,
|
||||
oldPath,
|
||||
newPath,
|
||||
displayPath,
|
||||
status,
|
||||
additions,
|
||||
deletions,
|
||||
isBinary,
|
||||
hunkText: sectionLines.join('\n'),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** Returns the single parsed file matching a path, if present in the diff. */
|
||||
export const parseDiffFileForPath = (
|
||||
diff: string | undefined,
|
||||
filePath: string,
|
||||
): ParsedDiffFile | undefined => {
|
||||
const normalized = filePath.replace(/^\.\/+/, '');
|
||||
return parseDiffFiles(diff).find(
|
||||
(file) =>
|
||||
file.displayPath === normalized ||
|
||||
file.newPath === normalized ||
|
||||
file.oldPath === normalized,
|
||||
);
|
||||
};
|
||||
|
||||
export const extractFileDiff = (diff: string | undefined, filePath: string) => {
|
||||
if (!diff?.trim() || filePath === '.') return '';
|
||||
const lines = diff.split('\n');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user