@@ -10,7 +10,7 @@ import {
1010} from "react" ;
1111import type { DiffFile , LayoutMode } from "../../../core/types" ;
1212import type { VisibleAgentNote } from "../../lib/agentAnnotations" ;
13- import { measureDiffSectionMetrics } from "../../lib/sectionHeights" ;
13+ import { measureDiffSectionMetrics , type DiffSectionMetrics } from "../../lib/sectionHeights" ;
1414import { diffHunkId , diffSectionId } from "../../lib/ids" ;
1515import type { AppTheme } from "../../themes" ;
1616import { DiffSection } from "./DiffSection" ;
@@ -19,6 +19,80 @@ import { VerticalScrollbar, type VerticalScrollbarHandle } from "../scrollbar/Ve
1919
2020const EMPTY_VISIBLE_AGENT_NOTES : VisibleAgentNote [ ] = [ ] ;
2121
22+ interface ViewportRowAnchor {
23+ fileId : string ;
24+ rowKey : string ;
25+ rowOffsetWithin : number ;
26+ }
27+
28+ function findViewportRowAnchor (
29+ files : DiffFile [ ] ,
30+ sectionMetrics : DiffSectionMetrics [ ] ,
31+ scrollTop : number ,
32+ ) {
33+ let offsetY = 0 ;
34+
35+ for ( let index = 0 ; index < files . length ; index += 1 ) {
36+ if ( index > 0 ) {
37+ offsetY += 1 ;
38+ }
39+
40+ offsetY += 1 ;
41+ const bodyTop = offsetY ;
42+ const metrics = sectionMetrics [ index ] ;
43+ const bodyHeight = metrics ?. bodyHeight ?? 0 ;
44+ const relativeTop = scrollTop - bodyTop ;
45+
46+ if ( relativeTop >= 0 && relativeTop < bodyHeight && metrics ) {
47+ const rowMetric = metrics . rowMetrics . find (
48+ ( candidate ) =>
49+ relativeTop >= candidate . offset && relativeTop < candidate . offset + candidate . height ,
50+ ) ;
51+ if ( rowMetric ) {
52+ return {
53+ fileId : files [ index ] ! . id ,
54+ rowKey : rowMetric . key ,
55+ rowOffsetWithin : relativeTop - rowMetric . offset ,
56+ } satisfies ViewportRowAnchor ;
57+ }
58+ }
59+
60+ offsetY = bodyTop + bodyHeight ;
61+ }
62+
63+ return null ;
64+ }
65+
66+ function resolveViewportRowAnchorTop (
67+ files : DiffFile [ ] ,
68+ sectionMetrics : DiffSectionMetrics [ ] ,
69+ anchor : ViewportRowAnchor ,
70+ ) {
71+ let offsetY = 0 ;
72+
73+ for ( let index = 0 ; index < files . length ; index += 1 ) {
74+ if ( index > 0 ) {
75+ offsetY += 1 ;
76+ }
77+
78+ offsetY += 1 ;
79+ const bodyTop = offsetY ;
80+ const file = files [ index ] ;
81+ const metrics = sectionMetrics [ index ] ;
82+ if ( file ?. id === anchor . fileId && metrics ) {
83+ const rowMetric = metrics . rowMetricsByKey . get ( anchor . rowKey ) ;
84+ if ( rowMetric ) {
85+ return bodyTop + rowMetric . offset + Math . min ( anchor . rowOffsetWithin , rowMetric . height - 1 ) ;
86+ }
87+ return bodyTop ;
88+ }
89+
90+ offsetY = bodyTop + ( metrics ?. bodyHeight ?? 0 ) ;
91+ }
92+
93+ return 0 ;
94+ }
95+
2296/** Render the main multi-file review stream. */
2397export function DiffPane ( {
2498 diffContentWidth,
@@ -35,6 +109,7 @@ export function DiffPane({
35109 showLineNumbers,
36110 showHunkHeaders,
37111 wrapLines,
112+ wrapToggleScrollTop,
38113 theme,
39114 width,
40115 onOpenAgentNotesAtHunk,
@@ -54,6 +129,7 @@ export function DiffPane({
54129 showLineNumbers : boolean ;
55130 showHunkHeaders : boolean ;
56131 wrapLines : boolean ;
132+ wrapToggleScrollTop : number | null ;
57133 theme : AppTheme ;
58134 width : number ;
59135 onOpenAgentNotesAtHunk : ( fileId : string , hunkIndex : number ) => void ;
@@ -131,6 +207,10 @@ export function DiffPane({
131207 const [ scrollViewport , setScrollViewport ] = useState ( { top : 0 , height : 0 } ) ;
132208 const scrollbarRef = useRef < VerticalScrollbarHandle > ( null ) ;
133209 const prevScrollTopRef = useRef ( 0 ) ;
210+ const previousSectionMetricsRef = useRef < DiffSectionMetrics [ ] | null > ( null ) ;
211+ const previousFilesRef = useRef < DiffFile [ ] > ( files ) ;
212+ const previousWrapLinesRef = useRef ( wrapLines ) ;
213+ const suppressNextSelectionAutoScrollRef = useRef ( false ) ;
134214
135215 useEffect ( ( ) => {
136216 const updateViewport = ( ) => {
@@ -156,8 +236,20 @@ export function DiffPane({
156236 } , [ scrollRef ] ) ;
157237
158238 const baseSectionMetrics = useMemo (
159- ( ) => files . map ( ( file ) => measureDiffSectionMetrics ( file , layout , showHunkHeaders , theme ) ) ,
160- [ files , layout , showHunkHeaders , theme ] ,
239+ ( ) =>
240+ files . map ( ( file ) =>
241+ measureDiffSectionMetrics (
242+ file ,
243+ layout ,
244+ showHunkHeaders ,
245+ theme ,
246+ EMPTY_VISIBLE_AGENT_NOTES ,
247+ diffContentWidth ,
248+ showLineNumbers ,
249+ wrapLines ,
250+ ) ,
251+ ) ,
252+ [ diffContentWidth , files , layout , showHunkHeaders , showLineNumbers , theme , wrapLines ] ,
161253 ) ;
162254 const baseEstimatedBodyHeights = useMemo (
163255 ( ) => baseSectionMetrics . map ( ( metrics ) => metrics . bodyHeight ) ,
@@ -218,6 +310,8 @@ export function DiffPane({
218310 theme ,
219311 visibleNotes ,
220312 diffContentWidth ,
313+ showLineNumbers ,
314+ wrapLines ,
221315 ) ;
222316 } ) ,
223317 [
@@ -226,8 +320,10 @@ export function DiffPane({
226320 files ,
227321 layout ,
228322 showHunkHeaders ,
323+ showLineNumbers ,
229324 theme ,
230325 visibleAgentNotesByFile ,
326+ wrapLines ,
231327 ] ,
232328 ) ;
233329 const estimatedBodyHeights = useMemo (
@@ -306,6 +402,53 @@ export function DiffPane({
306402 } , [ estimatedBodyHeights , sectionMetrics , selectedFile , selectedFileIndex , selectedHunkIndex ] ) ;
307403
308404 useLayoutEffect ( ( ) => {
405+ const wrapChanged = previousWrapLinesRef . current !== wrapLines ;
406+ const previousSectionMetrics = previousSectionMetricsRef . current ;
407+ const previousFiles = previousFilesRef . current ;
408+
409+ if ( wrapChanged && previousSectionMetrics && previousFiles . length > 0 ) {
410+ const previousScrollTop = Math . max (
411+ wrapToggleScrollTop ?? 0 ,
412+ prevScrollTopRef . current ,
413+ scrollViewport . top ,
414+ ) ;
415+ const anchor = findViewportRowAnchor (
416+ previousFiles ,
417+ previousSectionMetrics ,
418+ previousScrollTop ,
419+ ) ;
420+ if ( anchor ) {
421+ const nextTop = resolveViewportRowAnchorTop ( files , sectionMetrics , anchor ) ;
422+ const restoreViewportAnchor = ( ) => {
423+ scrollRef . current ?. scrollTo ( nextTop ) ;
424+ } ;
425+
426+ restoreViewportAnchor ( ) ;
427+ suppressNextSelectionAutoScrollRef . current = true ;
428+ const retryDelays = [ 0 , 16 , 48 ] ;
429+ const timeouts = retryDelays . map ( ( delay ) => setTimeout ( restoreViewportAnchor , delay ) ) ;
430+
431+ previousWrapLinesRef . current = wrapLines ;
432+ previousSectionMetricsRef . current = sectionMetrics ;
433+ previousFilesRef . current = files ;
434+
435+ return ( ) => {
436+ timeouts . forEach ( ( timeout ) => clearTimeout ( timeout ) ) ;
437+ } ;
438+ }
439+ }
440+
441+ previousWrapLinesRef . current = wrapLines ;
442+ previousSectionMetricsRef . current = sectionMetrics ;
443+ previousFilesRef . current = files ;
444+ } , [ files , scrollRef , scrollViewport . top , sectionMetrics , wrapLines , wrapToggleScrollTop ] ) ;
445+
446+ useLayoutEffect ( ( ) => {
447+ if ( suppressNextSelectionAutoScrollRef . current ) {
448+ suppressNextSelectionAutoScrollRef . current = false ;
449+ return ;
450+ }
451+
309452 if ( ! selectedAnchorId ) {
310453 return ;
311454 }
@@ -340,7 +483,6 @@ export function DiffPane({
340483 selectedAnchorId ,
341484 selectedEstimatedScrollTop ,
342485 visibleAgentNotesByFile . size ,
343- wrapLines ,
344486 ] ) ;
345487
346488 // Configure scroll step size to scroll exactly 1 line per step
@@ -379,7 +521,10 @@ export function DiffPane({
379521 verticalScrollbarOptions = { { visible : false } }
380522 horizontalScrollbarOptions = { { visible : false } }
381523 >
382- < box style = { { width : "100%" , flexDirection : "column" , overflow : "visible" } } >
524+ < box
525+ key = { `diff-content:${ layout } :${ wrapLines ? "wrap" : "nowrap" } :${ width } ` }
526+ style = { { width : "100%" , flexDirection : "column" , overflow : "visible" } }
527+ >
383528 { files . map ( ( file , index ) => {
384529 const shouldRenderSection = visibleWindowedFileIds ?. has ( file . id ) ?? true ;
385530 const shouldPrefetchVisibleHighlight =
0 commit comments