Skip to content

Commit 48d20e4

Browse files
committed
perf: widen diff overscan during rapid scrolling
1 parent c6263f6 commit 48d20e4

4 files changed

Lines changed: 93 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ All notable user-visible changes to Hunk are documented in this file.
1212

1313
### Fixed
1414

15+
- Expanded the diff window during rapid scrolling bursts so large reviews keep real rows mounted instead of falling back to blank placeholder regions.
16+
1517
## [0.14.0] - 2026-05-26
1618

1719
### Added

‎src/ui/components/panes/DiffPane.tsx‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import {
2727
reviewNoteSource,
2828
type VisibleAgentNote,
2929
} from "../../lib/agentAnnotations";
30+
import {
31+
computeRapidScrollOverscanRows,
32+
RAPID_SCROLL_OVERSCAN_IDLE_MS,
33+
} from "../../lib/adaptiveScrollOverscan";
3034
import { computeHunkRevealScrollTop } from "../../lib/hunkScroll";
3135
import {
3236
measureDiffSectionGeometry,
@@ -123,12 +127,14 @@ function buildAdjacentPrefetchFileIds(files: DiffFile[], selectedFileId?: string
123127
function buildHighlightPrefetchFileIds({
124128
adjacentPrefetchFileIds,
125129
fileSectionLayouts,
130+
rapidScrollOverscanRows,
126131
scrollTop,
127132
viewportHeight,
128133
selectedFileId,
129134
}: {
130135
adjacentPrefetchFileIds: Set<string>;
131136
fileSectionLayouts: FileSectionLayout[];
137+
rapidScrollOverscanRows: number;
132138
scrollTop: number;
133139
viewportHeight: number;
134140
selectedFileId?: string;
@@ -140,7 +146,7 @@ function buildHighlightPrefetchFileIds({
140146
}
141147

142148
const clampedViewportHeight = Math.max(1, viewportHeight);
143-
const prefetchRows = Math.max(24, clampedViewportHeight * 3);
149+
const prefetchRows = Math.max(24, clampedViewportHeight * 3, rapidScrollOverscanRows);
144150
const minPrefetchY = Math.max(0, scrollTop - prefetchRows);
145151
const maxPrefetchY = scrollTop + viewportHeight + prefetchRows;
146152

@@ -406,6 +412,7 @@ export function DiffPane({
406412
// other files can still use placeholders and viewport windowing.
407413
const windowingEnabled = !wrapLines;
408414
const [scrollViewport, setScrollViewport] = useState({ top: 0, height: 0 });
415+
const [rapidScrollOverscanRows, setRapidScrollOverscanRows] = useState(0);
409416
const [hoveredFileId, setHoveredFileId] = useState<string | null>(null);
410417
const [copySelectionDrag, setCopySelectionDrag] = useState<CopySelectionDrag | null>(null);
411418
// Mirror the drag state in a ref so updateCopySelection can suppress native selection
@@ -429,11 +436,28 @@ export function DiffPane({
429436
const suppressViewportSelectionSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
430437
null,
431438
);
439+
const rapidScrollOverscanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
432440
// Initialized to null so the first render never fires a selection change; a real scroll
433441
// is required before passive viewport-follow selection can trigger.
434442
const lastViewportSelectionTopRef = useRef<number | null>(null);
435443
const lastViewportRowAnchorRef = useRef<ViewportRowAnchor | null>(null);
436444

445+
/** Temporarily widen the mounted diff window while scroll input is arriving in bursts. */
446+
const activateRapidScrollOverscan = useCallback((overscanRows: number) => {
447+
if (overscanRows <= 0) {
448+
return;
449+
}
450+
451+
setRapidScrollOverscanRows((current) => Math.max(current, overscanRows));
452+
if (rapidScrollOverscanTimeoutRef.current) {
453+
clearTimeout(rapidScrollOverscanTimeoutRef.current);
454+
}
455+
rapidScrollOverscanTimeoutRef.current = setTimeout(() => {
456+
rapidScrollOverscanTimeoutRef.current = null;
457+
setRapidScrollOverscanRows(0);
458+
}, RAPID_SCROLL_OVERSCAN_IDLE_MS);
459+
}, []);
460+
437461
/**
438462
* Ignore viewport-follow selection updates while the pane is scrolling to an explicit selection.
439463
* That lets direct hunk/file navigation own the viewport until the jump settles.
@@ -454,6 +478,9 @@ export function DiffPane({
454478
if (suppressViewportSelectionSyncTimeoutRef.current) {
455479
clearTimeout(suppressViewportSelectionSyncTimeoutRef.current);
456480
}
481+
if (rapidScrollOverscanTimeoutRef.current) {
482+
clearTimeout(rapidScrollOverscanTimeoutRef.current);
483+
}
457484
};
458485
}, []);
459486

@@ -475,8 +502,15 @@ export function DiffPane({
475502
// Detect scroll activity, show scrollbar, and clear hover-only controls. The pointer may
476503
// now sit over a different row, but only an actual mouse move should reveal row actions.
477504
if (nextTop !== prevScrollTopRef.current) {
505+
const previousTop = prevScrollTopRef.current;
478506
scrollbarRef.current?.show();
479507
clearAddNoteHoverForScroll();
508+
activateRapidScrollOverscan(
509+
computeRapidScrollOverscanRows({
510+
deltaRows: nextTop - previousTop,
511+
viewportHeight: nextHeight,
512+
}),
513+
);
480514
prevScrollTopRef.current = nextTop;
481515
}
482516

@@ -524,7 +558,7 @@ export function DiffPane({
524558
scrollBox.viewport.off("layout-changed", handleViewportChange);
525559
scrollBox.viewport.off("resized", handleViewportChange);
526560
};
527-
}, [clearAddNoteHoverForScroll, files.length, scrollRef]);
561+
}, [activateRapidScrollOverscan, clearAddNoteHoverForScroll, files.length, scrollRef]);
528562

529563
const sectionHeaderHeights = useMemo(() => buildInStreamFileHeaderHeights(files), [files]);
530564

@@ -568,11 +602,11 @@ export function DiffPane({
568602
);
569603

570604
const visibleViewportFileIds = useMemo(() => {
571-
const overscanTerminalRows = 8;
605+
const overscanTerminalRows = Math.max(8, rapidScrollOverscanRows);
572606
const minVisibleY = Math.max(0, scrollViewport.top - overscanTerminalRows);
573607
const maxVisibleY = scrollViewport.top + scrollViewport.height + overscanTerminalRows;
574608
return collectIntersectingFileSectionIds(baseFileSectionLayouts, minVisibleY, maxVisibleY);
575-
}, [baseFileSectionLayouts, scrollViewport.height, scrollViewport.top]);
609+
}, [baseFileSectionLayouts, rapidScrollOverscanRows, scrollViewport.height, scrollViewport.top]);
576610

577611
const visibleAgentNotesByFile = useMemo(() => {
578612
const next = new Map<string, VisibleAgentNote[]>();
@@ -970,13 +1004,15 @@ export function DiffPane({
9701004
buildHighlightPrefetchFileIds({
9711005
adjacentPrefetchFileIds,
9721006
fileSectionLayouts,
1007+
rapidScrollOverscanRows,
9731008
scrollTop: scrollViewport.top,
9741009
viewportHeight: scrollViewport.height,
9751010
selectedFileId,
9761011
}),
9771012
[
9781013
adjacentPrefetchFileIds,
9791014
fileSectionLayouts,
1015+
rapidScrollOverscanRows,
9801016
scrollViewport.height,
9811017
scrollViewport.top,
9821018
selectedFileId,
@@ -1076,7 +1112,7 @@ export function DiffPane({
10761112
return next;
10771113
}
10781114

1079-
const overscanTerminalRows = Math.max(24, scrollViewport.height * 2);
1115+
const overscanTerminalRows = Math.max(24, scrollViewport.height * 2, rapidScrollOverscanRows);
10801116

10811117
files.forEach((file, index) => {
10821118
const sectionLayout = fileSectionLayouts[index];
@@ -1115,6 +1151,7 @@ export function DiffPane({
11151151
}, [
11161152
fileSectionLayouts,
11171153
files,
1154+
rapidScrollOverscanRows,
11181155
scrollViewport.height,
11191156
scrollViewport.top,
11201157
sectionGeometry,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { computeRapidScrollOverscanRows } from "./adaptiveScrollOverscan";
3+
4+
describe("computeRapidScrollOverscanRows", () => {
5+
test("leaves slow row-by-row movement on the default window", () => {
6+
expect(computeRapidScrollOverscanRows({ deltaRows: 1, viewportHeight: 30 })).toBe(0);
7+
expect(computeRapidScrollOverscanRows({ deltaRows: -3, viewportHeight: 30 })).toBe(0);
8+
});
9+
10+
test("expands to at least three viewports during bursty scrolling", () => {
11+
expect(computeRapidScrollOverscanRows({ deltaRows: 8, viewportHeight: 30 })).toBe(90);
12+
});
13+
14+
test("scales with large coalesced jumps but stays bounded", () => {
15+
expect(computeRapidScrollOverscanRows({ deltaRows: 80, viewportHeight: 20 })).toBe(160);
16+
expect(computeRapidScrollOverscanRows({ deltaRows: -1_000, viewportHeight: 40 })).toBe(240);
17+
});
18+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export const RAPID_SCROLL_OVERSCAN_IDLE_MS = 160;
2+
3+
const RAPID_SCROLL_MIN_DELTA_ROWS = 4;
4+
const RAPID_SCROLL_MIN_VIEWPORT_MULTIPLIER = 3;
5+
const RAPID_SCROLL_MAX_OVERSCAN_ROWS = 240;
6+
7+
/**
8+
* Return the temporary overscan halo to use after one coalesced scroll jump.
9+
*
10+
* Slow row-by-row movement keeps the default window small. Bursty wheel/page movement expands the
11+
* mounted window for a short idle period so the terminal can keep showing real, slightly
12+
* over-rendered rows instead of placeholders while scroll events outrun React commits.
13+
*/
14+
export function computeRapidScrollOverscanRows({
15+
deltaRows,
16+
viewportHeight,
17+
}: {
18+
deltaRows: number;
19+
viewportHeight: number;
20+
}) {
21+
const absoluteDeltaRows = Math.abs(deltaRows);
22+
if (absoluteDeltaRows < RAPID_SCROLL_MIN_DELTA_ROWS) {
23+
return 0;
24+
}
25+
26+
const viewportRows = Math.max(1, Math.floor(viewportHeight));
27+
return Math.min(
28+
RAPID_SCROLL_MAX_OVERSCAN_ROWS,
29+
Math.max(absoluteDeltaRows * 2, viewportRows * RAPID_SCROLL_MIN_VIEWPORT_MULTIPLIER),
30+
);
31+
}

0 commit comments

Comments
 (0)