Skip to content

Commit 27dcccb

Browse files
authored
fix(ui): preserve viewport position when switching layouts (#185)
1 parent a9f69df commit 27dcccb

File tree

8 files changed

+590
-100
lines changed

8 files changed

+590
-100
lines changed

src/ui/App.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export function App({
9999
const sidebarScrollRef = useRef<ScrollBoxRenderable | null>(null);
100100
const diffScrollRef = useRef<ScrollBoxRenderable | null>(null);
101101
const wrapToggleScrollTopRef = useRef<number | null>(null);
102+
const layoutToggleScrollTopRef = useRef<number | null>(null);
103+
const [layoutToggleRequestId, setLayoutToggleRequestId] = useState(0);
102104
const [layoutMode, setLayoutMode] = useState<LayoutMode>(bootstrap.initialMode);
103105
const [themeId, setThemeId] = useState(
104106
() => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id,
@@ -275,6 +277,13 @@ export function App({
275277
[maxCodeHorizontalOffset, wrapLines],
276278
);
277279

280+
/** Preserve the current review position before changing the active diff layout. */
281+
const selectLayoutMode = useCallback((mode: LayoutMode) => {
282+
layoutToggleScrollTopRef.current = diffScrollRef.current?.scrollTop ?? 0;
283+
setLayoutToggleRequestId((current) => current + 1);
284+
setLayoutMode(mode);
285+
}, []);
286+
278287
/** Toggle the global agent note layer on or off. */
279288
const toggleAgentNotes = () => {
280289
setShowAgentNotes((current) => !current);
@@ -472,7 +481,7 @@ export function App({
472481
moveToHunk: review.moveToHunk,
473482
refreshCurrentInput: triggerRefreshCurrentInput,
474483
requestQuit,
475-
selectLayoutMode: setLayoutMode,
484+
selectLayoutMode,
476485
selectThemeId: setThemeId,
477486
showAgentNotes,
478487
showHelp,
@@ -497,6 +506,7 @@ export function App({
497506
moveToAnnotatedHunk,
498507
requestQuit,
499508
review.moveToHunk,
509+
selectLayoutMode,
500510
triggerRefreshCurrentInput,
501511
showAgentNotes,
502512
showHelp,
@@ -547,7 +557,7 @@ export function App({
547557
requestQuit,
548558
scrollCodeHorizontally,
549559
scrollDiff,
550-
selectLayoutMode: setLayoutMode,
560+
selectLayoutMode,
551561
showHelp,
552562
switchMenu,
553563
toggleAgentNotes,
@@ -707,6 +717,8 @@ export function App({
707717
showHunkHeaders={showHunkHeaders}
708718
wrapLines={wrapLines}
709719
wrapToggleScrollTop={wrapToggleScrollTopRef.current}
720+
layoutToggleScrollTop={layoutToggleScrollTopRef.current}
721+
layoutToggleRequestId={layoutToggleRequestId}
710722
selectedFileTopAlignRequestId={review.selectedFileTopAlignRequestId}
711723
selectedHunkRevealRequestId={review.selectedHunkRevealRequestId}
712724
theme={activeTheme}

src/ui/AppHost.interactions.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,10 @@ function firstVisibleAddedLine(frame: string) {
364364
return frame.match(/line\d{2} = 1\d{2}/)?.[0] ?? null;
365365
}
366366

367+
function firstVisibleSourceLineNumber(frame: string) {
368+
return frame.match(/line(\d{2}) =/)?.[1] ?? null;
369+
}
370+
367371
function firstVisibleAddedLineNumber(frame: string) {
368372
return frame.match(/\s*(\d+)\s+\+/)?.[1] ?? null;
369373
}
@@ -1244,6 +1248,67 @@ describe("App interactions", () => {
12441248
}
12451249
});
12461250

1251+
test("layout toggles preserve the current viewport anchor across split and stack", async () => {
1252+
const setup = await testRender(<AppHost bootstrap={createLineScrollBootstrap()} />, {
1253+
width: 220,
1254+
height: 12,
1255+
});
1256+
1257+
try {
1258+
await flush(setup);
1259+
1260+
let frame = setup.captureCharFrame();
1261+
expect(frame).toContain("line01 = 101");
1262+
expect(frame).not.toContain("line08 = 108");
1263+
1264+
for (let index = 0; index < 24; index += 1) {
1265+
await act(async () => {
1266+
await setup.mockInput.pressArrow("down");
1267+
});
1268+
await flush(setup);
1269+
frame = setup.captureCharFrame();
1270+
if (frame.includes("line08 = 108") && !frame.includes("line01 = 101")) {
1271+
break;
1272+
}
1273+
}
1274+
1275+
expect(frame).toContain("line08 = 108");
1276+
expect(frame).not.toContain("line01 = 101");
1277+
const anchoredLineNumber = firstVisibleSourceLineNumber(frame);
1278+
expect(anchoredLineNumber).not.toBeNull();
1279+
1280+
await act(async () => {
1281+
await setup.mockInput.typeText("2");
1282+
});
1283+
await flush(setup);
1284+
await act(async () => {
1285+
await Bun.sleep(80);
1286+
await setup.renderOnce();
1287+
});
1288+
1289+
frame = setup.captureCharFrame();
1290+
expect(frame).toContain(`line${anchoredLineNumber} =`);
1291+
expect(firstVisibleSourceLineNumber(frame)).toBe(anchoredLineNumber);
1292+
1293+
await act(async () => {
1294+
await setup.mockInput.typeText("1");
1295+
});
1296+
await flush(setup);
1297+
await act(async () => {
1298+
await Bun.sleep(80);
1299+
await setup.renderOnce();
1300+
});
1301+
1302+
frame = setup.captureCharFrame();
1303+
expect(frame).toContain(`line${anchoredLineNumber} =`);
1304+
expect(firstVisibleSourceLineNumber(frame)).toBe(anchoredLineNumber);
1305+
} finally {
1306+
await act(async () => {
1307+
setup.renderer.destroy();
1308+
});
1309+
}
1310+
});
1311+
12471312
test("Space scrolls down by viewport", async () => {
12481313
// Create a file with many lines so Space has room to scroll
12491314
const before =

src/ui/components/panes/DiffPane.tsx

Lines changed: 50 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import { computeHunkRevealScrollTop } from "../../lib/hunkScroll";
1515
import {
1616
measureDiffSectionGeometry,
1717
type DiffSectionGeometry,
18-
type DiffSectionRowBounds,
1918
} from "../../lib/diffSectionGeometry";
2019
import {
2120
buildFileSectionLayouts,
@@ -27,6 +26,11 @@ import {
2726
} from "../../lib/fileSectionLayout";
2827
import { diffHunkId, diffSectionId } from "../../lib/ids";
2928
import { findViewportCenteredHunkTarget } from "../../lib/viewportSelection";
29+
import {
30+
findViewportRowAnchor,
31+
resolveViewportRowAnchorTop,
32+
type ViewportRowAnchor,
33+
} from "../../lib/viewportAnchor";
3034
import type { AppTheme } from "../../themes";
3135
import { DiffSection } from "./DiffSection";
3236
import { DiffFileHeaderRow } from "./DiffFileHeaderRow";
@@ -36,99 +40,6 @@ import { prefetchHighlightedDiff } from "../../diff/useHighlightedDiff";
3640

3741
const 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. */
13344
function 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

Comments
 (0)