feat: preserve scroll position per file in editor (#349)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Brennan Benson 2026-04-06 22:14:12 -07:00 committed by GitHub
parent 499bde7b2e
commit 001846ccc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 660 additions and 95 deletions

View File

@ -0,0 +1,213 @@
# 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<string, number>` with LRU eviction, following the proven pattern from `CombinedDiffViewer` (lines 3958). 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<K, V>(map: Map<K, V>, 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<string, number>()
```
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<typeof setTimeout> | 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<typeof setTimeout> | 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 353388) 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 `<EditorContent>` 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

View File

@ -7,6 +7,7 @@ import React, { useState, useEffect, useCallback, useRef, useLayoutEffect } from
import type { editor as monacoEditor } from 'monaco-editor'
import { useAppStore } from '@/store'
import { joinPath } from '@/lib/path'
import { setWithLRU } from '@/lib/scroll-cache'
import '@/lib/monaco-setup'
import { Button } from '@/components/ui/button'
import type { OpenFile } from '@/store/slices/editor'
@ -39,24 +40,6 @@ type CachedCombinedDiffViewState = {
const combinedDiffViewStateCache = new Map<string, CachedCombinedDiffViewState>()
const combinedDiffScrollTopCache = new Map<string, number>()
// Why: Module-scoped Maps grow unboundedly as unique file.ids 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).
const CACHE_MAX_ENTRIES = 20
function setWithLRU<K, V>(map: Map<K, V>, 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) {
// The first key in the Map is the oldest entry.
const oldestKey = map.keys().next().value
if (oldestKey !== undefined) {
map.delete(oldestKey)
}
}
}
export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)

View File

@ -70,7 +70,12 @@ export function EditorContent({
activeFile.diffSource === 'combined-branch')
const renderMonacoEditor = (fc: FileContent): React.JSX.Element => (
// Why: Without a key, React reuses the same MonacoEditor instance when
// switching tabs, just updating props. That means useLayoutEffect cleanup
// (which snapshots scroll position) never fires. Keying on activeFile.id
// forces unmount/remount so the scroll cache captures the outgoing position.
<MonacoEditor
key={activeFile.id}
filePath={activeFile.filePath}
relativePath={activeFile.relativePath}
content={editBuffers[activeFile.id] ?? fc.content}
@ -103,7 +108,9 @@ export function EditorContent({
if (renderMode === 'rich-editor') {
return (
// Why: same remount reasoning as MonacoEditor — see renderMonacoEditor.
<RichMarkdownEditor
key={activeFile.id}
content={currentContent}
filePath={activeFile.filePath}
onContentChange={handleContentChange}
@ -123,7 +130,11 @@ export function EditorContent({
to that renderer preserves readable preview mode instead of forcing the
user out of preview entirely. Source mode remains available for edits. */}
<div className="min-h-0 flex-1">
<MarkdownPreview content={currentContent} filePath={activeFile.filePath} />
<MarkdownPreview
key={activeFile.id}
content={currentContent}
filePath={activeFile.filePath}
/>
</div>
</div>
)
@ -169,7 +180,7 @@ export function EditorContent({
}
if (isCombinedDiff) {
return <CombinedDiffViewer file={activeFile} />
return <CombinedDiffViewer key={activeFile.id} file={activeFile} />
}
if (activeFile.mode === 'edit') {

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkFrontmatter from 'remark-frontmatter'
@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useAppStore } from '@/store'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import { getMarkdownPreviewLinkTarget } from './markdown-preview-links'
import { useLocalImageSrc } from './useLocalImageSrc'
import {
@ -42,6 +43,79 @@ export default function MarkdownPreview({
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Why: Each markdown viewing mode (source/rich/preview) produces different
// DOM structures and content heights. A scroll position saved in source mode
// has no meaningful correspondence in preview mode. Using mode-scoped keys
// means each mode remembers its own position independently.
const scrollCacheKey = `${filePath}:preview`
// Save scroll position with trailing throttle and synchronous unmount snapshot.
useLayoutEffect(() => {
const container = rootRef.current
if (!container) {
return
}
let throttleTimer: ReturnType<typeof setTimeout> | 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 with RAF retry loop for async react-markdown content.
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)
// Why: content is included so the restore loop re-triggers when markdown
// content arrives or changes (e.g., async file load), since scrollHeight
// depends on rendered content and may not be large enough until then.
}, [scrollCacheKey, content])
const moveToMatch = useCallback((direction: 1 | -1) => {
const matches = matchesRef.current
if (matches.length === 0) {

View File

@ -1,4 +1,4 @@
import React, { useRef, useCallback, useEffect, useState } from 'react'
import React, { useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'
import Editor, { type OnMount } from '@monaco-editor/react'
import type { editor } from 'monaco-editor'
import { Copy, ExternalLink } from 'lucide-react'
@ -9,6 +9,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '@/store'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import '@/lib/monaco-setup'
import { setupContextualCopy } from './setup-contextual-copy'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
@ -39,6 +40,11 @@ export default function MonacoEditor({
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const copyToastTimeoutRef = useRef<number | null>(null)
const copyHintIntervalRef = useRef<number | null>(null)
// Why: The scroll throttle timer must be accessible from useLayoutEffect cleanup
// so we can cancel any pending write before synchronously snapshotting the final
// scroll position on unmount. Without this, a pending timer could fire after
// cleanup and overwrite the correct value with a stale one.
const scrollThrottleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const propsRef = useRef({ relativePath, language, onSave })
useEffect(() => {
@ -96,6 +102,20 @@ export default function MonacoEditor({
setEditorCursorLine(filePath, e.position.lineNumber)
})
// 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.
editorInstance.onDidScrollChange((e) => {
if (scrollThrottleTimerRef.current !== null) {
clearTimeout(scrollThrottleTimerRef.current)
}
scrollThrottleTimerRef.current = setTimeout(() => {
setWithLRU(scrollTopCache, filePath, e.scrollTop)
scrollThrottleTimerRef.current = null
}, 150)
})
// Intercept right-click on line number gutter to show Radix context menu
// (same approach as VSCode: custom menu instead of Monaco's built-in one)
editorInstance.onMouseDown((e) => {
@ -122,7 +142,20 @@ export default function MonacoEditor({
performReveal(editorInstance, reveal.line, reveal.column, reveal.matchLength)
useAppStore.getState().setPendingEditorReveal(null)
} else {
editorInstance.focus()
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.
// Focus is deferred into the same RAF to avoid a one-frame flash where
// the editor is focused at scroll position 0 before restoration.
requestAnimationFrame(() => {
editorInstance.setScrollTop(savedScrollTop)
editorInstance.focus()
})
} else {
editorInstance.focus()
}
}
},
[copyShortcutLabel, filePath, setEditorCursorLine]
@ -137,6 +170,26 @@ export default function MonacoEditor({
[onContentChange]
)
// Snapshot scroll position synchronously on unmount so tab switches always
// capture the latest value, even if the trailing throttle hasn't fired yet.
// Why useLayoutEffect: cleanup runs before @monaco-editor/react's useEffect
// disposes the editor instance, guaranteeing getScrollTop() reads valid state.
useLayoutEffect(() => {
return () => {
// Why: Cancel any pending throttled scroll write so it cannot fire after
// this synchronous snapshot, which would overwrite the correct final
// position with a stale intermediate value.
if (scrollThrottleTimerRef.current !== null) {
clearTimeout(scrollThrottleTimerRef.current)
scrollThrottleTimerRef.current = null
}
const ed = editorRef.current
if (ed) {
setWithLRU(scrollTopCache, filePath, ed.getScrollTop())
}
}
}, [filePath])
// Update editor options when settings change
useEffect(() => {
if (!editorRef.current || !settings) {

View File

@ -1,17 +1,18 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import { ImageIcon, List, ListOrdered, Quote } from 'lucide-react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import { RichMarkdownToolbarButton } from './RichMarkdownToolbarButton'
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
import { extractIpcErrorMessage, getImageCopyDestination } from './rich-markdown-image-utils'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { slashCommands } from './rich-markdown-commands'
import type { SlashCommand } from './rich-markdown-commands'
import { runSlashCommand, slashCommands, syncSlashMenu } from './rich-markdown-commands'
import type { SlashCommand, SlashMenuState } from './rich-markdown-commands'
import { RichMarkdownSearchBar } from './RichMarkdownSearchBar'
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
@ -22,14 +23,6 @@ type RichMarkdownEditorProps = {
onSave: (content: string) => void
}
type SlashMenuState = {
query: string
from: number
to: number
left: number
top: number
}
const richMarkdownExtensions = createRichMarkdownExtensions({
includePlaceholder: true
})
@ -42,6 +35,7 @@ export default function RichMarkdownEditor({
}: RichMarkdownEditorProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement | null>(null)
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const [slashMenu, setSlashMenu] = useState<SlashMenuState | null>(null)
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0)
const isMac = navigator.userAgent.includes('Mac')
@ -157,6 +151,77 @@ export default function RichMarkdownEditor({
useEffect(() => {
editorRef.current = editor ?? null
}, [editor])
const scrollCacheKey = `${filePath}:rich`
// Save scroll position with trailing throttle and synchronous unmount snapshot.
useLayoutEffect(() => {
const container = scrollContainerRef.current
if (!container) {
return
}
let throttleTimer: ReturnType<typeof setTimeout> | 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 with RAF retry loop for async Tiptap content.
useLayoutEffect(() => {
const container = scrollContainerRef.current
const targetScrollTop = scrollTopCache.get(scrollCacheKey)
if (!container || targetScrollTop === undefined) {
return
}
let frameId = 0
let attempts = 0
// Why: Tiptap renders asynchronously as it hydrates its ProseMirror document,
// so scrollHeight may be undersized on the initial 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)
// Why: `editor` is included so the effect re-runs when the Tiptap editor
// instance becomes available (non-null). With `immediatelyRender: false`,
// editor is null on the first render, so the retry loop would start before
// content is mounted and exhaust its 30 frames before Tiptap hydrates.
}, [scrollCacheKey, editor])
// Why: the custom Image extension reads filePath from editor.storage to resolve
// relative image src values to file:// URLs for display. After updating the
// stored path we dispatch a no-op transaction so ProseMirror re-renders image
@ -337,7 +402,9 @@ export default function RichMarkdownEditor({
query={searchQuery}
searchInputRef={searchInputRef}
/>
<EditorContent editor={editor} className="min-h-0 flex-1 overflow-auto" />
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto">
<EditorContent editor={editor} />
</div>
{slashMenu && filteredSlashCommands.length > 0 ? (
<div
className="rich-markdown-slash-menu"
@ -377,63 +444,3 @@ export default function RichMarkdownEditor({
</div>
)
}
function syncSlashMenu(
editor: Editor,
root: HTMLDivElement | null,
setSlashMenu: React.Dispatch<React.SetStateAction<SlashMenuState | null>>
): void {
if (!root || editor.view.composing || !editor.isEditable) {
setSlashMenu(null)
return
}
const { state, view } = editor
const { selection } = state
if (!selection.empty) {
setSlashMenu(null)
return
}
const { $from } = selection
if (!$from.parent.isTextblock) {
setSlashMenu(null)
return
}
const blockTextBeforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
const slashMatch = blockTextBeforeCursor.match(/^\s*\/([a-z0-9-]*)$/i)
if (!slashMatch) {
setSlashMenu(null)
return
}
const slashOffset = blockTextBeforeCursor.lastIndexOf('/')
const start = selection.from - ($from.parentOffset - slashOffset)
const coords = view.coordsAtPos(selection.from)
const rect = root.getBoundingClientRect()
setSlashMenu({
query: slashMatch[1] ?? '',
from: start,
to: selection.from,
left: coords.left - rect.left,
top: coords.bottom - rect.top + 8
})
}
function runSlashCommand(
editor: Editor,
slashMenu: SlashMenuState,
command: SlashCommand,
onImageCommand?: () => void
): void {
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run()
// Why: image insertion cannot rely on window.prompt() in Electron, so this
// command is rerouted into the editor's local image picker flow.
if (command.id === 'image' && onImageCommand) {
onImageCommand()
return
}
command.run(editor)
}

View File

@ -2,6 +2,14 @@ import React from 'react'
import type { Editor } from '@tiptap/react'
import { Heading1, Heading2, Heading3, ImageIcon, List, ListOrdered, Quote } from 'lucide-react'
export type SlashMenuState = {
query: string
from: number
to: number
left: number
top: number
}
export type SlashCommandId =
| 'text'
| 'heading-1'
@ -24,6 +32,27 @@ export type SlashCommand = {
run: (editor: Editor) => void
}
/**
* Executes a slash command by first deleting the typed slash text, then
* delegating to the command's run method. Image is special-cased because
* window.prompt() is not supported in Electron's renderer process.
*/
export function runSlashCommand(
editor: Editor,
slashMenu: { from: number; to: number },
command: SlashCommand,
onImageCommand?: () => void
): void {
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run()
// Why: image insertion cannot rely on window.prompt() in Electron, so this
// command is rerouted into the editor's local image picker flow.
if (command.id === 'image' && onImageCommand) {
onImageCommand()
return
}
command.run(editor)
}
export const slashCommands: SlashCommand[] = [
{
id: 'text',
@ -144,3 +173,51 @@ export const slashCommands: SlashCommand[] = [
}
}
]
/**
* Inspects the editor selection to decide whether the slash-command menu
* should be open (and where to position it), or dismissed.
*/
export function syncSlashMenu(
editor: Editor,
root: HTMLDivElement | null,
setSlashMenu: React.Dispatch<React.SetStateAction<SlashMenuState | null>>
): void {
if (!root || editor.view.composing || !editor.isEditable) {
setSlashMenu(null)
return
}
const { state, view } = editor
const { selection } = state
if (!selection.empty) {
setSlashMenu(null)
return
}
const { $from } = selection
if (!$from.parent.isTextblock) {
setSlashMenu(null)
return
}
const blockTextBeforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
const slashMatch = blockTextBeforeCursor.match(/^\s*\/([a-z0-9-]*)$/i)
if (!slashMatch) {
setSlashMenu(null)
return
}
const slashOffset = blockTextBeforeCursor.lastIndexOf('/')
const start = selection.from - ($from.parentOffset - slashOffset)
const coords = view.coordsAtPos(selection.from)
const rect = root.getBoundingClientRect()
setSlashMenu({
query: slashMatch[1] ?? '',
from: start,
to: selection.from,
left: coords.left - rect.left,
top: coords.bottom - rect.top + 8
})
}

View File

@ -0,0 +1,112 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { setWithLRU, scrollTopCache } from './scroll-cache'
beforeEach(() => {
scrollTopCache.clear()
})
describe('setWithLRU', () => {
it('inserts a new entry into the map', () => {
const map = new Map<string, number>()
setWithLRU(map, 'a', 1)
expect(map.get('a')).toBe(1)
expect(map.size).toBe(1)
})
it('updates an existing entry', () => {
const map = new Map<string, number>()
setWithLRU(map, 'a', 1)
setWithLRU(map, 'a', 2)
expect(map.get('a')).toBe(2)
expect(map.size).toBe(1)
})
it('evicts the oldest entry when exceeding the default limit', () => {
const map = new Map<string, number>()
for (let i = 0; i <= 20; i++) {
setWithLRU(map, `key-${i}`, i)
}
// 21 inserts with default limit of 20 → oldest (key-0) evicted
expect(map.size).toBe(20)
expect(map.has('key-0')).toBe(false)
expect(map.has('key-1')).toBe(true)
expect(map.has('key-20')).toBe(true)
})
it('evicts the oldest entry when exceeding a custom limit', () => {
const map = new Map<string, number>()
setWithLRU(map, 'a', 1, 3)
setWithLRU(map, 'b', 2, 3)
setWithLRU(map, 'c', 3, 3)
setWithLRU(map, 'd', 4, 3)
expect(map.size).toBe(3)
expect(map.has('a')).toBe(false)
expect(map.get('d')).toBe(4)
})
it('refreshes insertion order when updating an existing key', () => {
const map = new Map<string, number>()
setWithLRU(map, 'a', 1, 3)
setWithLRU(map, 'b', 2, 3)
setWithLRU(map, 'c', 3, 3)
// Touch 'a' to move it to the end
setWithLRU(map, 'a', 10, 3)
// Now 'b' is the oldest — inserting 'd' should evict 'b', not 'a'
setWithLRU(map, 'd', 4, 3)
expect(map.has('a')).toBe(true)
expect(map.has('b')).toBe(false)
expect(map.has('c')).toBe(true)
expect(map.has('d')).toBe(true)
expect(map.get('a')).toBe(10)
})
it('does not evict when at exactly the limit', () => {
const map = new Map<string, number>()
setWithLRU(map, 'a', 1, 3)
setWithLRU(map, 'b', 2, 3)
setWithLRU(map, 'c', 3, 3)
expect(map.size).toBe(3)
expect(map.has('a')).toBe(true)
})
it('works with a limit of 1', () => {
const map = new Map<string, number>()
setWithLRU(map, 'a', 1, 1)
expect(map.size).toBe(1)
setWithLRU(map, 'b', 2, 1)
expect(map.size).toBe(1)
expect(map.has('a')).toBe(false)
expect(map.has('b')).toBe(true)
})
it('evicts only one entry per insert even when far over limit', () => {
const map = new Map<string, number>()
// Pre-fill with 5 entries
for (let i = 0; i < 5; i++) {
map.set(`key-${i}`, i)
}
// Insert with a limit of 3 — only evicts one, leaving 5 entries
// (LRU eviction is per-insert, not bulk)
setWithLRU(map, 'new', 99, 3)
expect(map.size).toBe(5)
expect(map.has('key-0')).toBe(false)
expect(map.has('new')).toBe(true)
})
})
describe('scrollTopCache', () => {
it('is an empty Map on import', () => {
expect(scrollTopCache).toBeInstanceOf(Map)
expect(scrollTopCache.size).toBe(0)
})
it('works with setWithLRU for mode-scoped keys', () => {
setWithLRU(scrollTopCache, '/path/to/file.ts', 100)
setWithLRU(scrollTopCache, '/path/to/file.ts:preview', 200)
setWithLRU(scrollTopCache, '/path/to/file.ts:rich', 300)
expect(scrollTopCache.get('/path/to/file.ts')).toBe(100)
expect(scrollTopCache.get('/path/to/file.ts:preview')).toBe(200)
expect(scrollTopCache.get('/path/to/file.ts:rich')).toBe(300)
expect(scrollTopCache.size).toBe(3)
})
})

View File

@ -0,0 +1,35 @@
// Why: 20 entries covers a typical working set of open/recently-viewed files.
// Eviction only means losing a scroll position (user sees top of file), not a
// correctness bug, so a conservative cap is fine.
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).
// `maxEntries` is optional so consumers with their own Maps (like
// CombinedDiffViewer) can use different limits.
export function setWithLRU<K, V>(
map: Map<K, V>,
key: K,
value: V,
maxEntries: number = CACHE_MAX_ENTRIES
): void {
// Re-insert to refresh insertion order (move to end).
map.delete(key)
map.set(key, value)
if (map.size > maxEntries) {
// Why: Use the iterator's `.done` property rather than checking
// `value !== undefined`, because K could legitimately be `undefined`
// in a generic Map — the undefined check would skip valid evictions.
const first = map.keys().next()
if (!first.done) {
map.delete(first.value)
}
}
}
// 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<string, number>()