# Design: Preserve Scroll Position Per File in Editor ## Context When navigating between file tabs, the scroll position resets to the top because each editor component unmounts and remounts. The cursor line is already tracked per file via `editorCursorLine` in the store, but scroll position is not. This causes a poor UX when users switch between files frequently. ## Approach Use a module-scoped `Map` with LRU eviction, following the proven pattern from `CombinedDiffViewer` (lines 39–58). The scroll cache is extracted into a shared utility at `src/renderer/src/lib/scroll-cache.ts` so all viewer components share one bounded Map. **Why not Zustand?** Scroll position updates at high frequency during user interaction. Putting it in Zustand means every write spreads a new object (`{ ...s.editorScrollTop, [fileId]: scrollTop }`), which (a) causes React re-render notifications even though no component subscribes to scroll position for rendering, and (b) generates object allocation churn on every scroll event. A module-scoped Map avoids both problems: zero re-renders, zero GC pressure, O(1) reads and writes. CombinedDiffViewer already validates this approach in production. **Why LRU at 20 entries?** Without a cap, the Map grows unboundedly as unique file IDs accumulate across a session. 20 entries covers typical tab working sets with headroom. This matches `CombinedDiffViewer`'s existing cap and means no explicit cleanup is needed when files are closed — eviction is automatic. ## Files to Modify ### 1. Shared Utility: `src/renderer/src/lib/scroll-cache.ts` (new file) Extract `setWithLRU` from `CombinedDiffViewer` into a shared module, and expose a single scroll cache Map: ```ts const CACHE_MAX_ENTRIES = 20 // Why: Module-scoped Maps grow unboundedly as unique file keys accumulate. // Cap them with a simple LRU eviction: after each set, if the map exceeds // this limit, delete the oldest entry (Maps iterate in insertion order). export function setWithLRU(map: Map, key: K, value: V): void { // Re-insert to refresh insertion order (move to end). map.delete(key) map.set(key, value) if (map.size > CACHE_MAX_ENTRIES) { const oldestKey = map.keys().next().value if (oldestKey !== undefined) { map.delete(oldestKey) } } } // Why: A single shared Map for scroll positions across all editor components. // Module-scoped so it survives component unmount/remount without triggering // React re-renders (unlike Zustand, which would broadcast state changes on // every scroll event even though no component renders from scroll position). export const scrollTopCache = new Map() ``` After extracting, update `CombinedDiffViewer` to import `setWithLRU` from this module instead of defining it inline. `CombinedDiffViewer`'s own `combinedDiffViewStateCache` and `combinedDiffScrollTopCache` stay local since their value types are component-specific. --- ### 2. MonacoEditor: `src/renderer/src/components/editor/MonacoEditor.tsx` **Save scroll position:** In `handleMount` (line ~62), add a throttled scroll listener: ```ts import { scrollTopCache, setWithLRU } from '@renderer/lib/scroll-cache' // Why: Writing to the Map at 60fps (every scroll frame) is unnecessary since // we only need the final position when the user stops scrolling or switches // tabs. A trailing throttle of ~150ms captures the resting position while // avoiding excessive writes. let scrollThrottleTimer: ReturnType | null = null editorInstance.onDidScrollChange((e) => { if (scrollThrottleTimer !== null) clearTimeout(scrollThrottleTimer) scrollThrottleTimer = setTimeout(() => { setWithLRU(scrollTopCache, filePath, e.scrollTop) scrollThrottleTimer = null }, 150) }) ``` Also snapshot the current position synchronously in the cleanup/unmount path (before the timeout fires) so tab switches always capture the latest value — same pattern as `CombinedDiffViewer`'s `updateCachedScrollPosition()` call in its cleanup return. **Restore scroll position:** In the `else` branch (line ~118) where there is NO pending reveal: ```ts const savedScrollTop = scrollTopCache.get(filePath) if (savedScrollTop !== undefined) { // Why: Monaco renders synchronously, so a single RAF is sufficient to // wait for the layout pass. Unlike react-markdown or Tiptap, there is // no async content loading that would require a retry loop. requestAnimationFrame(() => editorInstance.setScrollTop(savedScrollTop)) } ``` **Key edge case:** When `pendingEditorReveal` exists (search-result navigation), skip scroll restoration — `performReveal` handles its own scroll. --- ### 3. MarkdownPreview: `src/renderer/src/components/editor/MarkdownPreview.tsx` **Mode-scoped cache key:** ```ts // Why: Each markdown viewing mode (source/rich/preview) produces different // DOM structures and content heights. A scroll position saved in source mode // (a code block at line 500) has no meaningful correspondence in preview mode // (rendered HTML at a completely different height). Using mode-scoped keys // means each mode remembers its own position independently. const scrollCacheKey = `${filePath}:preview` ``` **Save scroll position:** Add a throttled scroll listener on `rootRef.current` (the scrollable div, line 192) via `useLayoutEffect`: ```ts useLayoutEffect(() => { const container = rootRef.current if (!container) return let throttleTimer: ReturnType | null = null const onScroll = (): void => { if (throttleTimer !== null) clearTimeout(throttleTimer) throttleTimer = setTimeout(() => { setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop) throttleTimer = null }, 150) } container.addEventListener('scroll', onScroll, { passive: true }) return () => { // Snapshot final position synchronously before detach. setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop) if (throttleTimer !== null) clearTimeout(throttleTimer) container.removeEventListener('scroll', onScroll) } }, [scrollCacheKey]) ``` **Restore scroll position (RAF retry loop):** react-markdown renders asynchronously — content may not be in the DOM when the layout effect first runs, so `scrollHeight` is still small and `scrollTop` gets clamped to 0. Use `CombinedDiffViewer`'s RAF retry pattern (lines 353–388) to keep attempting until content has loaded: ```ts useLayoutEffect(() => { const container = rootRef.current const targetScrollTop = scrollTopCache.get(scrollCacheKey) if (!container || targetScrollTop === undefined) return let frameId = 0 let attempts = 0 // Why: react-markdown renders asynchronously, so scrollHeight may still be // too small on the first frame. Retry up to 30 frames (~500ms at 60fps) to // accommodate content loading. This matches CombinedDiffViewer's proven // pattern for dynamic-height content restoration. const tryRestore = (): void => { const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight) const nextScrollTop = Math.min(targetScrollTop, maxScrollTop) container.scrollTop = nextScrollTop if (Math.abs(container.scrollTop - targetScrollTop) <= 1 || maxScrollTop >= targetScrollTop) { return } attempts += 1 if (attempts < 30) { frameId = window.requestAnimationFrame(tryRestore) } } tryRestore() return () => window.cancelAnimationFrame(frameId) }, [scrollCacheKey]) ``` --- ### 4. RichMarkdownEditor: `src/renderer/src/components/editor/RichMarkdownEditor.tsx` Same approach as MarkdownPreview with two differences: 1. **Cache key:** `${filePath}:rich` 2. **Scroll container:** The scrollable container is `` with `overflow-auto` (line 334). Wrap in a div with a ref to get the scroll container, move `overflow-auto` to wrapper. The RAF retry loop is needed here too — Tiptap renders asynchronously as it hydrates its ProseMirror document, so `scrollHeight` may be undersized on the initial frame. Save/restore logic is identical to MarkdownPreview, substituting the container ref and cache key. --- ### 5. DiffViewer — Deferred DiffViewer doesn't currently receive `filePath` and diff views are typically opened for brief review. Deferring to avoid scope creep. `CombinedDiffViewer` already has its own scroll cache; if DiffViewer needs one later it can import from `scroll-cache.ts`. ## Edge Cases | Case | Behavior | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Pending reveal (search nav) | Scroll restoration skipped; `performReveal` takes priority | | First open (no saved position) | `undefined` in cache → no restoration, starts at top | | Markdown mode switch (source → preview) | Mode-scoped keys (`path:source`, `path:preview`, `path:rich`) mean each mode preserves its own position independently. No cross-mode confusion. | | Scroll at 0 | Always restore (including 0) to guard against Monaco auto-scroll behavior | | External file content changes | **Known trade-off:** When external changes add/remove content above the viewport, the saved scroll position points to different content. Monaco clamps `scrollTop` if it exceeds the new document length, but does not adjust for insertions above the viewport. HTML containers (`MarkdownPreview`, `RichMarkdownEditor`) behave the same way — the browser clamps `scrollTop` to `scrollHeight - clientHeight`. This is acceptable: scroll position is a best-effort hint, not a semantic anchor. Fixing this would require mapping scroll offsets to content anchors (like line numbers), which is out of scope. | | LRU eviction (>20 files) | Oldest scroll entry is evicted. User sees top-of-file on return to a very old tab — same as a fresh open. No data corruption or memory leak. | | Close file, reopen | LRU may or may not still have the entry. If present, position restores. If evicted, starts at top. No explicit cleanup needed. | | Async content (react-markdown, Tiptap) | RAF retry loop (up to 30 attempts) handles content that renders after the initial layout pass. Falls back to best-effort clamped position if content never reaches the target height. | ## Verification 1. Open a code file, scroll down ~50%, switch to another tab, switch back → verify position preserved 2. Open a markdown file in preview mode, scroll down, switch tabs, return → verify position preserved 3. Open a markdown file in rich mode, scroll down, switch tabs, return → verify position preserved 4. Switch a markdown file from source to preview mode → verify each mode has independent scroll position (scroll in source, switch to preview, preview starts at its own saved position or top) 5. Use Cmd+Shift+F to search, click a result → verify it scrolls to the match (NOT saved position) 6. Open 25+ files to trigger LRU eviction, return to the earliest file → verify it starts at top gracefully 7. `pnpm run typecheck` passes