Skip to content

Fix wrap toggle redraw and preserve viewport anchor#110

Open
benvinegar wants to merge 6 commits intomainfrom
fix/wrap-toggle-refresh-anchor
Open

Fix wrap toggle redraw and preserve viewport anchor#110
benvinegar wants to merge 6 commits intomainfrom
fix/wrap-toggle-refresh-anchor

Conversation

@benvinegar
Copy link
Member

Summary

  • fix w in pager mode and make wrap toggles repaint immediately
  • preserve the top visible diff row when toggling line wrapping instead of snapping back to the selected hunk/top
  • add app, UI, and TTY smoke coverage for wrap toggling and viewport anchoring

Testing

  • bun run typecheck
  • bun test test/app-interactions.test.tsx
  • bun test test/ui-components.test.tsx
  • HUNK_RUN_TTY_SMOKE=1 bun test test/tty-render-smoke.test.ts

@greptile-apps
Copy link

greptile-apps bot commented Mar 24, 2026

Greptile Summary

This PR fixes two related wrap-toggle bugs — w not being handled in pager mode, and the viewport snapping back to the selected hunk/top instead of preserving the user's scroll position after toggling line wrapping — and wires up an immediate intermediate redraw on wrapLines change so the repaint feels instant.

Key changes:

  • App.tsx: adds wrapToggleScrollTopRef (captures scrollTop synchronously at the moment of keypress), adds wrapLines to the intermediate-render effect dep array, and inserts the missing w handler in the pager-mode key branch.
  • DiffPane.tsx: introduces findViewportRowAnchor / resolveViewportRowAnchorTop helpers that translate a pixel scroll offset into a stable (fileId, rowKey, rowOffsetWithin) anchor, then back to a pixel offset under the new metrics. A new useLayoutEffect fires when wrapLines changes, computes the anchor from the previous metrics (stored in refs), resolves its new position in the current metrics, and calls scrollTo with retry timeouts at 0 / 16 / 48 ms to survive async terminal redraws. A suppressNextSelectionAutoScrollRef one-shot flag prevents the existing selection-scroll effect (and its retries) from racing against the anchor restore.
  • sectionHeights.ts: DiffSectionMetrics gains rowMetrics / rowMetricsByKey so callers can map row keys to pixel offsets in both directions. plannedRowHeight now delegates to measureRenderedRowHeight (accounting for wrapping), and showLineNumbers / wrapLines are included in both the function signature and cache key.
  • Tests: new app-interaction, UI-component, and TTY smoke tests cover pager-mode toggle, on/off/on cycling, and viewport-anchor preservation in both wrap directions.

Confidence Score: 4/5

  • Safe to merge — no critical bugs found; the anchor-restore logic is well-reasoned and the new tests cover all advertised scenarios.
  • The implementation is architecturally sound: the ref-snapshotting approach for previous metrics is correct, the suppressNextSelectionAutoScrollRef early-return correctly prevents retry scheduling (not just the sync call), and the Math.max fallback for stale scroll sources works in practice. Minor style concerns (O(n) linear scan in findViewportRowAnchor, undocumented magic retry delays, and the Math.max preference ordering) prevent a perfect score but none represent correctness issues that would cause regressions.
  • src/ui/components/panes/DiffPane.tsx — the new anchor-restore useLayoutEffect is the most complex addition and warrants extra attention during future changes to the scroll/selection interaction.

Important Files Changed

Filename Overview
src/ui/App.tsx Adds wrapToggleScrollTopRef to capture scroll position at toggle time, wires up wrapLines to the intermediate-render effect for immediate redraws, and adds the missing w shortcut handler in pager mode. All changes are minimal and correct.
src/ui/components/panes/DiffPane.tsx Core of the PR: adds viewport-row anchor types, findViewportRowAnchor/resolveViewportRowAnchorTop helpers, a new useLayoutEffect that restores the viewport position after a wrap toggle, and a suppressNextSelectionAutoScrollRef one-shot flag to prevent the selection scroll from overriding the anchor restore. Logic is correct; minor O(n) linear scan and undocumented magic retry delays noted.
src/ui/lib/sectionHeights.ts Extends DiffSectionMetrics with rowMetrics/rowMetricsByKey, updates plannedRowHeight to delegate to measureRenderedRowHeight (now accounts for wrap), adds showLineNumbers/wrapLines parameters to measureDiffSectionMetrics, and updates the cache key. Changes are correct and consistent.
test/app-interactions.test.tsx Good test coverage: pager-mode w shortcut, toggle on/off/on cycle, and viewport anchor preservation after wrap toggle (both directions). The Bun.sleep(80) correctly waits for all retry timeouts (max 48 ms) to settle before asserting.
test/tty-render-smoke.test.ts Adds createLongWrapFixtureFiles and three new TTY smoke tests covering regular-mode toggle, toggle cycling, and pager-mode toggle. longWrapFixture branching in runTtySmoke is clean.
test/ui-components.test.tsx Adds wrapToggleScrollTop: null to the default DiffPane props and a new split-vs-stack wrap row-count comparison test. Prop addition is a required forward-compatible fix.

Sequence Diagram

sequenceDiagram
    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()
Loading

Reviews (1): Last reviewed commit: "Fix wrap toggle redraw and viewport anch..." | Re-trigger Greptile

Comment on lines +47 to +57
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;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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).

Suggested change
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;
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 404 to +444
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]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +410 to +414
const previousScrollTop = Math.max(
wrapToggleScrollTop ?? 0,
prevScrollTopRef.current,
scrollViewport.top,
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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):

Suggested change
const previousScrollTop = Math.max(
wrapToggleScrollTop ?? 0,
prevScrollTopRef.current,
scrollViewport.top,
);
const previousScrollTop =
wrapToggleScrollTop != null
? wrapToggleScrollTop
: Math.max(prevScrollTopRef.current, scrollViewport.top);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@benvinegar benvinegar force-pushed the fix/wrap-toggle-refresh-anchor branch from 9287387 to 0a043b8 Compare March 24, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant