@@ -5,6 +5,7 @@ import { acceptChange } from "./decisions";
55import { openCommentComposer } from "./selection" ;
66import { render } from "./render" ;
77import { 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-
2918let 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.
3640function 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`).
0 commit comments