198fbb7980
- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js, workflow-stats.js); idx_events_session restored, idx_events_tool added - compat-sqlite gains function/pluck/readonly so a plugin install without a better-sqlite3 build boots again; antigravity-usage uses db.js's Database - install-hooks adds no checkout hooks while the agenthub plugin is enabled (events were doubled) - terminal attach runs the live tmux server's own binary; the pane scrollbar gets a visible 2-column style, and claude panes start with CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll - Codex sessions' conversation in GET /api/sessions/:id/transcript - Codex/Antigravity sessions claimed by their workspace without hooks - UI-audit follow-ups, docs and OpenAPI
1014 lines
34 KiB
TypeScript
1014 lines
34 KiB
TypeScript
/**
|
||
* @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.
|
||
* - Line pairing for split view happens here, on the rows the server already
|
||
* numbered. 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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
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";
|
||
|
||
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 };
|
||
|
||
/**
|
||
* 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<string>) {
|
||
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 ? <div data-diff-spacer aria-hidden style={{ height: rows * ROW_PX }} /> : 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 (
|
||
<div data-diff-content style={{ minWidth }}>
|
||
<Spacer rows={range.start} />
|
||
{children}
|
||
<Spacer rows={count - range.end} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||
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 (
|
||
<>
|
||
<Code text={text.slice(0, from)} lang={lang} />
|
||
<span className="rounded-sm bg-fg-primary/15">
|
||
<Code text={text.slice(from, to)} lang={lang} />
|
||
</span>
|
||
<Code text={text.slice(to)} lang={lang} />
|
||
</>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
{highlight(text, lang).map((tok, i) => (
|
||
<span key={i} className={tokenClass(tok.type)}>
|
||
{tok.text}
|
||
</span>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
// Nothing in common at either edge: the whole line is the change, and
|
||
// highlighting all of it adds noise on top of the row tint.
|
||
if (start === 0 && end === 0) return null;
|
||
return {
|
||
del: [start, del.length - end],
|
||
add: [start, add.length - end],
|
||
};
|
||
}
|
||
|
||
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
|
||
type EmphPair = { del: [number, number]; add: [number, 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: PairRow[]; emph: Map<number, [number, number]> };
|
||
|
||
// 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<LaneDiffHunk, Analysed>();
|
||
|
||
/** Pair each run of removed lines with the run of added lines following it,
|
||
* carrying indices so emphasis lands without searching the hunk again. */
|
||
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||
const hit = analysed.get(hunk);
|
||
if (hit) return hit;
|
||
const lines = hunk.lines;
|
||
const rows: PairRow[] = [];
|
||
const emph = new Map<number, [number, number]>();
|
||
for (let i = 0; i < lines.length; ) {
|
||
const line = lines[i]!;
|
||
if (line.type === "ctx") {
|
||
rows.push({ left: line, right: line, emph: null });
|
||
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);
|
||
}
|
||
rows.push({ left, right, emph: e });
|
||
}
|
||
}
|
||
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 (
|
||
<button
|
||
type="button"
|
||
data-testid={`diff-comment-line-${n}`}
|
||
onClick={(e) => comment.open(n, e.currentTarget)}
|
||
title={comment.title(n)}
|
||
className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60 hover:bg-accent/20 hover:text-accent"
|
||
>
|
||
{n}
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
|
||
);
|
||
}
|
||
|
||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
|
||
* `style` pins a windowed row's height; unwindowed rows carry none. */
|
||
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 (
|
||
<div
|
||
className={`flex w-max min-w-full ${tint}`}
|
||
style={style}
|
||
data-diff-item={style ? "" : undefined}
|
||
data-change-index={anchor}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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). */
|
||
function rowTint(type: LaneDiffLine["type"] | null) {
|
||
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"]) {
|
||
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/last split-view row. */
|
||
firstRow: number;
|
||
lastRow: number;
|
||
};
|
||
|
||
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
|
||
|
||
/** 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[] = [];
|
||
const owner = new Map<LaneDiffLine, 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 };
|
||
out.push(c);
|
||
}
|
||
c.lastLine = i;
|
||
owner.set(line, c);
|
||
});
|
||
analyseHunk(hunk).rows.forEach((r, i) => {
|
||
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
|
||
if (!c) return;
|
||
if (c.firstRow < 0) c.firstRow = i;
|
||
c.lastRow = i;
|
||
});
|
||
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<{
|
||
actions: (change: LaneDiffChange) => ReactNode;
|
||
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) 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 (
|
||
<span className="sticky right-2 z-10 ml-auto h-0 w-0 self-start">
|
||
<span
|
||
data-testid="lane-hunk-bar"
|
||
className="absolute right-0 top-[18px] flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-label={t("changes.prevHunk")}
|
||
title={t("changes.prevHunk")}
|
||
disabled={index <= 0}
|
||
onClick={() => ctx.go(index - 1)}
|
||
className={nav}
|
||
>
|
||
<ChevronUp className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label={t("changes.nextHunk")}
|
||
title={t("changes.nextHunk")}
|
||
disabled={index >= total - 1}
|
||
onClick={() => ctx.go(index + 1)}
|
||
className={nav}
|
||
>
|
||
<ChevronDown className="h-3 w-3" />
|
||
</button>
|
||
<span className="px-0.5 tabular-nums text-fg-muted">
|
||
{t("changes.hunkOf", { n: index + 1, total })}
|
||
</span>
|
||
{ctx.actions(change)}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className={`flex items-center gap-2 bg-surface-2 px-2 text-[10px] text-fg-muted ${
|
||
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
|
||
}`}
|
||
style={fixed ? FIXED : undefined}
|
||
data-diff-item={fixed ? "" : undefined}
|
||
>
|
||
<span className="min-w-0 flex-1 truncate">
|
||
{at ? `@@${at}@@` : hunk.header}
|
||
{hunk.context ? ` ${hunk.context}` : ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
||
<Gutter n={line.oldLine} />
|
||
<Gutter n={line.newLine} commentable />
|
||
<span className="whitespace-pre px-2">
|
||
<span className={`select-none ${markerClass(line.type)}`}>
|
||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||
</span>
|
||
<Code text={line.text} lang={lang} emph={emph} />
|
||
</span>
|
||
{bar && <HunkBar change={bar} />}
|
||
</Row>
|
||
);
|
||
}
|
||
|
||
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
|
||
const { emph } = analyseHunk(hunk);
|
||
return (
|
||
<div>
|
||
<HunkHeader hunk={hunk} />
|
||
{hunk.lines.map((line, i) => (
|
||
<UnifiedLine
|
||
key={i}
|
||
line={line}
|
||
lang={lang}
|
||
emph={emph.get(i)}
|
||
bar={barAtLine(hunk, i)}
|
||
start={startAtLine(hunk, i)}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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;
|
||
})()
|
||
);
|
||
}
|
||
|
||
function SplitLine({
|
||
row,
|
||
lang,
|
||
side,
|
||
style,
|
||
bar,
|
||
start,
|
||
}: {
|
||
row: PairRow;
|
||
lang: string;
|
||
side: "left" | "right";
|
||
style?: CSSProperties;
|
||
bar?: LaneDiffChange;
|
||
start?: LaneDiffChange;
|
||
}) {
|
||
const line = side === "left" ? row.left : row.right;
|
||
const anchor = useAnchor(start);
|
||
return (
|
||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
||
<Gutter
|
||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||
commentable={side === "right"}
|
||
/>
|
||
<span className="whitespace-pre px-1">
|
||
{line ? (
|
||
<Code
|
||
text={line.text}
|
||
lang={lang}
|
||
emph={side === "left" ? row.emph?.del : row.emph?.add}
|
||
/>
|
||
) : null}
|
||
</span>
|
||
{bar && <HunkBar change={bar} />}
|
||
</Row>
|
||
);
|
||
}
|
||
|
||
/** 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) => (
|
||
<div key={`${hunk.header}:${h}`}>
|
||
<HunkHeader hunk={hunk} />
|
||
{analyseHunk(hunk).rows.map((row, i) => (
|
||
<SplitLine
|
||
key={i}
|
||
row={row}
|
||
lang={lang}
|
||
side={side}
|
||
// One bar per change block: on the right (new) column only.
|
||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||
start={startAtRow(hunk, i)}
|
||
/>
|
||
))}
|
||
</div>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** 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 ? (
|
||
<HunkHeader key={range.start + k} hunk={hunk} fixed />
|
||
) : (
|
||
<SplitLine
|
||
key={range.start + k}
|
||
row={analyseHunk(hunk).rows[i]!}
|
||
lang={lang}
|
||
side={side}
|
||
style={FIXED}
|
||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||
start={startAtRow(hunk, i)}
|
||
/>
|
||
)
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** `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;
|
||
}
|
||
}
|
||
|
||
/** Both sides, each its own scroller but kept in step on both axes. */
|
||
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
||
const left = useRef<HTMLDivElement>(null);
|
||
const right = useRef<HTMLDivElement>(null);
|
||
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
||
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);
|
||
win.onScroll(src);
|
||
};
|
||
|
||
const column = (side: "left" | "right") =>
|
||
win.on ? (
|
||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||
<SplitSlice items={items} range={win.range} lang={lang} side={side} />
|
||
</Windowed>
|
||
) : (
|
||
<SplitColumn hunks={hunks} lang={lang} side={side} />
|
||
);
|
||
|
||
return (
|
||
<div className={`flex ${boxHeight(fill, win.on)}`}>
|
||
<div
|
||
ref={left}
|
||
data-testid="lane-diff-split-left"
|
||
onScroll={() => sync("left")}
|
||
className="w-1/2 min-w-0 overflow-auto"
|
||
>
|
||
{column("left")}
|
||
</div>
|
||
<div
|
||
ref={right}
|
||
data-testid="lane-diff-split-right"
|
||
onScroll={() => sync("right")}
|
||
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
|
||
>
|
||
{column("right")}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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 `<path>:<line> — <text>` here; false = it reached no pane. */
|
||
onComment?: (text: string) => boolean;
|
||
}) {
|
||
const root = useRef<HTMLDivElement>(null);
|
||
const { t } = useTranslation(["lanes"]);
|
||
const [commentAt, setCommentAt] = useState<number | null>(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<HTMLElement | null>(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<HTMLElement>(`[data-change-index="${index}"]`);
|
||
// The review list pins each file's header over its own diff.
|
||
const sticky = el
|
||
.closest("[data-review-file]")
|
||
?.querySelector<HTMLElement>("[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;
|
||
};
|
||
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
|
||
|
||
if (file.binary)
|
||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||
if (!file.hunks || file.hunks.length === 0)
|
||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
||
|
||
return (
|
||
<CommentLine.Provider value={openComment}>
|
||
<HunkActions.Provider value={review}>
|
||
<div
|
||
ref={root}
|
||
data-testid="lane-diff-view"
|
||
data-layout={layout}
|
||
onScroll={(e) => {
|
||
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" ? (
|
||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||
) : win.on ? (
|
||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||
{items
|
||
.slice(win.range.start, win.range.end)
|
||
.map(({ hunk, i }, k) =>
|
||
i < 0 ? (
|
||
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
||
) : (
|
||
<UnifiedLine
|
||
key={win.range.start + k}
|
||
line={hunk.lines[i]!}
|
||
lang={lang}
|
||
emph={analyseHunk(hunk).emph.get(i)}
|
||
style={FIXED}
|
||
bar={barAtLine(hunk, i)}
|
||
start={startAtLine(hunk, i)}
|
||
/>
|
||
)
|
||
)}
|
||
</Windowed>
|
||
) : (
|
||
file.hunks.map((hunk, i) => (
|
||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||
))
|
||
)}
|
||
{file.truncated && (
|
||
<p className="px-2 py-1 text-[10px] text-status-warning">
|
||
{t("changes.fileTruncated")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{commentAt != null && (
|
||
<form
|
||
data-testid="diff-comment-box"
|
||
style={
|
||
anchor
|
||
? {
|
||
position: "fixed",
|
||
top: Math.min(anchor.top, window.innerHeight - 180),
|
||
left: anchor.left,
|
||
width: "min(28rem, calc(100vw - 2rem))",
|
||
}
|
||
: undefined
|
||
}
|
||
className={`z-30 flex flex-col gap-1 border-border bg-surface-2 p-2 ${
|
||
anchor ? "rounded-lg border shadow-lg" : "border-t"
|
||
}`}
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
sendComment();
|
||
}}
|
||
>
|
||
<label className="text-[11px] text-fg-muted">
|
||
{t("changes.commentOn", { at: `${file.path}:${commentAt}` })}
|
||
<textarea
|
||
autoFocus
|
||
rows={2}
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") setCommentAt(null);
|
||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) sendComment();
|
||
}}
|
||
className="mt-1 block w-full rounded border border-border bg-surface-1 px-2 py-1 font-sans text-xs text-fg-primary"
|
||
/>
|
||
</label>
|
||
{undelivered && (
|
||
<p role="alert" className="text-[11px] text-status-danger">
|
||
{t("changes.commentNoPane")}
|
||
</p>
|
||
)}
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
className="btn-ghost text-xs"
|
||
onClick={() => setCommentAt(null)}
|
||
>
|
||
{t("changes.commentCancel")}
|
||
</button>
|
||
<button type="submit" className="btn-primary text-xs" disabled={!draft.trim()}>
|
||
{t("changes.commentSend")}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</HunkActions.Provider>
|
||
</CommentLine.Provider>
|
||
);
|
||
}
|
||
|
||
/** A whole file's text (no diff), for the viewer's "read the file" mode. */
|
||
export function LaneFileView({
|
||
path,
|
||
lines,
|
||
truncated,
|
||
fill = false,
|
||
onEditAt,
|
||
}: {
|
||
path: string;
|
||
lines: string[];
|
||
truncated?: boolean;
|
||
/** Take the parent's height instead of the 60vh cap. */
|
||
fill?: boolean;
|
||
/** Set when the file is editable: a click on the text (0-based line, and
|
||
* where the view was scrolled) asks to open the editor there. */
|
||
onEditAt?: (line: number, scrollTop: number) => void;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
const lang = langFor(path);
|
||
const win = useWindow(lines.length);
|
||
// gutter + the row's px-2
|
||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||
const row = (text: string, i: number, style?: CSSProperties) => (
|
||
<Row key={i} tint="" style={style}>
|
||
{/* `contents`: a line marker for the click handler, not a layout box. */}
|
||
<span data-ln={i} className="contents">
|
||
<Gutter n={i + 1} />
|
||
<span className="whitespace-pre px-2">
|
||
<Code text={text} lang={lang} />
|
||
</span>
|
||
</span>
|
||
</Row>
|
||
);
|
||
return (
|
||
<div
|
||
data-testid="lane-file-view"
|
||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||
tabIndex={onEditAt ? 0 : undefined}
|
||
onClick={
|
||
onEditAt &&
|
||
((e) => {
|
||
// A click that ends a drag-selection is copying, not asking to edit.
|
||
if (window.getSelection()?.toString()) return;
|
||
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
|
||
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
|
||
})
|
||
}
|
||
onKeyDown={
|
||
onEditAt &&
|
||
((e) => {
|
||
if (e.key !== "Enter") return;
|
||
e.preventDefault();
|
||
onEditAt(0, e.currentTarget.scrollTop);
|
||
})
|
||
}
|
||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
|
||
onEditAt ? " cursor-text" : ""
|
||
}`}
|
||
>
|
||
{win.on ? (
|
||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||
{lines
|
||
.slice(win.range.start, win.range.end)
|
||
.map((text, k) => row(text, win.range.start + k, FIXED))}
|
||
</Windowed>
|
||
) : (
|
||
lines.map((text, i) => row(text, i))
|
||
)}
|
||
{truncated && (
|
||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default LaneDiffView;
|