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.
206 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
} |