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.
This commit is contained in:
206
apps/web/src/components/DiffSplitView.tsx
Normal file
206
apps/web/src/components/DiffSplitView.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user