@@ -11,7 +11,11 @@ import {
1111import type { DiffFile , LayoutMode } from "../../../core/types" ;
1212import type { VisibleAgentNote } from "../../lib/agentAnnotations" ;
1313import { computeHunkRevealScrollTop } from "../../lib/hunkScroll" ;
14- import { measureDiffSectionMetrics } from "../../lib/sectionHeights" ;
14+ import {
15+ measureDiffSectionMetrics ,
16+ type DiffSectionMetrics ,
17+ type DiffSectionRowMetric ,
18+ } from "../../lib/sectionHeights" ;
1519import { diffHunkId , diffSectionId } from "../../lib/ids" ;
1620import type { AppTheme } from "../../themes" ;
1721import { DiffSection } from "./DiffSection" ;
@@ -20,6 +24,101 @@ import { VerticalScrollbar, type VerticalScrollbarHandle } from "../scrollbar/Ve
2024
2125const EMPTY_VISIBLE_AGENT_NOTES : VisibleAgentNote [ ] = [ ] ;
2226
27+ /** Identify the rendered diff row that currently owns the top of the viewport. */
28+ interface ViewportRowAnchor {
29+ fileId : string ;
30+ rowKey : string ;
31+ rowOffsetWithin : number ;
32+ }
33+
34+ /** Find the rendered row metric covering a vertical offset within one file body. */
35+ function binarySearchRowMetric ( rowMetrics : DiffSectionRowMetric [ ] , relativeTop : number ) {
36+ let low = 0 ;
37+ let high = rowMetrics . length - 1 ;
38+
39+ while ( low <= high ) {
40+ const mid = ( low + high ) >>> 1 ;
41+ const rowMetric = rowMetrics [ mid ] ! ;
42+
43+ if ( relativeTop < rowMetric . offset ) {
44+ high = mid - 1 ;
45+ } else if ( relativeTop >= rowMetric . offset + rowMetric . height ) {
46+ low = mid + 1 ;
47+ } else {
48+ return rowMetric ;
49+ }
50+ }
51+
52+ return undefined ;
53+ }
54+
55+ /** Capture a stable top-row anchor from the pre-toggle layout so it can be restored later. */
56+ function findViewportRowAnchor (
57+ files : DiffFile [ ] ,
58+ sectionMetrics : DiffSectionMetrics [ ] ,
59+ scrollTop : number ,
60+ ) {
61+ let offsetY = 0 ;
62+
63+ for ( let index = 0 ; index < files . length ; index += 1 ) {
64+ if ( index > 0 ) {
65+ offsetY += 1 ;
66+ }
67+
68+ offsetY += 1 ;
69+ const bodyTop = offsetY ;
70+ const metrics = sectionMetrics [ index ] ;
71+ const bodyHeight = metrics ?. bodyHeight ?? 0 ;
72+ const relativeTop = scrollTop - bodyTop ;
73+
74+ if ( relativeTop >= 0 && relativeTop < bodyHeight && metrics ) {
75+ const rowMetric = binarySearchRowMetric ( metrics . rowMetrics , relativeTop ) ;
76+ if ( rowMetric ) {
77+ return {
78+ fileId : files [ index ] ! . id ,
79+ rowKey : rowMetric . key ,
80+ rowOffsetWithin : relativeTop - rowMetric . offset ,
81+ } satisfies ViewportRowAnchor ;
82+ }
83+ }
84+
85+ offsetY = bodyTop + bodyHeight ;
86+ }
87+
88+ return null ;
89+ }
90+
91+ /** Resolve a captured row anchor into its new scrollTop after wrapping/layout metrics change. */
92+ function resolveViewportRowAnchorTop (
93+ files : DiffFile [ ] ,
94+ sectionMetrics : DiffSectionMetrics [ ] ,
95+ anchor : ViewportRowAnchor ,
96+ ) {
97+ let offsetY = 0 ;
98+
99+ for ( let index = 0 ; index < files . length ; index += 1 ) {
100+ if ( index > 0 ) {
101+ offsetY += 1 ;
102+ }
103+
104+ offsetY += 1 ;
105+ const bodyTop = offsetY ;
106+ const file = files [ index ] ;
107+ const metrics = sectionMetrics [ index ] ;
108+ if ( file ?. id === anchor . fileId && metrics ) {
109+ const rowMetric = metrics . rowMetricsByKey . get ( anchor . rowKey ) ;
110+ if ( rowMetric ) {
111+ return bodyTop + rowMetric . offset + Math . min ( anchor . rowOffsetWithin , rowMetric . height - 1 ) ;
112+ }
113+ return bodyTop ;
114+ }
115+
116+ offsetY = bodyTop + ( metrics ?. bodyHeight ?? 0 ) ;
117+ }
118+
119+ return 0 ;
120+ }
121+
23122/** Render the main multi-file review stream. */
24123export function DiffPane ( {
25124 diffContentWidth,
@@ -36,6 +135,7 @@ export function DiffPane({
36135 showLineNumbers,
37136 showHunkHeaders,
38137 wrapLines,
138+ wrapToggleScrollTop,
39139 theme,
40140 width,
41141 onOpenAgentNotesAtHunk,
@@ -55,6 +155,7 @@ export function DiffPane({
55155 showLineNumbers : boolean ;
56156 showHunkHeaders : boolean ;
57157 wrapLines : boolean ;
158+ wrapToggleScrollTop : number | null ;
58159 theme : AppTheme ;
59160 width : number ;
60161 onOpenAgentNotesAtHunk : ( fileId : string , hunkIndex : number ) => void ;
@@ -132,6 +233,10 @@ export function DiffPane({
132233 const [ scrollViewport , setScrollViewport ] = useState ( { top : 0 , height : 0 } ) ;
133234 const scrollbarRef = useRef < VerticalScrollbarHandle > ( null ) ;
134235 const prevScrollTopRef = useRef ( 0 ) ;
236+ const previousSectionMetricsRef = useRef < DiffSectionMetrics [ ] | null > ( null ) ;
237+ const previousFilesRef = useRef < DiffFile [ ] > ( files ) ;
238+ const previousWrapLinesRef = useRef ( wrapLines ) ;
239+ const suppressNextSelectionAutoScrollRef = useRef ( false ) ;
135240
136241 useEffect ( ( ) => {
137242 const updateViewport = ( ) => {
@@ -157,8 +262,20 @@ export function DiffPane({
157262 } , [ scrollRef ] ) ;
158263
159264 const baseSectionMetrics = useMemo (
160- ( ) => files . map ( ( file ) => measureDiffSectionMetrics ( file , layout , showHunkHeaders , theme ) ) ,
161- [ files , layout , showHunkHeaders , theme ] ,
265+ ( ) =>
266+ files . map ( ( file ) =>
267+ measureDiffSectionMetrics (
268+ file ,
269+ layout ,
270+ showHunkHeaders ,
271+ theme ,
272+ EMPTY_VISIBLE_AGENT_NOTES ,
273+ diffContentWidth ,
274+ showLineNumbers ,
275+ wrapLines ,
276+ ) ,
277+ ) ,
278+ [ diffContentWidth , files , layout , showHunkHeaders , showLineNumbers , theme , wrapLines ] ,
162279 ) ;
163280 const baseEstimatedBodyHeights = useMemo (
164281 ( ) => baseSectionMetrics . map ( ( metrics ) => metrics . bodyHeight ) ,
@@ -226,6 +343,8 @@ export function DiffPane({
226343 theme ,
227344 visibleNotes ,
228345 diffContentWidth ,
346+ showLineNumbers ,
347+ wrapLines ,
229348 ) ;
230349 } ) ,
231350 [
@@ -234,8 +353,10 @@ export function DiffPane({
234353 files ,
235354 layout ,
236355 showHunkHeaders ,
356+ showLineNumbers ,
237357 theme ,
238358 visibleAgentNotesByFile ,
359+ wrapLines ,
239360 ] ,
240361 ) ;
241362 const estimatedBodyHeights = useMemo (
@@ -325,6 +446,57 @@ export function DiffPane({
325446 const prevSelectedAnchorIdRef = useRef < string | null > ( null ) ;
326447
327448 useLayoutEffect ( ( ) => {
449+ const wrapChanged = previousWrapLinesRef . current !== wrapLines ;
450+ const previousSectionMetrics = previousSectionMetricsRef . current ;
451+ const previousFiles = previousFilesRef . current ;
452+
453+ if ( wrapChanged && previousSectionMetrics && previousFiles . length > 0 ) {
454+ const previousScrollTop =
455+ // Prefer the synchronously captured pre-toggle position so anchor restoration does not
456+ // race the polling-based viewport snapshot.
457+ wrapToggleScrollTop != null
458+ ? wrapToggleScrollTop
459+ : Math . max ( prevScrollTopRef . current , scrollViewport . top ) ;
460+ const anchor = findViewportRowAnchor (
461+ previousFiles ,
462+ previousSectionMetrics ,
463+ previousScrollTop ,
464+ ) ;
465+ if ( anchor ) {
466+ const nextTop = resolveViewportRowAnchorTop ( files , sectionMetrics , anchor ) ;
467+ const restoreViewportAnchor = ( ) => {
468+ scrollRef . current ?. scrollTo ( nextTop ) ;
469+ } ;
470+
471+ restoreViewportAnchor ( ) ;
472+ // The wrap-toggle anchor restore should win over the usual selection-following behavior.
473+ suppressNextSelectionAutoScrollRef . current = true ;
474+ // Retry across a couple of repaint cycles so the restored top-row anchor sticks
475+ // after wrapped row heights and viewport culling settle.
476+ const retryDelays = [ 0 , 16 , 48 ] ;
477+ const timeouts = retryDelays . map ( ( delay ) => setTimeout ( restoreViewportAnchor , delay ) ) ;
478+
479+ previousWrapLinesRef . current = wrapLines ;
480+ previousSectionMetricsRef . current = sectionMetrics ;
481+ previousFilesRef . current = files ;
482+
483+ return ( ) => {
484+ timeouts . forEach ( ( timeout ) => clearTimeout ( timeout ) ) ;
485+ } ;
486+ }
487+ }
488+
489+ previousWrapLinesRef . current = wrapLines ;
490+ previousSectionMetricsRef . current = sectionMetrics ;
491+ previousFilesRef . current = files ;
492+ } , [ files , scrollRef , scrollViewport . top , sectionMetrics , wrapLines , wrapToggleScrollTop ] ) ;
493+
494+ useLayoutEffect ( ( ) => {
495+ if ( suppressNextSelectionAutoScrollRef . current ) {
496+ suppressNextSelectionAutoScrollRef . current = false ;
497+ return ;
498+ }
499+
328500 if ( ! selectedAnchorId && ! selectedEstimatedHunkBounds ) {
329501 prevSelectedAnchorIdRef . current = null ;
330502 return ;
@@ -384,7 +556,8 @@ export function DiffPane({
384556 }
385557 } ;
386558
387- // Run after this pane renders the selected section/hunk, then retry briefly while layout settles.
559+ // Run after this pane renders the selected section/hunk, then retry briefly while layout
560+ // settles across a couple of repaint cycles.
388561 scrollSelectionIntoView ( ) ;
389562 const retryDelays = [ 0 , 16 , 48 ] ;
390563 const timeouts = retryDelays . map ( ( delay ) => setTimeout ( scrollSelectionIntoView , delay ) ) ;
@@ -429,7 +602,12 @@ export function DiffPane({
429602 verticalScrollbarOptions = { { visible : false } }
430603 horizontalScrollbarOptions = { { visible : false } }
431604 >
432- < box style = { { width : "100%" , flexDirection : "column" , overflow : "visible" } } >
605+ < box
606+ // Remount the diff content when width/layout/wrap mode changes so viewport culling
607+ // recomputes against the new row geometry, while the outer scrollbox keeps its state.
608+ key = { `diff-content:${ layout } :${ wrapLines ? "wrap" : "nowrap" } :${ width } ` }
609+ style = { { width : "100%" , flexDirection : "column" , overflow : "visible" } }
610+ >
433611 { files . map ( ( file , index ) => {
434612 const shouldRenderSection = visibleWindowedFileIds ?. has ( file . id ) ?? true ;
435613 const shouldPrefetchVisibleHighlight =
0 commit comments