Skip to content

Commit c2a8c82

Browse files
ymansurozerclaude
andauthored
perf: cache cursor row measurements — no more per-keypress layout sweep (#58)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6ad6927 commit c2a8c82

5 files changed

Lines changed: 107 additions & 31 deletions

File tree

src/ui/cursor-rows.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { mergeRows, type Row } from "./cursor-rows";
4+
5+
// Build a measured row with the only fields mergeRows reads; `el` is carried through untouched.
6+
function row(side: Row["side"], line: number, top: number, change = false): Row {
7+
return { el: {} as HTMLElement, side, line, top, height: 20, change };
8+
}
9+
10+
test("orders rows top-to-bottom regardless of input order", () => {
11+
const out = mergeRows([row("additions", 3, 40), row("additions", 1, 0), row("additions", 2, 20)]);
12+
assert.deepEqual(
13+
out.map((r) => r.line),
14+
[1, 2, 3],
15+
);
16+
});
17+
18+
test("merges split-view twins at the same rounded top — additions stays primary", () => {
19+
// A context line shows in both columns at (near-)identical y; the additions cell sorts first,
20+
// so it becomes the primary row and the deletions coordinate is preserved as `alt`.
21+
const out = mergeRows([row("deletions", 3, 10.4), row("additions", 5, 10.2)]);
22+
assert.equal(out.length, 1);
23+
assert.equal(out[0].side, "additions");
24+
assert.equal(out[0].line, 5);
25+
assert.deepEqual(out[0].alt, { side: "deletions", line: 3 });
26+
});
27+
28+
test("keeps rows at distinct tops separate and merges only on rounded equality", () => {
29+
const out = mergeRows([
30+
row("additions", 1, 10.2),
31+
row("deletions", 9, 10.4), // rounds to 10 → merged into the row above as its twin
32+
row("additions", 2, 30),
33+
]);
34+
assert.equal(out.length, 2);
35+
assert.deepEqual(
36+
out.map((r) => r.line),
37+
[1, 2],
38+
);
39+
assert.deepEqual(out[0].alt, { side: "deletions", line: 9 });
40+
assert.equal(out[1].alt, undefined);
41+
});

src/ui/cursor-rows.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { Side } from "./types";
2+
3+
export type Row = {
4+
el: HTMLElement;
5+
side: Side;
6+
line: number;
7+
top: number;
8+
height: number;
9+
change: boolean;
10+
// The split-view twin of a context row (see mergeRows): the deletions-side coordinates of the
11+
// same visual line, kept so a cursor seeded from a left-column click still matches this row.
12+
alt?: { side: Side; line: number };
13+
};
14+
15+
// Sort measured rows top-to-bottom (additions before deletions when they share a y) and merge the
16+
// split-view twins of a context line — the additions- and deletions-column cells that render at
17+
// the same visual line — into ONE row, keeping the additions side primary and the deletions
18+
// coordinate as `alt` so a cursor seeded from either column still matches. Pure over the measured
19+
// list; the getBoundingClientRect sweep that produces `out` lives in cursor.ts (rows()).
20+
export function mergeRows(out: Row[]): Row[] {
21+
out.sort((a, b) => a.top - b.top || (a.side === b.side ? 0 : a.side === "additions" ? -1 : 1));
22+
const seen = new Map<number, Row>();
23+
const list: Row[] = [];
24+
for (const r of out) {
25+
const k = Math.round(r.top);
26+
const kept = seen.get(k);
27+
if (kept) {
28+
kept.alt = { side: r.side, line: r.line };
29+
continue;
30+
}
31+
seen.set(k, r);
32+
list.push(r);
33+
}
34+
return list;
35+
}

src/ui/cursor.ts

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { acceptChange } from "./decisions";
55
import { openCommentComposer } from "./selection";
66
import { render } from "./render";
77
import { diffShadowRoot } from "./diff-dom";
8+
import { type Row, mergeRows } from "./cursor-rows";
89

910
// ── The diff line cursor ─────────────────────────────────────────────────────
1011
// Keyboard review needs a "current line" the diff doesn't otherwise have. We keep it as a
@@ -14,28 +15,32 @@ import { diffShadowRoot } from "./diff-dom";
1415
// pointer and keyboard share ONE highlight: a click seeds the cursor (cursorSyncTo) and the
1516
// arrows move the same selection from there.
1617

17-
type Row = {
18-
el: HTMLElement;
19-
side: Side;
20-
line: number;
21-
top: number;
22-
height: number;
23-
change: boolean;
24-
// The split-view twin of a context row (see rows()): the deletions-side coordinates of the
25-
// same visual line, kept so a cursor seeded from a left-column click still matches this row.
26-
alt?: { side: Side; line: number };
27-
};
28-
2918
let cur: { side: Side; line: number } | null = null;
3019

20+
// rows() runs a full-file getBoundingClientRect sweep to build its Row[]; it's called per keypress
21+
// (cursorMoveLine/Hunk, landAt, ensureCursor) and after every render (cursorResync), so on a large
22+
// expanded file each arrow press forced a synchronous whole-file layout. The list is derived purely
23+
// from DOM structure and content-relative tops (the stored `top` is relative to the scrolled
24+
// content, not the viewport), so it's stable across scroll — only a re-render, or a resize
25+
// reflowing line heights, actually changes it. So we cache it and rebuild only on those events:
26+
// invalidateCursorRows() is wired to @pierre's onPostRender (render.ts — mount / update / unmount,
27+
// which covers our render() AND @pierre's own expandHunk rerenders) and to window resize (main.ts).
28+
// Scroll deliberately does NOT invalidate: scrolling never changes the list, and arrow navigation
29+
// (which scrolls via scrollIntoView) stays on cache hits instead of re-sweeping the file per press.
30+
let cached: Row[] | null = null;
31+
32+
export function invalidateCursorRows() {
33+
cached = null;
34+
}
35+
3136
// Every navigable code line in visual (top-to-bottom) order. @pierre tags each line's gutter with
3237
// a [data-line-number-content] span inside a [data-line-type] cell, within a [data-additions] /
3338
// [data-deletions] column (split) — that gives us side + number + row element. Context lines show
34-
// in both split columns at the same y; merge by rounded top into one row (additions side as the
35-
// primary, the deletions twin preserved as `alt` so either coordinate matches).
39+
// in both split columns at the same y; mergeRows() folds those twins into one row.
3640
function rows(): Row[] {
41+
if (cached) return cached;
3742
const sh = diffShadowRoot();
38-
if (!sh) return [];
43+
if (!sh) return []; // shadow not mounted yet — don't cache, retry on the next call
3944
const diff = $("diff");
4045
const diffTop = diff.getBoundingClientRect().top;
4146
const scrollTop = diff.scrollTop;
@@ -65,20 +70,8 @@ function rows(): Row[] {
6570
change: type.startsWith("change-"),
6671
});
6772
});
68-
out.sort((a, b) => a.top - b.top || (a.side === b.side ? 0 : a.side === "additions" ? -1 : 1));
69-
const seen = new Map<number, Row>();
70-
const list: Row[] = [];
71-
for (const r of out) {
72-
const k = Math.round(r.top);
73-
const kept = seen.get(k);
74-
if (kept) {
75-
kept.alt = { side: r.side, line: r.line };
76-
continue;
77-
}
78-
seen.set(k, r);
79-
list.push(r);
80-
}
81-
return list;
73+
cached = mergeRows(out);
74+
return cached;
8275
}
8376

