Files
boocode/apps/web/src/components/DiffSplitView.tsx
indifferentketchup 31d8efe66a feat(web): enhanced file panel — side-by-side diff, hide whitespace, inline review
Adds DiffSplitView component for side-by-side diff mode, whitespace-only
change filtering, inline review comments with thread/gutter cell UI, diff
preferences persistence, and write-file API support for in-browser editing.

Backend: hideWhitespace param on git diff endpoint, write_file route.
2026-06-07 22:16:20 +00:00

206 lines
8.1 KiB
TypeScript

import { useMemo, useRef, useEffect, useState } from 'react';
import { codeToHtml } from 'shiki';
import type { GitDiffFile } from '@/api/types';
import { parseDiff, buildSplitRows, reconstructNewContent, type SplitRow } from '@/utils/diff-layout';
import { inferLanguage } from '@/lib/attachments';
import { cn } from '@/lib/utils';
interface DiffSplitViewProps {
file: GitDiffFile;
wrapLines?: boolean;
}
/** Side-by-side split diff renderer. Left = deletions, right = additions. */
export function DiffSplitView({ file, wrapLines = false }: DiffSplitViewProps) {
// ── Edge cases (rendered before hooks) ──────────────────────────────────
if (file.is_binary) {
return <p className="text-xs text-muted-foreground italic px-2 py-1">Binary file</p>;
}
if (file.is_too_large) {
return <p className="text-xs text-muted-foreground italic px-2 py-1">Diff too large to display</p>;
}
if (file.change_type === 'untracked' && !file.diff_body) {
return <p className="text-xs text-muted-foreground italic px-2 py-1">Untracked file</p>;
}
if (!file.diff_body) {
return <p className="text-xs text-muted-foreground italic px-2 py-1">No diff content</p>;
}
return <DiffSplitViewInner file={file} wrapLines={wrapLines} />;
}
/**
* Inner component — assumes file.diff_body is non-null.
* Separated so the early-return edge cases above don't violate rules of hooks.
*/
function DiffSplitViewInner({ file, wrapLines }: { file: GitDiffFile; wrapLines: boolean }) {
// ── Parse diff ───────────────────────────────────────────────────────────
const parsed = useMemo(() => parseDiff(file.diff_body!), [file.diff_body]);
const parsedFile = parsed[0];
const rows = useMemo(() => {
if (!parsedFile) return [] as SplitRow[];
return buildSplitRows(parsedFile);
}, [parsedFile]);
const newContent = useMemo(() => {
if (!parsedFile) return '';
return reconstructNewContent(parsedFile.hunks);
}, [parsedFile]);
// ── Syntax highlighting ──────────────────────────────────────────────────
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(null);
const [highlighting, setHighlighting] = useState(false);
const highlightKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!newContent) return;
if (highlightKeyRef.current === newContent) return;
highlightKeyRef.current = newContent;
let cancelled = false;
setHighlighting(true);
setHighlightedLines(null);
const lang = inferLanguage(file.path) ?? 'plaintext';
void codeToHtml(newContent, { lang, theme: 'github-dark' })
.then((html) => {
if (cancelled) return;
const container = document.createElement('div');
// eslint-disable-next-line no-unsanitized/property
container.innerHTML = html;
const codeEl = container.querySelector('code');
if (codeEl) {
const lineSpans = codeEl.querySelectorAll('.line');
setHighlightedLines(Array.from(lineSpans, (span) => span.innerHTML));
} else {
setHighlightedLines(null);
}
})
.catch(() => {
if (!cancelled) setHighlightedLines(null);
})
.finally(() => {
if (!cancelled) setHighlighting(false);
});
return () => { cancelled = true; };
}, [newContent, file.path]);
// ── Build new-line-number → highlighted-HTML map ───────────────────────
// Walk the hunks counting only add/context lines (which form the new file)
// and map each 1-based new-line-number to its highlighted HTML string.
const newLineHtmlMap = useMemo(() => {
if (!highlightedLines || !parsedFile) return new Map<number, string>();
const map = new Map<number, string>();
let idx = 0;
for (const hunk of parsedFile.hunks) {
let newLineNo = hunk.newStart;
for (const line of hunk.lines) {
if (line.type === 'header') continue;
if (line.type === 'add' || line.type === 'context') {
if (idx < highlightedLines.length) {
map.set(newLineNo, highlightedLines[idx]!);
}
idx++;
newLineNo++;
}
}
}
return map;
}, [highlightedLines, parsedFile]);
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className={cn('text-[11px] font-mono overflow-x-auto', wrapLines && 'break-all')}>
{highlighting && (
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting</p>
)}
<table className="w-full border-collapse">
<colgroup>
<col className="w-[40px]" />
<col />
<col className="w-px" />
<col className="w-[40px]" />
<col />
</colgroup>
<tbody>
{rows.map((row, idx) => {
if (row.kind === 'header') {
return (
<tr key={`h-${idx}`} className="bg-muted/30">
<td
colSpan={5}
className="text-muted-foreground text-[11px] px-2 py-0.5 select-none"
>
{row.content}
</td>
</tr>
);
}
const left = row.left;
const right = row.right;
const leftBg = left?.type === 'remove' ? 'bg-red-950/30' : '';
const rightBg = right?.type === 'add' ? 'bg-green-950/30' : '';
const leftHtml = left?.lineNumber != null ? newLineHtmlMap.get(left.lineNumber) : undefined;
const rightHtml = right?.lineNumber != null ? newLineHtmlMap.get(right.lineNumber) : undefined;
return (
<tr key={`p-${idx}`} className="hover:bg-muted/10">
<td className={cn(leftBg, 'border-r border-border/20 align-top')}>
<span className="text-muted-foreground text-right pr-1 select-none text-[11px] block">
{left?.lineNumber ?? ''}
</span>
</td>
<td className={cn(leftBg, 'align-top')}>
<div
className={cn(
'pl-2 text-[11px]',
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
)}
>
{left ? (
leftHtml ? (
// eslint-disable-next-line no-unsanitized/property
<span dangerouslySetInnerHTML={{ __html: leftHtml }} />
) : (
<span>{left.content}</span>
)
) : null}
</div>
</td>
<td className="border-l border-border/30 w-px p-0" />
<td className={cn(rightBg, 'border-r border-border/20 align-top')}>
<span className="text-muted-foreground text-right pr-1 select-none text-[11px] block">
{right?.lineNumber ?? ''}
</span>
</td>
<td className={cn(rightBg, 'align-top')}>
<div
className={cn(
'pl-2 text-[11px]',
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
)}
>
{right ? (
rightHtml ? (
// eslint-disable-next-line no-unsanitized/property
<span dangerouslySetInnerHTML={{ __html: rightHtml }} />
) : (
<span>{right.content}</span>
)
) : null}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}