@@ -15,7 +15,6 @@ import { computeHunkRevealScrollTop } from "../../lib/hunkScroll";
1515import {
1616 measureDiffSectionGeometry ,
1717 type DiffSectionGeometry ,
18- type DiffSectionRowBounds ,
1918} from "../../lib/diffSectionGeometry" ;
2019import {
2120 buildFileSectionLayouts ,
@@ -27,6 +26,11 @@ import {
2726} from "../../lib/fileSectionLayout" ;
2827import { diffHunkId , diffSectionId } from "../../lib/ids" ;
2928import { findViewportCenteredHunkTarget } from "../../lib/viewportSelection" ;
29+ import {
30+ findViewportRowAnchor ,
31+ resolveViewportRowAnchorTop ,
32+ type ViewportRowAnchor ,
33+ } from "../../lib/viewportAnchor" ;
3034import type { AppTheme } from "../../themes" ;
3135import { DiffSection } from "./DiffSection" ;
3236import { DiffFileHeaderRow } from "./DiffFileHeaderRow" ;
@@ -36,99 +40,6 @@ import { prefetchHighlightedDiff } from "../../diff/useHighlightedDiff";
3640
3741const EMPTY_VISIBLE_AGENT_NOTES : VisibleAgentNote [ ] = [ ] ;
3842
39- /** Identify the rendered diff row that currently owns the top of the viewport. */
40- interface ViewportRowAnchor {
41- fileId : string ;
42- rowKey : string ;
43- rowOffsetWithin : number ;
44- }
45-
46- /** Find the rendered row bounds covering a vertical offset within one file body. */
47- function binarySearchRowBounds ( sectionRowBounds : DiffSectionRowBounds [ ] , relativeTop : number ) {
48- let low = 0 ;
49- let high = sectionRowBounds . length - 1 ;
50-
51- while ( low <= high ) {
52- const mid = ( low + high ) >>> 1 ;
53- const rowBounds = sectionRowBounds [ mid ] ! ;
54-
55- if ( relativeTop < rowBounds . top ) {
56- high = mid - 1 ;
57- } else if ( relativeTop >= rowBounds . top + rowBounds . height ) {
58- low = mid + 1 ;
59- } else {
60- return rowBounds ;
61- }
62- }
63-
64- return undefined ;
65- }
66-
67- /** Capture a stable top-row anchor from the pre-toggle layout so it can be restored later. */
68- function findViewportRowAnchor (
69- files : DiffFile [ ] ,
70- sectionGeometry : DiffSectionGeometry [ ] ,
71- scrollTop : number ,
72- headerHeights : number [ ] ,
73- ) {
74- const fileSectionLayouts = buildFileSectionLayouts (
75- files ,
76- sectionGeometry . map ( ( metrics ) => metrics ?. bodyHeight ?? 0 ) ,
77- headerHeights ,
78- ) ;
79-
80- for ( let index = 0 ; index < files . length ; index += 1 ) {
81- const sectionLayout = fileSectionLayouts [ index ] ;
82- const bodyTop = sectionLayout ?. bodyTop ?? 0 ;
83- const geometry = sectionGeometry [ index ] ;
84- const bodyHeight = geometry ?. bodyHeight ?? 0 ;
85- const relativeTop = scrollTop - bodyTop ;
86-
87- if ( relativeTop >= 0 && relativeTop < bodyHeight && geometry ) {
88- const rowBounds = binarySearchRowBounds ( geometry . rowBounds , relativeTop ) ;
89- if ( rowBounds ) {
90- return {
91- fileId : files [ index ] ! . id ,
92- rowKey : rowBounds . key ,
93- rowOffsetWithin : relativeTop - rowBounds . top ,
94- } satisfies ViewportRowAnchor ;
95- }
96- }
97- }
98-
99- return null ;
100- }
101-
102- /** Resolve a captured row anchor into its new scrollTop after wrapping or layout changes. */
103- function resolveViewportRowAnchorTop (
104- files : DiffFile [ ] ,
105- sectionGeometry : DiffSectionGeometry [ ] ,
106- anchor : ViewportRowAnchor ,
107- headerHeights : number [ ] ,
108- ) {
109- const fileSectionLayouts = buildFileSectionLayouts (
110- files ,
111- sectionGeometry . map ( ( metrics ) => metrics ?. bodyHeight ?? 0 ) ,
112- headerHeights ,
113- ) ;
114-
115- for ( let index = 0 ; index < files . length ; index += 1 ) {
116- const sectionLayout = fileSectionLayouts [ index ] ;
117- const bodyTop = sectionLayout ?. bodyTop ?? 0 ;
118- const file = files [ index ] ;
119- const geometry = sectionGeometry [ index ] ;
120- if ( file ?. id === anchor . fileId && geometry ) {
121- const rowBounds = geometry . rowBoundsByKey . get ( anchor . rowKey ) ;
122- if ( rowBounds ) {
123- return bodyTop + rowBounds . top + Math . min ( anchor . rowOffsetWithin , rowBounds . height - 1 ) ;
124- }
125- return bodyTop ;
126- }
127- }
128-
129- return 0 ;
130- }
131-
13243/** Keep syntax-highlight warm for the files immediately adjacent to the current selection. */
13344function buildAdjacentPrefetchFileIds ( files : DiffFile [ ] , selectedFileId ?: string ) {
13445 if ( ! selectedFileId ) {
@@ -217,6 +128,8 @@ export function DiffPane({
217128 showHunkHeaders,
218129 wrapLines,
219130 wrapToggleScrollTop,
131+ layoutToggleScrollTop = null ,
132+ layoutToggleRequestId = 0 ,
220133 selectedFileTopAlignRequestId = 0 ,
221134 selectedHunkRevealRequestId,
222135 theme,
@@ -243,6 +156,8 @@ export function DiffPane({
243156 showHunkHeaders : boolean ;
244157 wrapLines : boolean ;
245158 wrapToggleScrollTop : number | null ;
159+ layoutToggleScrollTop ?: number | null ;
160+ layoutToggleRequestId ?: number ;
246161 selectedFileTopAlignRequestId ?: number ;
247162 selectedHunkRevealRequestId ?: number ;
248163 theme : AppTheme ;
@@ -333,8 +248,10 @@ export function DiffPane({
333248 const prevScrollTopRef = useRef ( 0 ) ;
334249 const previousSectionGeometryRef = useRef < DiffSectionGeometry [ ] | null > ( null ) ;
335250 const previousFilesRef = useRef < DiffFile [ ] > ( files ) ;
251+ const previousLayoutRef = useRef ( layout ) ;
336252 const previousWrapLinesRef = useRef ( wrapLines ) ;
337253 const previousSelectedFileTopAlignRequestIdRef = useRef ( selectedFileTopAlignRequestId ) ;
254+ const previousLayoutToggleRequestIdRef = useRef ( layoutToggleRequestId ) ;
338255 const previousSelectedHunkRevealRequestIdRef = useRef ( selectedHunkRevealRequestId ) ;
339256 const pendingFileTopAlignFileIdRef = useRef < string | null > ( null ) ;
340257 const suppressViewportSelectionSyncRef = useRef ( false ) ;
@@ -344,6 +261,7 @@ export function DiffPane({
344261 // Initialized to null so the first render never fires a selection change; a real scroll
345262 // is required before passive viewport-follow selection can trigger.
346263 const lastViewportSelectionTopRef = useRef < number | null > ( null ) ;
264+ const lastViewportRowAnchorRef = useRef < ViewportRowAnchor | null > ( null ) ;
347265
348266 /**
349267 * Ignore viewport-follow selection updates while the pane is scrolling to an explicit selection.
@@ -734,23 +652,29 @@ export function DiffPane({
734652 ) ;
735653
736654 useLayoutEffect ( ( ) => {
655+ const layoutChanged = previousLayoutRef . current !== layout ;
656+ const explicitLayoutToggle = previousLayoutToggleRequestIdRef . current !== layoutToggleRequestId ;
737657 const wrapChanged = previousWrapLinesRef . current !== wrapLines ;
738658 const previousSectionMetrics = previousSectionGeometryRef . current ;
739659 const previousFiles = previousFilesRef . current ;
740- const previousSectionHeaderHeights = buildInStreamFileHeaderHeights ( previousFiles ) ;
741660
742- if ( wrapChanged && previousSectionMetrics && previousFiles . length > 0 ) {
661+ if ( ( layoutChanged || wrapChanged ) && previousSectionMetrics && previousFiles . length > 0 ) {
662+ const previousSectionHeaderHeights = buildInStreamFileHeaderHeights ( previousFiles ) ;
743663 const previousScrollTop =
744664 // Prefer the synchronously captured pre-toggle position so anchor restoration does not
745665 // race the polling-based viewport snapshot.
746- wrapToggleScrollTop != null
666+ wrapChanged && wrapToggleScrollTop != null
747667 ? wrapToggleScrollTop
748- : Math . max ( prevScrollTopRef . current , scrollViewport . top ) ;
668+ : layoutChanged && explicitLayoutToggle && layoutToggleScrollTop != null
669+ ? layoutToggleScrollTop
670+ : ( scrollRef . current ?. scrollTop ??
671+ Math . max ( prevScrollTopRef . current , scrollViewport . top ) ) ;
749672 const anchor = findViewportRowAnchor (
750673 previousFiles ,
751674 previousSectionMetrics ,
752675 previousScrollTop ,
753676 previousSectionHeaderHeights ,
677+ lastViewportRowAnchorRef . current ?. stableKey ,
754678 ) ;
755679 if ( anchor ) {
756680 const nextTop = resolveViewportRowAnchorTop (
@@ -763,13 +687,16 @@ export function DiffPane({
763687 scrollRef . current ?. scrollTo ( nextTop ) ;
764688 } ;
765689
690+ lastViewportRowAnchorRef . current = anchor ;
766691 suppressViewportSelectionSync ( ) ;
767692 restoreViewportAnchor ( ) ;
768693 // Retry across a couple of repaint cycles so the restored top-row anchor sticks
769694 // after wrapped row heights and viewport culling settle.
770695 const retryDelays = [ 0 , 16 , 48 ] ;
771696 const timeouts = retryDelays . map ( ( delay ) => setTimeout ( restoreViewportAnchor , delay ) ) ;
772697
698+ previousLayoutRef . current = layout ;
699+ previousLayoutToggleRequestIdRef . current = layoutToggleRequestId ;
773700 previousWrapLinesRef . current = wrapLines ;
774701 previousSectionGeometryRef . current = sectionGeometry ;
775702 previousFilesRef . current = files ;
@@ -780,11 +707,16 @@ export function DiffPane({
780707 }
781708 }
782709
710+ previousLayoutRef . current = layout ;
711+ previousLayoutToggleRequestIdRef . current = layoutToggleRequestId ;
783712 previousWrapLinesRef . current = wrapLines ;
784713 previousSectionGeometryRef . current = sectionGeometry ;
785714 previousFilesRef . current = files ;
786715 } , [
787716 files ,
717+ layout ,
718+ layoutToggleRequestId ,
719+ layoutToggleScrollTop ,
788720 scrollRef ,
789721 scrollViewport . top ,
790722 sectionGeometry ,
@@ -794,6 +726,26 @@ export function DiffPane({
794726 wrapToggleScrollTop ,
795727 ] ) ;
796728
729+ useLayoutEffect ( ( ) => {
730+ if ( files . length === 0 ) {
731+ lastViewportRowAnchorRef . current = null ;
732+ return ;
733+ }
734+
735+ const currentScrollTop = scrollRef . current ?. scrollTop ?? scrollViewport . top ;
736+ const nextAnchor = findViewportRowAnchor (
737+ files ,
738+ sectionGeometry ,
739+ currentScrollTop ,
740+ sectionHeaderHeights ,
741+ lastViewportRowAnchorRef . current ?. stableKey ,
742+ ) ;
743+
744+ if ( nextAnchor ) {
745+ lastViewportRowAnchorRef . current = nextAnchor ;
746+ }
747+ } , [ files , scrollRef , scrollViewport . top , sectionGeometry , sectionHeaderHeights ] ) ;
748+
797749 useLayoutEffect ( ( ) => {
798750 if ( previousSelectedFileTopAlignRequestIdRef . current === selectedFileTopAlignRequestId ) {
799751 return ;
0 commit comments