a9c71cd905
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
1101 lines
38 KiB
TypeScript
1101 lines
38 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.
|
||
* - 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ĩ <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";
|
||
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<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. */
|
||
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 (
|
||
<>
|
||
<Code text={text.slice(0, from)} lang={lang} />
|
||
<span className="rounded-sm bg-diff-mod/40">
|
||
<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;
|
||
// 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<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
|
||
* 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<number, [number, number]>();
|
||
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 (
|
||
<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; 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 (
|
||
<div
|
||
className={`flex w-max min-w-full ${tint}`}
|
||
style={style ?? MIN_ROW}
|
||
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).
|
||
* 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<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[] = [];
|
||
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 (
|
||
<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, !!emph)} 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, !!emph)}`}>
|
||
{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;
|
||
})()
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<Row tint={rowTint(line?.type ?? null, !!e)} style={FIXED} 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={e} /> : 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} fixed />
|
||
{analyseHunk(hunk).rows.map((_, i) => (
|
||
<SplitLine
|
||
key={i}
|
||
hunk={hunk}
|
||
i={i}
|
||
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}
|
||
hunk={hunk}
|
||
i={i}
|
||
lang={lang}
|
||
side={side}
|
||
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;
|
||
}
|
||
}
|
||
|
||
/** 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<HTMLDivElement>(null);
|
||
const right = useRef<HTMLDivElement>(null);
|
||
const mid = useRef<HTMLDivElement>(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 ? (
|
||
<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={mid}
|
||
data-testid="lane-diff-connectors"
|
||
aria-hidden
|
||
className="shrink-0 overflow-hidden border-x border-border-light bg-surface-1"
|
||
style={{ width: GUTTER_PX }}
|
||
>
|
||
{/* Taller than the columns by a scrollbar, so it can scroll as far. */}
|
||
<svg width={GUTTER_PX} height={items.length * ROW_PX + 24} className="block">
|
||
{bands
|
||
.filter((b) => b.bottom >= from && b.top <= to)
|
||
.map((b) => (
|
||
<path
|
||
key={`${b.top}:${b.d}`}
|
||
d={b.d}
|
||
data-kind={b.kind}
|
||
className={BAND[b.kind]}
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
))}
|
||
</svg>
|
||
</div>
|
||
<div
|
||
ref={right}
|
||
data-testid="lane-diff-split-right"
|
||
onScroll={() => sync("right")}
|
||
className="w-1/2 min-w-0 overflow-auto"
|
||
>
|
||
{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;
|
||
};
|
||
// 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 <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,
|
||
}: {
|
||
path: string;
|
||
lines: string[];
|
||
truncated?: boolean;
|
||
/** Take the parent's height instead of the 60vh cap. */
|
||
fill?: boolean;
|
||
}) {
|
||
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}>
|
||
<Gutter n={i + 1} />
|
||
<span className="whitespace-pre px-2">
|
||
<Code text={text} lang={lang} />
|
||
</span>
|
||
</Row>
|
||
);
|
||
return (
|
||
<div
|
||
data-testid="lane-file-view"
|
||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||
>
|
||
{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;
|