8477
// A row matches on its primary coordinates or its split-view twin (`alt`).

src/ui/main.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
} from "./guide";
3737
import { setBaseTitle, reviewStats } from "./progress";
3838
import { installKeys, helpGroups, confirmYes, confirmNo } from "./keys";
39-
import { cursorReset, cursorSelection } from "./cursor";
39+
import { cursorReset, cursorSelection, invalidateCursorRows } from "./cursor";
4040
import type { ReviewState, FileRow, Settings, DiffStyle } from "./types";
4141

4242
// Close the inline composer when clicking outside it (unless it has unsaved text). The
@@ -517,4 +517,8 @@ render();
517517
$("diff").addEventListener("scroll", () => {
518518
S.diffScrolled = $("diff").scrollTop > 140;
519519
});
520+
// A resize reflows line heights (and wrap), so the cursor's cached row measurements no longer
521+
// hold — drop the cache so the next navigation re-measures. (Scroll doesn't: the cached tops are
522+
// content-relative, so scrolling leaves the row list unchanged — see cursor.ts.)
523+
window.addEventListener("resize", invalidateCursorRows);
520524
setInterval(pollState, 1500);

src/ui/render.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { approveCurrentFile, resetReview } from "./decisions";
2626
import { blockersChip } from "./blockers";
2727
import { isMarkdownPath, renderMarkdownFile } from "./mdfile";
2828
import { renderMarkdown } from "./markdown";
29-
import { cursorResync, cursorReset } from "./cursor";
29+
import { cursorResync, cursorReset, invalidateCursorRows } from "./cursor";
3030
import { hasGuide, renderOverview, currentGuideEntry } from "./guide";
3131
import { updateProgress } from "./progress";
3232
import {
@@ -579,6 +579,9 @@ async function renderCenter() {
579579
// finds nothing; onPostRender fires when they exist. (afterRender still runs it too, for
580580
// the warm/cached path where rows are already present — both are idempotent.)
581581
onPostRender: (_node: HTMLElement, _inst: unknown, phase: string) => {
582+
// The rendered rows just changed (mount, update, or @pierre's own expandHunk rerender —
583+
// which never routes through our render()), so the cursor's cached row list is stale.
584+
invalidateCursorRows();
582585
if (phase !== "unmount") applySkimCollapse();
583586
},
584587
// @pierre reserves a right-side gutter via `scrollbar-gutter: stable` on the code grid

0 commit comments

Comments
 (0)