From 169a84929c60a7dd3da00d79ce3c6d4668dcd33d Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 2 Jun 2026 08:17:53 -0400 Subject: [PATCH 1/2] perf: widen diff overscan during rapid scrolling --- CHANGELOG.md | 2 + src/ui/components/panes/DiffPane.tsx | 47 ++++++++++++++++++++--- src/ui/lib/adaptiveScrollOverscan.test.ts | 18 +++++++++ src/ui/lib/adaptiveScrollOverscan.ts | 31 +++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 src/ui/lib/adaptiveScrollOverscan.test.ts create mode 100644 src/ui/lib/adaptiveScrollOverscan.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 530a7fb2..f4da1969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ All notable user-visible changes to Hunk are documented in this file. ### Fixed +- Expanded the diff window during rapid scrolling bursts so large reviews keep real rows mounted instead of falling back to blank placeholder regions. + ## [0.14.1] - 2026-06-01 ### Added diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index 40f05599..d5bc2308 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -27,6 +27,10 @@ import { reviewNoteSource, type VisibleAgentNote, } from "../../lib/agentAnnotations"; +import { + computeRapidScrollOverscanRows, + RAPID_SCROLL_OVERSCAN_IDLE_MS, +} from "../../lib/adaptiveScrollOverscan"; import { computeHunkRevealScrollTop } from "../../lib/hunkScroll"; import { measureDiffSectionGeometry, @@ -123,12 +127,14 @@ function buildAdjacentPrefetchFileIds(files: DiffFile[], selectedFileId?: string function buildHighlightPrefetchFileIds({ adjacentPrefetchFileIds, fileSectionLayouts, + rapidScrollOverscanRows, scrollTop, viewportHeight, selectedFileId, }: { adjacentPrefetchFileIds: Set; fileSectionLayouts: FileSectionLayout[]; + rapidScrollOverscanRows: number; scrollTop: number; viewportHeight: number; selectedFileId?: string; @@ -140,7 +146,7 @@ function buildHighlightPrefetchFileIds({ } const clampedViewportHeight = Math.max(1, viewportHeight); - const prefetchRows = Math.max(24, clampedViewportHeight * 3); + const prefetchRows = Math.max(24, clampedViewportHeight * 3, rapidScrollOverscanRows); const minPrefetchY = Math.max(0, scrollTop - prefetchRows); const maxPrefetchY = scrollTop + viewportHeight + prefetchRows; @@ -406,6 +412,7 @@ export function DiffPane({ // other files can still use placeholders and viewport windowing. const windowingEnabled = !wrapLines; const [scrollViewport, setScrollViewport] = useState({ top: 0, height: 0 }); + const [rapidScrollOverscanRows, setRapidScrollOverscanRows] = useState(0); const [hoveredFileId, setHoveredFileId] = useState(null); const [copySelectionDrag, setCopySelectionDrag] = useState(null); // Mirror the drag state in a ref so updateCopySelection can suppress native selection @@ -429,11 +436,28 @@ export function DiffPane({ const suppressViewportSelectionSyncTimeoutRef = useRef | null>( null, ); + const rapidScrollOverscanTimeoutRef = useRef | null>(null); // Initialized to null so the first render never fires a selection change; a real scroll // is required before passive viewport-follow selection can trigger. const lastViewportSelectionTopRef = useRef(null); const lastViewportRowAnchorRef = useRef(null); + /** Temporarily widen the mounted diff window while scroll input is arriving in bursts. */ + const activateRapidScrollOverscan = useCallback((overscanRows: number) => { + if (overscanRows <= 0) { + return; + } + + setRapidScrollOverscanRows((current) => Math.max(current, overscanRows)); + if (rapidScrollOverscanTimeoutRef.current) { + clearTimeout(rapidScrollOverscanTimeoutRef.current); + } + rapidScrollOverscanTimeoutRef.current = setTimeout(() => { + rapidScrollOverscanTimeoutRef.current = null; + setRapidScrollOverscanRows(0); + }, RAPID_SCROLL_OVERSCAN_IDLE_MS); + }, []); + /** * Ignore viewport-follow selection updates while the pane is scrolling to an explicit selection. * That lets direct hunk/file navigation own the viewport until the jump settles. @@ -454,6 +478,9 @@ export function DiffPane({ if (suppressViewportSelectionSyncTimeoutRef.current) { clearTimeout(suppressViewportSelectionSyncTimeoutRef.current); } + if (rapidScrollOverscanTimeoutRef.current) { + clearTimeout(rapidScrollOverscanTimeoutRef.current); + } }; }, []); @@ -475,8 +502,15 @@ export function DiffPane({ // Detect scroll activity, show scrollbar, and clear hover-only controls. The pointer may // now sit over a different row, but only an actual mouse move should reveal row actions. if (nextTop !== prevScrollTopRef.current) { + const previousTop = prevScrollTopRef.current; scrollbarRef.current?.show(); clearAddNoteHoverForScroll(); + activateRapidScrollOverscan( + computeRapidScrollOverscanRows({ + deltaRows: nextTop - previousTop, + viewportHeight: nextHeight, + }), + ); prevScrollTopRef.current = nextTop; } @@ -524,7 +558,7 @@ export function DiffPane({ scrollBox.viewport.off("layout-changed", handleViewportChange); scrollBox.viewport.off("resized", handleViewportChange); }; - }, [clearAddNoteHoverForScroll, files.length, scrollRef]); + }, [activateRapidScrollOverscan, clearAddNoteHoverForScroll, files.length, scrollRef]); const sectionHeaderHeights = useMemo(() => buildInStreamFileHeaderHeights(files), [files]); @@ -568,11 +602,11 @@ export function DiffPane({ ); const visibleViewportFileIds = useMemo(() => { - const overscanTerminalRows = 8; + const overscanTerminalRows = Math.max(8, rapidScrollOverscanRows); const minVisibleY = Math.max(0, scrollViewport.top - overscanTerminalRows); const maxVisibleY = scrollViewport.top + scrollViewport.height + overscanTerminalRows; return collectIntersectingFileSectionIds(baseFileSectionLayouts, minVisibleY, maxVisibleY); - }, [baseFileSectionLayouts, scrollViewport.height, scrollViewport.top]); + }, [baseFileSectionLayouts, rapidScrollOverscanRows, scrollViewport.height, scrollViewport.top]); const visibleAgentNotesByFile = useMemo(() => { const next = new Map(); @@ -970,6 +1004,7 @@ export function DiffPane({ buildHighlightPrefetchFileIds({ adjacentPrefetchFileIds, fileSectionLayouts, + rapidScrollOverscanRows, scrollTop: scrollViewport.top, viewportHeight: scrollViewport.height, selectedFileId, @@ -977,6 +1012,7 @@ export function DiffPane({ [ adjacentPrefetchFileIds, fileSectionLayouts, + rapidScrollOverscanRows, scrollViewport.height, scrollViewport.top, selectedFileId, @@ -1076,7 +1112,7 @@ export function DiffPane({ return next; } - const overscanTerminalRows = Math.max(24, scrollViewport.height * 2); + const overscanTerminalRows = Math.max(24, scrollViewport.height * 2, rapidScrollOverscanRows); files.forEach((file, index) => { const sectionLayout = fileSectionLayouts[index]; @@ -1115,6 +1151,7 @@ export function DiffPane({ }, [ fileSectionLayouts, files, + rapidScrollOverscanRows, scrollViewport.height, scrollViewport.top, sectionGeometry, diff --git a/src/ui/lib/adaptiveScrollOverscan.test.ts b/src/ui/lib/adaptiveScrollOverscan.test.ts new file mode 100644 index 00000000..120e9fd0 --- /dev/null +++ b/src/ui/lib/adaptiveScrollOverscan.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { computeRapidScrollOverscanRows } from "./adaptiveScrollOverscan"; + +describe("computeRapidScrollOverscanRows", () => { + test("leaves slow row-by-row movement on the default window", () => { + expect(computeRapidScrollOverscanRows({ deltaRows: 1, viewportHeight: 30 })).toBe(0); + expect(computeRapidScrollOverscanRows({ deltaRows: -3, viewportHeight: 30 })).toBe(0); + }); + + test("expands to at least three viewports during bursty scrolling", () => { + expect(computeRapidScrollOverscanRows({ deltaRows: 8, viewportHeight: 30 })).toBe(90); + }); + + test("scales with large coalesced jumps but stays bounded", () => { + expect(computeRapidScrollOverscanRows({ deltaRows: 80, viewportHeight: 20 })).toBe(160); + expect(computeRapidScrollOverscanRows({ deltaRows: -1_000, viewportHeight: 40 })).toBe(240); + }); +}); diff --git a/src/ui/lib/adaptiveScrollOverscan.ts b/src/ui/lib/adaptiveScrollOverscan.ts new file mode 100644 index 00000000..3816ecd0 --- /dev/null +++ b/src/ui/lib/adaptiveScrollOverscan.ts @@ -0,0 +1,31 @@ +export const RAPID_SCROLL_OVERSCAN_IDLE_MS = 160; + +const RAPID_SCROLL_MIN_DELTA_ROWS = 4; +const RAPID_SCROLL_MIN_VIEWPORT_MULTIPLIER = 3; +const RAPID_SCROLL_MAX_OVERSCAN_ROWS = 240; + +/** + * Return the temporary overscan halo to use after one coalesced scroll jump. + * + * Slow row-by-row movement keeps the default window small. Bursty wheel/page movement expands the + * mounted window for a short idle period so the terminal can keep showing real, slightly + * over-rendered rows instead of placeholders while scroll events outrun React commits. + */ +export function computeRapidScrollOverscanRows({ + deltaRows, + viewportHeight, +}: { + deltaRows: number; + viewportHeight: number; +}) { + const absoluteDeltaRows = Math.abs(deltaRows); + if (absoluteDeltaRows < RAPID_SCROLL_MIN_DELTA_ROWS) { + return 0; + } + + const viewportRows = Math.max(1, Math.floor(viewportHeight)); + return Math.min( + RAPID_SCROLL_MAX_OVERSCAN_ROWS, + Math.max(absoluteDeltaRows * 2, viewportRows * RAPID_SCROLL_MIN_VIEWPORT_MULTIPLIER), + ); +} From 868ae64834487d3cec9cf5a8ecc28968905d12e4 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 2 Jun 2026 08:35:25 -0400 Subject: [PATCH 2/2] fix: avoid initial rapid-scroll overscan bursts --- src/ui/components/panes/DiffPane.tsx | 12 +++++++++--- src/ui/lib/adaptiveScrollOverscan.test.ts | 4 ++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index d5bc2308..d3bb6950 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -423,6 +423,7 @@ export function DiffPane({ const lastClickPointRef = useRef(null); const scrollbarRef = useRef(null); const prevScrollTopRef = useRef(0); + const hasReadScrollViewportRef = useRef(false); const previousSectionGeometryRef = useRef(null); const previousFilesRef = useRef(files); const previousLayoutRef = useRef(layout); @@ -499,9 +500,14 @@ export function DiffPane({ const nextTop = scrollBox.scrollTop ?? 0; const nextHeight = scrollBox.viewport.height ?? 0; - // Detect scroll activity, show scrollbar, and clear hover-only controls. The pointer may - // now sit over a different row, but only an actual mouse move should reveal row actions. - if (nextTop !== prevScrollTopRef.current) { + // The first viewport read is a baseline snapshot, not scroll input. The scroll box may retain + // a non-zero top across remounts, so do not treat that retained position as a rapid burst. + if (!hasReadScrollViewportRef.current) { + hasReadScrollViewportRef.current = true; + prevScrollTopRef.current = nextTop; + } else if (nextTop !== prevScrollTopRef.current) { + // Detect scroll activity, show scrollbar, and clear hover-only controls. The pointer may + // now sit over a different row, but only an actual mouse move should reveal row actions. const previousTop = prevScrollTopRef.current; scrollbarRef.current?.show(); clearAddNoteHoverForScroll(); diff --git a/src/ui/lib/adaptiveScrollOverscan.test.ts b/src/ui/lib/adaptiveScrollOverscan.test.ts index 120e9fd0..8afa0a21 100644 --- a/src/ui/lib/adaptiveScrollOverscan.test.ts +++ b/src/ui/lib/adaptiveScrollOverscan.test.ts @@ -7,6 +7,10 @@ describe("computeRapidScrollOverscanRows", () => { expect(computeRapidScrollOverscanRows({ deltaRows: -3, viewportHeight: 30 })).toBe(0); }); + test("treats the minimum burst threshold as inclusive", () => { + expect(computeRapidScrollOverscanRows({ deltaRows: 4, viewportHeight: 30 })).toBe(90); + }); + test("expands to at least three viewports during bursty scrolling", () => { expect(computeRapidScrollOverscanRows({ deltaRows: 8, viewportHeight: 30 })).toBe(90); });