Skip to content

Commit c64797d

Browse files
authored
Fix wrap toggle redraw and preserve viewport anchor (#110)
1 parent 3d8fcb9 commit c64797d

File tree

7 files changed

+656
-48
lines changed

7 files changed

+656
-48
lines changed

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ CLI input
4646
- Prefer one implementation path per feature instead of separate "old" and "new" codepaths that duplicate behavior.
4747
- When refactoring logic that spans helpers and UI components, add tests at the level where the user-visible behavior actually lives, not only at the lowest helper layer.
4848

49+
## code comments
50+
51+
- Add short JSDoc-style comments to functions and helpers.
52+
- Add inline comments for intent, invariants, or tricky behavior that would not be obvious to a fresh reader.
53+
- Skip comments that only narrate what the code already says.
54+
4955
## review behavior
5056

5157
- Default behavior is a multi-file review stream in sidebar order.

src/ui/App.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ function AppShell({
107107
const terminal = useTerminalDimensions();
108108
const filesScrollRef = useRef<ScrollBoxRenderable | null>(null);
109109
const diffScrollRef = useRef<ScrollBoxRenderable | null>(null);
110+
const wrapToggleScrollTopRef = useRef<number | null>(null);
110111
const [layoutMode, setLayoutMode] = useState<LayoutMode>(bootstrap.initialMode);
111112
const [themeId, setThemeId] = useState(
112113
() => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id,
@@ -232,9 +233,10 @@ function AppShell({
232233
}, [maxFilesPaneWidth, showFilesPane]);
233234

234235
useEffect(() => {
235-
// Force an intermediate redraw when the shell geometry changes so pane relayout feels immediate.
236+
// Force an intermediate redraw when shell geometry or row-wrapping changes so pane relayout
237+
// feels immediate after toggling split/stack or line wrapping.
236238
renderer.intermediateRender();
237-
}, [renderer, resolvedLayout, showFilesPane, terminal.height, terminal.width]);
239+
}, [renderer, resolvedLayout, showFilesPane, terminal.height, terminal.width, wrapLines]);
238240

239241
useEffect(() => {
240242
if (!selectedFile && filteredFiles[0]) {
@@ -335,6 +337,9 @@ function AppShell({
335337

336338
/** Toggle whether diff code rows wrap instead of truncating to one terminal row. */
337339
const toggleLineWrap = () => {
340+
// Capture the pre-toggle viewport position synchronously so DiffPane can restore the same
341+
// top-most source row after wrapped row heights change.
342+
wrapToggleScrollTopRef.current = diffScrollRef.current?.scrollTop ?? 0;
338343
setWrapLines((current) => !current);
339344
};
340345

@@ -662,6 +667,11 @@ function AppShell({
662667
return;
663668
}
664669

670+
if (key.name === "w" || key.sequence === "w") {
671+
toggleLineWrap();
672+
return;
673+
}
674+
665675
return;
666676
}
667677

@@ -949,6 +959,7 @@ function AppShell({
949959
showLineNumbers={showLineNumbers}
950960
showHunkHeaders={showHunkHeaders}
951961
wrapLines={wrapLines}
962+
wrapToggleScrollTop={wrapToggleScrollTopRef.current}
952963
theme={activeTheme}
953964
width={diffPaneWidth}
954965
onOpenAgentNotesAtHunk={openAgentNotesAtHunk}

src/ui/components/panes/DiffPane.tsx

Lines changed: 183 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import {
1111
import type { DiffFile, LayoutMode } from "../../../core/types";
1212
import type { VisibleAgentNote } from "../../lib/agentAnnotations";
1313
import { computeHunkRevealScrollTop } from "../../lib/hunkScroll";
14-
import { measureDiffSectionMetrics } from "../../lib/sectionHeights";
14+
import {
15+
measureDiffSectionMetrics,
16+
type DiffSectionMetrics,
17+
type DiffSectionRowMetric,
18+
} from "../../lib/sectionHeights";
1519
import { diffHunkId, diffSectionId } from "../../lib/ids";
1620
import type { AppTheme } from "../../themes";
1721
import { DiffSection } from "./DiffSection";
@@ -20,6 +24,101 @@ import { VerticalScrollbar, type VerticalScrollbarHandle } from "../scrollbar/Ve
2024

2125
const 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. */
24123
export 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

Comments
 (0)