/** * @file The diff renderer behind the Changes panel (issue #35): one file's * hunks as a VS Code-style diff — unified or side-by-side, syntax-highlighted, * with the changed span inside a modified line picked out. * * Three deliberate choices: * - Highlighting reuses `lib/highlight.ts`, the tokenizer the conversation * view already ships. No new dependency for a panel that renders a few * hundred lines. * - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is * right for the case that matters (one edited span on a line), degrades to * "the whole line changed" otherwise, and costs nothing. * - Side by side is JetBrains-shaped and laid out here, on the rows the * server already numbered: within a hunk each column lists only its own * lines, one after another, so the sides drift apart past a change and a * curved band in the middle gutter joins each block to its counterpart * (`lib/diffConnectors.ts`). Only a hunk's END is padded, so the `@@` * headers — and the two scrollers, kept in step 1:1 — stay level. Every * split row is ROW_PX tall, which makes a band's y pure arithmetic. The * server stays the single source of what changed; this file only decides * how it looks. * Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an * overscan) between two spacers. Rows never wrap — the box scrolls sideways — * so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic * (`visibleRange`); the content's min-width comes from the longest line, so * the horizontal extent does not jump as rows come and go. * A line's comment box floats just under the clicked line number (fixed, * re-placed as the diff scrolls), not under the whole diff. * @author Nguyễn Ngọc Trí Vĩ */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronUp } from "lucide-react"; import { canonicalLang, highlight, tokenClass } from "../../lib/highlight"; import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types"; import { bandPath } from "../../lib/diffConnectors"; export type DiffLayout = "unified" | "split"; /** * How tall a diff box may be. `fill` (the single-file view) takes the height * its parent gives it. In the review list, which stacks many files, a diff * grows to its full height so the PANEL is the one vertical scroller — a * 60vh box per file nested inside it made hunk navigation scroll two boxes * at once and land nowhere predictable. Only a windowed diff (above * WINDOW_THRESHOLD rows) keeps the cap, because windowing needs its own * scroller. */ function boxHeight(fill: boolean, windowed: boolean) { return fill ? "min-h-0 flex-1" : windowed ? "max-h-[60vh]" : ""; } /** * Bring `el` to the top of the scroller that actually scrolls it, below * `pad` px of sticky header. Not `scrollIntoView`: that moves EVERY scrolling * ancestor, the page included. */ function scrollToTop(el: HTMLElement, pad: number) { let s = el.parentElement; while ( s && !(s.scrollHeight > s.clientHeight && /auto|scroll/.test(getComputedStyle(s).overflowY)) ) s = s.parentElement; if (!s) return; s.scrollTop += el.getBoundingClientRect().top - s.getBoundingClientRect().top - pad; } /** Rows above which a view windows; at or below it renders exactly as before. */ export const WINDOW_THRESHOLD = 500; /** A windowed row's pinned height — the rows' own 11px × 1.45 line box, rounded. */ export const ROW_PX = 16; const OVERSCAN = 20; const FIXED: CSSProperties = { height: ROW_PX }; const MIN_ROW: CSSProperties = { minHeight: ROW_PX }; /** * The `[start, end)` item range to render for a scroll position. `scrollTop` * is clamped to the content first, so a stale offset (content that shrank, a * layout switch) still lands on real rows instead of an empty window. */ export function visibleRange( scrollTop: number, viewport: number, rowHeight: number, count: number, overscan: number ) { const top = Math.min(Math.max(0, scrollTop), Math.max(0, count * rowHeight - viewport)); const start = Math.max(0, Math.floor(top / rowHeight) - overscan); const end = Math.min(count, Math.ceil((top + viewport) / rowHeight) + overscan); return { start: Math.min(start, end), end }; } /** * Windowing state for one scroller of `count` items. Off (the whole range) * at or below the threshold. */ function useWindow(count: number) { const [top, setTop] = useState(0); const on = count > WINDOW_THRESHOLD; // The offset is tracked even while windowing is off, so a diff that GROWS // past the threshold (expanded context) windows where the reader is, not at // row 0. A ref, so an unwindowed diff does not re-render on every scroll. const lastTop = useRef(0); // count 0 = no scroller here (split layout): the browser drops its offset, // so coming back to unified must start from 0, not the pre-split row. if (count === 0) lastTop.current = 0; const [wasOn, setWasOn] = useState(on); if (wasOn !== on) { setWasOn(on); setTop(on ? lastTop.current : 0); } // ponytail: the viewport is taken as the window height — never smaller than // a box that lives inside the window, so it only over-renders a few dozen // rows, and it needs no ResizeObserver to follow a panel resize. const range = on ? visibleRange(top, window.innerHeight, ROW_PX, count, OVERSCAN) : { start: 0, end: count }; const onScroll = (el: HTMLElement) => { lastTop.current = el.scrollTop; if (on) setTop(el.scrollTop); }; return { on, range, onScroll }; } /** Columns a line occupies under `whitespace-pre` (a tab counted at its widest). */ function longest(texts: Iterable) { let max = 0; for (const t of texts) { let n = t.length; for (let i = t.indexOf("\t"); i !== -1; i = t.indexOf("\t", i + 1)) n += 7; if (n > max) max = n; } return max; } /** Stands in for `rows` off-screen rows. */ function Spacer({ rows }: { rows: number }) { return rows > 0 ?
: null; } /** A windowed list: spacer, the rendered slice, spacer, at a stable width. */ function Windowed({ count, range, minWidth, children, }: { count: number; range: { start: number; end: number }; minWidth: string; children: React.ReactNode; }) { return (
{children}
); } /** Language tag for a path, for the tokenizer. Unknown ones render plain. */ export function langFor(filePath: string) { const ext = filePath.split(".").pop() || ""; return canonicalLang(ext === filePath ? "plain" : ext); } /** Tokenized line text. `emph` marks the span that actually changed. */ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) { if (emph) { const [from, to] = emph; return ( <> ); } return ( <> {highlight(text, lang).map((tok, i) => ( {tok.text} ))} ); } /** * The changed span of a del/add pair, as `[start, end]` offsets into each * side, or null when the lines share nothing worth pointing at. */ function emphasis( del: string, add: string ): { del: [number, number]; add: [number, number] } | null { if (del === add) return null; let start = 0; const max = Math.min(del.length, add.length); while (start < max && del[start] === add[start]) start += 1; let end = 0; while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1; // No letter or digit in common at either edge (blanks, a `;`, a `}` do not // count): the whole line is the change — a removed line and an added one, // not an edit — and highlighting all of it adds noise on top of the row tint. if (!/\w/.test(del.slice(0, start) + del.slice(del.length - end))) return null; return { del: [start, del.length - end], add: [start, add.length - end], }; } /** A split-view row: the index in `hunk.lines` each column shows, -1 for the * padding that closes the shorter side of a hunk. */ type SplitRow = { left: number; right: number }; /** A hunk's split-view rows plus each paired line's emphasis, keyed by its * index in `hunk.lines` (what the unified view renders by). */ type Analysed = { rows: SplitRow[]; emph: Map }; // Keyed by the hunk object: a hunk is immutable once fetched, so both split // columns and every re-render of the same data share one pass. A refetch // brings new objects and the old entries go with them. const analysed = new WeakMap(); /** Pair each run of removed lines with the run of added lines following it * for emphasis, keyed by line index so it lands without searching the hunk * again; and pack each column's own lines into the split rows. */ export function analyseHunk(hunk: LaneDiffHunk): Analysed { const hit = analysed.get(hunk); if (hit) return hit; const lines = hunk.lines; const emph = new Map(); for (let i = 0; i < lines.length;) { if (lines[i]!.type === "ctx") { i += 1; continue; } const delStart = i; while (i < lines.length && lines[i]!.type === "del") i += 1; const addStart = i; while (i < lines.length && lines[i]!.type === "add") i += 1; const dels = addStart - delStart; const adds = i - addStart; for (let k = 0; k < Math.max(dels, adds); k += 1) { const left = k < dels ? lines[delStart + k]! : null; const right = k < adds ? lines[addStart + k]! : null; const e = left && right ? emphasis(left.text, right.text) : null; if (e) { emph.set(delStart + k, e.del); emph.set(addStart + k, e.add); } } } const left: number[] = []; const right: number[] = []; lines.forEach((l, i) => { if (l.type !== "add") left.push(i); if (l.type !== "del") right.push(i); }); const rows = Array.from({ length: Math.max(left.length, right.length) }, (_, i) => ({ left: left[i] ?? -1, right: right[i] ?? -1, })); const out = { rows, emph }; analysed.set(hunk, out); return out; } /** Set by a LaneDiffView given `onComment`: a click on a NEW line's number * opens the comment box for that line. */ const CommentLine = createContext<{ open: (line: number, anchor?: HTMLElement) => void; title: (line: number) => string; } | null>(null); function Gutter({ n, commentable = false }: { n: number | null; commentable?: boolean }) { const comment = useContext(CommentLine); if (commentable && comment && n != null) { return ( ); } return ( {n ?? ""} ); } /** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row. * `style` pins a windowed row's height; an unwindowed row is at least one * line tall, so a split view's empty filler row keeps its column in step. */ function Row({ tint, style, anchor, children, }: { tint: string; style?: CSSProperties; /** This row starts change block `anchor` (file-wide): the bar's arrows land here. */ anchor?: number; children: React.ReactNode; }) { return (
{children}
); } /** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still * separate from context at a glance on every theme (the /10 fill alone did not). * Green/red mark a line added or removed whole; blue (`mod`) a line edited in * place — a -/+ pair that keeps part of its text, its edit emphasised. */ function rowTint(type: LaneDiffLine["type"] | null, mod = false) { if (mod && type !== "ctx") return "bg-diff-mod/20 shadow-[inset_3px_0_0_rgb(var(--diff-mod))]"; if (type === "add") return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]"; if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]"; return ""; } function markerClass(type: LaneDiffLine["type"], mod = false) { if (mod && type !== "ctx") return "font-bold text-diff-mod"; if (type === "add") return "font-bold text-status-success"; if (type === "del") return "font-bold text-status-danger"; return "text-fg-muted"; } /** One change block: a run of consecutive +/- lines inside a hunk. git merges * edits a few lines apart into one `@@` hunk, so the review bar acts on these * instead (#50) — `block` is the index the server's `stage-hunk` / * `discard-hunk` take beside the hunk's header. */ export type LaneDiffChange = { hunk: LaneDiffHunk; block: number; /** First/last index in `hunk.lines` (unified). */ firstLine: number; lastLine: number; /** First split-view row (either side): the bar's arrows scroll here. */ firstRow: number; /** The right column's row its review bar hangs under. */ lastRow: number; /** Split-view rows `[from, to)` on each side; empty (`from === to`) is the * point between two rows where a pure insertion / removal sits. */ left: [number, number]; right: [number, number]; }; const blocksOf = new WeakMap(); /** A hunk's change blocks, cached per hunk object like `analyseHunk`. */ export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] { const hit = blocksOf.get(hunk); if (hit) return hit; const out: LaneDiffChange[] = []; hunk.lines.forEach((line, i) => { if (line.type === "ctx") return; let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined; if (!c) { c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1, left: [0, 0], right: [0, 0], }; out.push(c); } c.lastLine = i; }); // A side's rows are its lines in order, so a block's rows there are the // count of that side's lines before it, plus its own. for (const c of out) { let l = 0; let r = 0; hunk.lines.forEach((line, i) => { if (i >= c.firstLine) return; if (line.type !== "add") l += 1; if (line.type !== "del") r += 1; }); let dl = 0; let dr = 0; for (let i = c.firstLine; i <= c.lastLine; i += 1) { if (hunk.lines[i]!.type === "del") dl += 1; else dr += 1; } c.left = [l, l + dl]; c.right = [r, r + dr]; c.firstRow = Math.min(l, r); // A pure removal hangs its bar under the right row its band points at. c.lastRow = dr ? r + dr - 1 : Math.max(0, r - 1); } blocksOf.set(hunk, out); return out; } /** The block whose last line (unified) / last row (split) is `i`, if any. */ const barAtLine = (hunk: LaneDiffHunk, i: number) => changeBlocks(hunk).find((c) => c.lastLine === i); const barAtRow = (hunk: LaneDiffHunk, i: number) => changeBlocks(hunk).find((c) => c.lastRow === i); const startAtLine = (hunk: LaneDiffHunk, i: number) => changeBlocks(hunk).find((c) => c.firstLine === i); const startAtRow = (hunk: LaneDiffHunk, i: number) => changeBlocks(hunk).find((c) => c.firstRow === i); /** Per-block review controls (Undo / Keep), drawn in a bar under each change * block's last line. A context rather than a prop because lines render from * four places; `changes` is what the bar's "n of N" and its arrows walk. */ const HunkActions = createContext<{ /** null = no bar; the anchors still mark each block for navigation. */ actions: ((change: LaneDiffChange) => ReactNode) | null; changes: LaneDiffChange[]; go: (index: number) => void; } | null>(null); /** File-wide index of `change`, for a row's `anchor`. */ function useAnchor(change?: LaneDiffChange) { const ctx = useContext(HunkActions); return change && ctx ? ctx.changes.indexOf(change) : undefined; } /** The review bar: hangs just BELOW a change block's last line, pinned to the * visible right edge, so it sits on the change it acts on — the editor-style * "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */ function HunkBar({ change }: { change: LaneDiffChange }) { const { t } = useTranslation(["lanes"]); const ctx = useContext(HunkActions); if (!ctx?.actions) return null; const index = ctx.changes.indexOf(change); const total = ctx.changes.length; const nav = "rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30"; return ( {t("changes.hunkOf", { n: index + 1, total })} {ctx.actions(change)} ); } function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) { const at = hunk.header.split("@@")[1]; // Windowed, the header is one ROW_PX row like the lines around it. return (
{at ? `@@${at}@@` : hunk.header} {hunk.context ? ` ${hunk.context}` : ""}
); } function UnifiedLine({ line, lang, emph, style, bar, start, }: { line: LaneDiffLine; lang: string; emph?: [number, number]; style?: CSSProperties; /** This is a change block's last line: hang its review bar under it. */ bar?: LaneDiffChange; /** This is a change block's first line: the bar's arrows scroll here. */ start?: LaneDiffChange; }) { const anchor = useAnchor(start); return ( {line.type === "add" ? "+" : line.type === "del" ? "-" : " "} {bar && } ); } function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) { const { emph } = analyseHunk(hunk); return (
{hunk.lines.map((line, i) => ( ))}
); } /** One windowable item: a hunk's header (`i < 0`) or its i-th row — a line in * unified, a paired row in split. */ type Item = { hunk: LaneDiffHunk; i: number }; function flatten(hunks: LaneDiffHunk[], layout: DiffLayout): Item[] { const out: Item[] = []; for (const hunk of hunks) { out.push({ hunk, i: -1 }); const n = layout === "split" ? analyseHunk(hunk).rows.length : hunk.lines.length; for (let i = 0; i < n; i += 1) out.push({ hunk, i }); } return out; } function longestInHunks(hunks: LaneDiffHunk[]) { return longest( (function* () { for (const h of hunks) for (const l of h.lines) yield l.text; })() ); } /** One column's row i of a hunk: that side's i-th line, or the padding * that closes the hunk's shorter side. Always ROW_PX tall (see the header). */ function SplitLine({ hunk, i, lang, side, bar, start, }: { hunk: LaneDiffHunk; i: number; lang: string; side: "left" | "right"; bar?: LaneDiffChange; start?: LaneDiffChange; }) { const { rows, emph } = analyseHunk(hunk); const at = rows[i]![side]; const line = at < 0 ? null : hunk.lines[at]!; const e = at < 0 ? undefined : emph.get(at); const anchor = useAnchor(start); return ( {line ? : null} {bar && } ); } /** One side of the split view: a hunk header, then that side's rows. */ function SplitColumn({ hunks, lang, side, }: { hunks: LaneDiffHunk[]; lang: string; side: "left" | "right"; }) { return ( <> {hunks.map((hunk, h) => (
{analyseHunk(hunk).rows.map((_, i) => ( ))}
))} ); } /** A windowed split column: the shared range of items, one side of each. */ function SplitSlice({ items, range, lang, side, }: { items: Item[]; range: { start: number; end: number }; lang: string; side: "left" | "right"; }) { return ( <> {items .slice(range.start, range.end) .map(({ hunk, i }, k) => i < 0 ? ( ) : ( ) )} ); } /** `v` held inside a scroller's own extent. */ export function clampScroll(v: number, scrollSize: number, clientSize: number) { return Math.min(v, Math.max(0, scrollSize - clientSize)); } type Axis = ["scrollLeft", "scrollWidth", "clientWidth"] | ["scrollTop", "scrollHeight", "clientHeight"]; const AXES: Axis[] = [ ["scrollLeft", "scrollWidth", "clientWidth"], ["scrollTop", "scrollHeight", "clientHeight"], ]; /** Mirror `src`'s offset onto `dst`, clamped to what `dst` can show. */ function follow(src: HTMLElement, dst: HTMLElement) { for (const [pos, size, client] of AXES) { // `src` pinned at its own end while `dst` sits past it: `src` is the // narrower column echoing a clamp we just caused. Pulling `dst` back to // it would snap the wide column home every time it scrolls past the // narrow one's longest line. if (src[pos] >= clampScroll(Infinity, src[size], src[client]) && dst[pos] > src[pos]) continue; const target = clampScroll(src[pos], dst[size], dst[client]); if (dst[pos] !== target) dst[pos] = target; } } /** The middle gutter's width: room for an S-curve to read as one. */ const GUTTER_PX = 36; const BAND = { add: "fill-status-success/20 stroke-status-success/60", del: "fill-status-danger/20 stroke-status-danger/60", mod: "fill-diff-mod/20 stroke-diff-mod/60", } as const; /** Every change block's band, in the columns' content coordinates: a hunk's * header is item `h`, so its row r starts at (h + 1 + r) × ROW_PX. */ function bandsOf(items: Item[]) { const out: { d: string; kind: keyof typeof BAND; top: number; bottom: number }[] = []; items.forEach(({ hunk, i }, h) => { if (i >= 0) return; for (const c of changeBlocks(hunk)) { const [y0, y1, z0, z1] = [...c.left, ...c.right].map((r) => (h + 1 + r) * ROW_PX) as [ number, number, number, number, ]; const kind = y0 === y1 ? "add" : z0 === z1 ? "del" : "mod"; out.push({ d: bandPath(GUTTER_PX, y0, y1, z0, z1), kind, top: Math.min(y0, z0), bottom: Math.max(y1, z1), }); } }); return out; } /** Both sides, each its own scroller but kept in step on both axes, and the * gutter between them that draws each block's band, scrolled with them. */ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) { const left = useRef(null); const right = useRef(null); const mid = useRef(null); const items = useMemo(() => flatten(hunks, "split"), [hunks]); const bands = useMemo(() => bandsOf(items), [items]); const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]); const win = useWindow(items.length); // Mirroring fires the other pane's own scroll handler, which mirrors back — // the equality check (and `follow`'s edge rule) is what ends that, so no // re-entry flag is needed. Both axes: the columns hold paired rows, so a // vertical drift puts a deletion beside some unrelated line. Both columns // window off the same offset, so they always hold the same row range. const sync = (from: "left" | "right") => { const src = from === "left" ? left.current : right.current; const dst = from === "left" ? right.current : left.current; if (!src || !dst) return; follow(src, dst); if (mid.current) mid.current.scrollTop = src.scrollTop; win.onScroll(src); }; // Windowed, only the bands that reach the rendered rows are drawn. const [from, to] = [win.range.start * ROW_PX, win.range.end * ROW_PX]; const column = (side: "left" | "right") => win.on ? ( ) : ( ); return (
sync("left")} className="w-1/2 min-w-0 overflow-auto" > {column("left")}
{/* Taller than the columns by a scrollbar, so it can scroll as far. */} {bands .filter((b) => b.bottom >= from && b.top <= to) .map((b) => ( ))}
sync("right")} className="w-1/2 min-w-0 overflow-auto" > {column("right")}
); } /** One file's diff, in the chosen layout. */ export function LaneDiffView({ file, layout, fill = false, hunkActions, onComment, }: { file: LaneChangeFile; layout: DiffLayout; /** Take the parent's height (single-file view) instead of the 60vh cap. */ fill?: boolean; /** Controls drawn in each change block's review bar. */ hunkActions?: (change: LaneDiffChange) => ReactNode; /** Makes each new line's number a button that writes a comment on that line * and hands `: — ` here; false = it reached no pane. */ onComment?: (text: string) => boolean; }) { const root = useRef(null); const { t } = useTranslation(["lanes"]); const [commentAt, setCommentAt] = useState(null); const [draft, setDraft] = useState(""); const [undelivered, setUndelivered] = useState(false); // The clicked line number: the box floats just under it, where the eye is, // instead of under the whole (possibly 2000-line) diff. const anchorEl = useRef(null); const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null); const place = useCallback(() => { const r = anchorEl.current?.isConnected ? anchorEl.current.getBoundingClientRect() : null; // A long diff unmounts rows scrolled out of its window, the clicked number // with them: the box docks under the diff rather than float over other lines. setAnchor(r ? { top: r.bottom + 2, left: r.left } : null); }, []); useEffect(() => { if (commentAt == null) return; window.addEventListener("resize", place); return () => window.removeEventListener("resize", place); }, [commentAt, place]); const openComment = useMemo( () => onComment ? { open: (line: number, el?: HTMLElement) => { anchorEl.current = el ?? null; place(); setCommentAt(line); setUndelivered(false); }, title: (line: number) => t("changes.commentLine", { n: line }), } : null, [onComment, t, place] ); const sendComment = () => { const text = draft.trim(); if (!text || commentAt == null || !onComment) return; if (!onComment(`${file.path}:${commentAt} — ${text}`)) return setUndelivered(true); setCommentAt(null); setDraft(""); }; const lang = langFor(file.path); const hunks = file.hunks; const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]); const minWidth = useMemo( // two gutters + the row's px-2, plus the marker column () => `calc(${hunks ? longestInHunks(hunks) + 1 : 0}ch + 6rem)`, [hunks] ); // Unified only; split windows inside its own two scrollers. const win = useWindow(layout === "unified" ? items.length : 0); const splitWindowed = useMemo( () => layout === "split" && !!hunks && flatten(hunks, "split").length > WINDOW_THRESHOLD, [layout, hunks] ); const changes = useMemo(() => (hunks ? hunks.flatMap(changeBlocks) : []), [hunks]); // The bar's arrows: bring change block `index`'s first line into view. A // windowed unified diff may not have it in the DOM, so there it is found by // offset. const go = (index: number) => { const el = root.current; const target = changes[index]; if (!el || !target) return; const header = el.querySelector(`[data-change-index="${index}"]`); // The review list pins each file's header over its own diff. const sticky = el .closest("[data-review-file]") ?.querySelector("[data-sticky-top]"); const pad = sticky?.offsetHeight ?? 0; if (header) { scrollToTop(header, pad); return; } // Windowed: the header is not in the DOM, but its offset is known. const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine); if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX; }; // Provided without actions too: every block's first row carries its // `data-change-index`, which the panel's F7 / Shift+F7 walk in any range. const review = hunks ? { actions: hunkActions ?? null, changes, go } : null; if (file.binary) return

{t("changes.binary")}

; if (!file.hunks || file.hunks.length === 0) return

{t("changes.noHunks")}

; return (
{ win.onScroll(e.currentTarget); if (commentAt != null) place(); }} className={`flex flex-col font-mono text-[11px] leading-[1.45] ${ layout === "split" ? boxHeight(fill, splitWindowed) : `overflow-auto ${boxHeight(fill, win.on)}` }`} > {layout === "split" ? ( ) : win.on ? ( {items .slice(win.range.start, win.range.end) .map(({ hunk, i }, k) => i < 0 ? ( ) : ( ) )} ) : ( file.hunks.map((hunk, i) => ( )) )} {file.truncated && (

{t("changes.fileTruncated")}

)}
{commentAt != null && (
{ e.preventDefault(); sendComment(); }} >