@@ -34,6 +34,7 @@ export function ThingsDiff(targetTab) {
3434 let lastLeftJson : any = null ;
3535 let lastRightJson : any = null ;
3636 let syncing = false ;
37+ let scrollSyncing = false ;
3738 let fetchToken = 0 ;
3839 let activeProbe : { cancel : ( ) => void } | null = null ;
3940 let overviewSvg : SVGSVGElement | null = null ;
@@ -176,6 +177,7 @@ export function ThingsDiff(targetTab) {
176177 diffInstance . destroy ( ) ;
177178 diffInstance = null ;
178179 }
180+ scrollSyncing = false ;
179181 overviewSvg = null ;
180182 if ( dom . diffContainer ) {
181183 dom . diffContainer . textContent = '' ;
@@ -373,8 +375,12 @@ export function ThingsDiff(targetTab) {
373375 function createOrUpdateDiff ( leftContent : string , rightContent : string ) {
374376 if ( diffInstance ) {
375377 const editors = diffInstance . getEditors ( ) ;
378+ scrollSyncing = true ;
376379 editors . left . setValue ( leftContent , - 1 ) ;
377380 editors . right . setValue ( rightContent , - 1 ) ;
381+ editors . left . getSession ( ) . setScrollTop ( 0 ) ;
382+ editors . right . getSession ( ) . setScrollTop ( 0 ) ;
383+ scrollSyncing = false ;
378384 diffInstance . diff ( ) ;
379385 renderChangeOverview ( diffInstance . diffs ) ;
380386 } else {
@@ -384,7 +390,7 @@ export function ThingsDiff(targetTab) {
384390 mode : 'ace/mode/json' ,
385391 theme : null ,
386392 diffGranularity : 'specific' ,
387- lockScrolling : true ,
393+ lockScrolling : false ,
388394 showDiffs : true ,
389395 showConnectors : true ,
390396 charDiffs : true ,
@@ -401,9 +407,48 @@ export function ThingsDiff(targetTab) {
401407 } ,
402408 onDiffReady : renderChangeOverview ,
403409 } ) ;
410+ setupScrollSync ( ) ;
404411 }
405412 }
406413
414+ /**
415+ * Sets up proportional scroll synchronization between the two diff editors.
416+ * AceDiff's built-in lockScrolling uses a throttled (16ms) handler with a
417+ * synchronous re-entrancy guard, which allows a reverse-sync feedback loop
418+ * because the guard resets before the throttled reverse handler fires.
419+ * Our implementation uses direct (un-throttled) handlers, so the guard is
420+ * still active when setScrollTop fires the reverse changeScrollTop event
421+ * synchronously, reliably blocking the feedback loop.
422+ */
423+ function setupScrollSync ( ) {
424+ if ( ! diffInstance ) return ;
425+ const editors = diffInstance . getEditors ( ) ;
426+
427+ const sync = ( source , target ) => {
428+ if ( scrollSyncing ) return ;
429+ scrollSyncing = true ;
430+
431+ const srcSession = source . getSession ( ) ;
432+ const tgtSession = target . getSession ( ) ;
433+ const srcTop = srcSession . getScrollTop ( ) ;
434+ const lineHeight = source . renderer . lineHeight || 16 ;
435+ const srcTotal = srcSession . getLength ( ) * lineHeight ;
436+ const srcVisible = source . renderer . $size . scrollerHeight ;
437+ const srcMax = Math . max ( 0 , srcTotal - srcVisible ) ;
438+ const ratio = srcMax > 0 ? srcTop / srcMax : 0 ;
439+
440+ const tgtTotal = tgtSession . getLength ( ) * lineHeight ;
441+ const tgtVisible = target . renderer . $size . scrollerHeight ;
442+ const tgtMax = Math . max ( 0 , tgtTotal - tgtVisible ) ;
443+
444+ tgtSession . setScrollTop ( Math . round ( ratio * tgtMax ) ) ;
445+ scrollSyncing = false ;
446+ } ;
447+
448+ editors . left . getSession ( ) . on ( 'changeScrollTop' , ( ) => sync ( editors . left , editors . right ) ) ;
449+ editors . right . getSession ( ) . on ( 'changeScrollTop' , ( ) => sync ( editors . right , editors . left ) ) ;
450+ }
451+
407452 /**
408453 * Renders a change overview minimap in the ace-diff gutter.
409454 * Each diff region is shown as a colored rectangle positioned proportionally
0 commit comments