Fix wrap toggle redraw and preserve viewport anchor#110
Fix wrap toggle redraw and preserve viewport anchor#110benvinegar wants to merge 6 commits intomainfrom
Conversation
Greptile SummaryThis PR fixes two related wrap-toggle bugs — Key changes:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant App as App.tsx
participant DiffPane as DiffPane.tsx
participant ScrollBox
User->>App: press "w"
App->>App: wrapToggleScrollTopRef.current = scrollTop
App->>App: setWrapLines(!current)
App->>DiffPane: re-render with new wrapLines + wrapToggleScrollTop
Note over DiffPane: sectionMetrics recomputed (new wrapLines)
Note over DiffPane: inner <box> key changes → remount → scroll resets
DiffPane->>DiffPane: useLayoutEffect [wrap-anchor] fires
Note over DiffPane: wrapChanged = true
DiffPane->>DiffPane: findViewportRowAnchor(previousFiles, previousSectionMetrics, previousScrollTop)
DiffPane->>DiffPane: resolveViewportRowAnchorTop(files, sectionMetrics, anchor)
DiffPane->>ScrollBox: scrollTo(nextTop)
DiffPane->>DiffPane: suppressNextSelectionAutoScrollRef = true
DiffPane->>DiffPane: schedule retryDelays [0, 16, 48ms] → scrollTo(nextTop)
DiffPane->>DiffPane: useLayoutEffect [selection-scroll] fires
Note over DiffPane: suppress flag is true → return early (NO retries scheduled)
Note over DiffPane: 0 / 16 / 48 ms timeouts fire
DiffPane->>ScrollBox: scrollTo(nextTop) × 3
Note over App: intermediateRender effect fires (wrapLines in deps)
App->>App: renderer.intermediateRender()
Reviews (1): Last reviewed commit: "Fix wrap toggle redraw and viewport anch..." | Re-trigger Greptile |
src/ui/components/panes/DiffPane.tsx
Outdated
| const rowMetric = metrics.rowMetrics.find( | ||
| (candidate) => | ||
| relativeTop >= candidate.offset && relativeTop < candidate.offset + candidate.height, | ||
| ); | ||
| if (rowMetric) { | ||
| return { | ||
| fileId: files[index]!.id, | ||
| rowKey: rowMetric.key, | ||
| rowOffsetWithin: relativeTop - rowMetric.offset, | ||
| } satisfies ViewportRowAnchor; | ||
| } |
There was a problem hiding this comment.
O(n) linear scan on every wrap toggle
rowMetrics.find(...) does a linear scan over all row metrics in the section to locate the anchor. For large diffs with many rows this will be slow. Since rowMetrics is already sorted by offset (rows are accumulated in order), a binary search on candidate.offset would reduce this to O(log n).
| const rowMetric = metrics.rowMetrics.find( | |
| (candidate) => | |
| relativeTop >= candidate.offset && relativeTop < candidate.offset + candidate.height, | |
| ); | |
| if (rowMetric) { | |
| return { | |
| fileId: files[index]!.id, | |
| rowKey: rowMetric.key, | |
| rowOffsetWithin: relativeTop - rowMetric.offset, | |
| } satisfies ViewportRowAnchor; | |
| } | |
| const rowMetric = binarySearchRowMetric(metrics.rowMetrics, relativeTop); |
Where a helper like this could be added near the top of the file:
function binarySearchRowMetric(rowMetrics: DiffSectionRowMetric[], relativeTop: number) {
let lo = 0;
let hi = rowMetrics.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const m = rowMetrics[mid]!;
if (relativeTop < m.offset) {
hi = mid - 1;
} else if (relativeTop >= m.offset + m.height) {
lo = mid + 1;
} else {
return m;
}
}
return undefined;
}There was a problem hiding this comment.
Good call. I switched the anchor lookup to a binary search over the sorted row metrics so wrap toggles stay O(log n) even on larger sections.
Responded by pi using unspecified-model.
| useLayoutEffect(() => { | ||
| const wrapChanged = previousWrapLinesRef.current !== wrapLines; | ||
| const previousSectionMetrics = previousSectionMetricsRef.current; | ||
| const previousFiles = previousFilesRef.current; | ||
|
|
||
| if (wrapChanged && previousSectionMetrics && previousFiles.length > 0) { | ||
| const previousScrollTop = Math.max( | ||
| wrapToggleScrollTop ?? 0, | ||
| prevScrollTopRef.current, | ||
| scrollViewport.top, | ||
| ); | ||
| const anchor = findViewportRowAnchor( | ||
| previousFiles, | ||
| previousSectionMetrics, | ||
| previousScrollTop, | ||
| ); | ||
| if (anchor) { | ||
| const nextTop = resolveViewportRowAnchorTop(files, sectionMetrics, anchor); | ||
| const restoreViewportAnchor = () => { | ||
| scrollRef.current?.scrollTo(nextTop); | ||
| }; | ||
|
|
||
| restoreViewportAnchor(); | ||
| suppressNextSelectionAutoScrollRef.current = true; | ||
| const retryDelays = [0, 16, 48]; | ||
| const timeouts = retryDelays.map((delay) => setTimeout(restoreViewportAnchor, delay)); | ||
|
|
||
| previousWrapLinesRef.current = wrapLines; | ||
| previousSectionMetricsRef.current = sectionMetrics; | ||
| previousFilesRef.current = files; | ||
|
|
||
| return () => { | ||
| timeouts.forEach((timeout) => clearTimeout(timeout)); | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| previousWrapLinesRef.current = wrapLines; | ||
| previousSectionMetricsRef.current = sectionMetrics; | ||
| previousFilesRef.current = files; | ||
| }, [files, scrollRef, scrollViewport.top, sectionMetrics, wrapLines, wrapToggleScrollTop]); |
There was a problem hiding this comment.
Magic retry delays lack explanation
The retryDelays = [0, 16, 48] values are chosen to cover two-three browser/terminal repaint cycles, but there's no comment explaining this. The selection auto-scroll effect below uses the same pattern — a short comment on each would help future readers understand the intent.
| useLayoutEffect(() => { | |
| const wrapChanged = previousWrapLinesRef.current !== wrapLines; | |
| const previousSectionMetrics = previousSectionMetricsRef.current; | |
| const previousFiles = previousFilesRef.current; | |
| if (wrapChanged && previousSectionMetrics && previousFiles.length > 0) { | |
| const previousScrollTop = Math.max( | |
| wrapToggleScrollTop ?? 0, | |
| prevScrollTopRef.current, | |
| scrollViewport.top, | |
| ); | |
| const anchor = findViewportRowAnchor( | |
| previousFiles, | |
| previousSectionMetrics, | |
| previousScrollTop, | |
| ); | |
| if (anchor) { | |
| const nextTop = resolveViewportRowAnchorTop(files, sectionMetrics, anchor); | |
| const restoreViewportAnchor = () => { | |
| scrollRef.current?.scrollTo(nextTop); | |
| }; | |
| restoreViewportAnchor(); | |
| suppressNextSelectionAutoScrollRef.current = true; | |
| const retryDelays = [0, 16, 48]; | |
| const timeouts = retryDelays.map((delay) => setTimeout(restoreViewportAnchor, delay)); | |
| previousWrapLinesRef.current = wrapLines; | |
| previousSectionMetricsRef.current = sectionMetrics; | |
| previousFilesRef.current = files; | |
| return () => { | |
| timeouts.forEach((timeout) => clearTimeout(timeout)); | |
| }; | |
| } | |
| } | |
| previousWrapLinesRef.current = wrapLines; | |
| previousSectionMetricsRef.current = sectionMetrics; | |
| previousFilesRef.current = files; | |
| }, [files, scrollRef, scrollViewport.top, sectionMetrics, wrapLines, wrapToggleScrollTop]); | |
| // Retry across two repaint cycles (~16 ms each) to handle async layout settling | |
| // after the content box is remounted due to the key change. | |
| const retryDelays = [0, 16, 48]; |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Added comments above both retry delay arrays to explain that they intentionally span a couple of repaint cycles while wrapped row heights and viewport culling settle.
Responded by pi using unspecified-model.
src/ui/components/panes/DiffPane.tsx
Outdated
| const previousScrollTop = Math.max( | ||
| wrapToggleScrollTop ?? 0, | ||
| prevScrollTopRef.current, | ||
| scrollViewport.top, | ||
| ); |
There was a problem hiding this comment.
Math.max across three scroll-position sources could silently use a stale value
When wrap is toggled rapidly while the scroll is still moving, prevScrollTopRef.current might lag behind (updated every ~50 ms via setInterval), and scrollViewport.top is the React state value (also 50 ms stale). The wrapToggleScrollTop prop is the freshest source since it's captured synchronously in toggleLineWrap. Taking the max of all three is defensive, but if—for any reason—an older source holds a larger value than the true current position, the wrong anchor row will be selected.
A safer approach would be to prefer wrapToggleScrollTop outright when it is non-null (it's always the most accurate since it's captured at the moment of the keypress):
| const previousScrollTop = Math.max( | |
| wrapToggleScrollTop ?? 0, | |
| prevScrollTopRef.current, | |
| scrollViewport.top, | |
| ); | |
| const previousScrollTop = | |
| wrapToggleScrollTop != null | |
| ? wrapToggleScrollTop | |
| : Math.max(prevScrollTopRef.current, scrollViewport.top); |
There was a problem hiding this comment.
Agreed. I changed the anchor capture to prefer the synchronous wrapToggleScrollTop value when it is present, and only fall back to the polled scroll state otherwise.
Responded by pi using unspecified-model.
9287387 to
0a043b8
Compare
Summary
win pager mode and make wrap toggles repaint immediatelyTesting