mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat: preserve scroll, cursor, and undo across tab switches (#421)
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import React, { useCallback, useRef } from 'react'
|
||||
import React, { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { useAppStore } from '@/store'
|
||||
import { diffViewStateCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import '@/lib/monaco-setup'
|
||||
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
|
||||
import { useContextualCopySetup } from './useContextualCopySetup'
|
||||
|
||||
type DiffViewerProps = {
|
||||
modelKey: string
|
||||
originalContent: string
|
||||
modifiedContent: string
|
||||
language: string
|
||||
@@ -18,6 +21,7 @@ type DiffViewerProps = {
|
||||
}
|
||||
|
||||
export default function DiffViewer({
|
||||
modelKey,
|
||||
originalContent,
|
||||
modifiedContent,
|
||||
language,
|
||||
@@ -38,6 +42,8 @@ export default function DiffViewer({
|
||||
settings?.theme === 'dark' ||
|
||||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const diffEditorRef = useRef<editor.IStandaloneDiffEditor | null>(null)
|
||||
|
||||
// Keep refs to latest callbacks so the mounted editor always calls current versions
|
||||
const onSaveRef = useRef(onSave)
|
||||
onSaveRef.current = onSave
|
||||
@@ -50,13 +56,23 @@ export default function DiffViewer({
|
||||
propsRef.current = { relativePath, language, onSave }
|
||||
|
||||
const handleMount: DiffOnMount = useCallback(
|
||||
(editor, monaco) => {
|
||||
const originalEditor = editor.getOriginalEditor()
|
||||
const modifiedEditor = editor.getModifiedEditor()
|
||||
(diffEditor, monaco) => {
|
||||
diffEditorRef.current = diffEditor
|
||||
|
||||
const originalEditor = diffEditor.getOriginalEditor()
|
||||
const modifiedEditor = diffEditor.getModifiedEditor()
|
||||
|
||||
setupCopy(originalEditor, monaco, filePath, propsRef)
|
||||
setupCopy(modifiedEditor, monaco, filePath, propsRef)
|
||||
|
||||
// Why: restoring the full diff view state matches VS Code more closely
|
||||
// than replaying scrollTop alone, and avoids divergent cursor/selection
|
||||
// state between the original and modified panes.
|
||||
const savedViewState = diffViewStateCache.get(modelKey)
|
||||
if (savedViewState) {
|
||||
requestAnimationFrame(() => diffEditor.restoreViewState(savedViewState))
|
||||
}
|
||||
|
||||
if (editable) {
|
||||
// Cmd/Ctrl+S to save
|
||||
modifiedEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
@@ -70,12 +86,27 @@ export default function DiffViewer({
|
||||
|
||||
modifiedEditor.focus()
|
||||
} else {
|
||||
editor.focus()
|
||||
diffEditor.focus()
|
||||
}
|
||||
},
|
||||
[editable, setupCopy, filePath]
|
||||
[editable, setupCopy, modelKey, filePath]
|
||||
)
|
||||
|
||||
// Why: VS Code snapshots diff view state on deactivation, not on scroll events.
|
||||
// The useLayoutEffect cleanup fires synchronously before React unmounts the
|
||||
// component on tab switch, which is Orca's equivalent of VS Code's clearInput().
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
const de = diffEditorRef.current
|
||||
if (de) {
|
||||
const currentViewState = de.saveViewState()
|
||||
if (currentViewState) {
|
||||
setWithLRU(diffViewStateCache, modelKey, currentViewState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [modelKey])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0">
|
||||
@@ -86,6 +117,14 @@ export default function DiffViewer({
|
||||
modified={modifiedContent}
|
||||
theme={isDark ? 'vs-dark' : 'vs'}
|
||||
onMount={handleMount}
|
||||
// Why: A single file can have multiple live diff tabs at once
|
||||
// (staged, unstaged, branch compare versions). The kept Monaco models
|
||||
// must therefore key off the tab identity, not the raw file path, or
|
||||
// one diff tab can incorrectly reuse another tab's model contents.
|
||||
originalModelPath={`diff:original:${modelKey}`}
|
||||
modifiedModelPath={`diff:modified:${modelKey}`}
|
||||
keepCurrentOriginalModel
|
||||
keepCurrentModifiedModel
|
||||
options={{
|
||||
readOnly: !editable,
|
||||
originalEditable: false,
|
||||
|
||||
@@ -306,6 +306,8 @@ export function EditorContent({
|
||||
}
|
||||
return (
|
||||
<DiffViewer
|
||||
key={activeFile.id}
|
||||
modelKey={activeFile.id}
|
||||
originalContent={dc.originalContent}
|
||||
modifiedContent={editBuffers[activeFile.id] ?? dc.modifiedContent}
|
||||
language={resolvedLanguage}
|
||||
|
||||
@@ -3,7 +3,8 @@ save/load/render lifecycle for many modes (edit, diff, conflict review), and
|
||||
keeping that UI state together is easier to reason about than scattering it
|
||||
across multiple components. Autosave now lives in a smaller headless controller
|
||||
so hidden editor UI no longer participates in shutdown. */
|
||||
import React, { useCallback, useEffect, useState, Suspense } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState, Suspense } from 'react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { Columns2, FileText, Rows2 } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
@@ -12,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
|
||||
import MarkdownViewToggle from './MarkdownViewToggle'
|
||||
import { EditorContent } from './EditorContent'
|
||||
import { scrollTopCache, cursorPositionCache, diffViewStateCache } from '@/lib/scroll-cache'
|
||||
import type { GitDiffResult } from '../../../../shared/types'
|
||||
import {
|
||||
getOpenFilesForExternalFileChange,
|
||||
@@ -69,9 +71,54 @@ export default function EditorPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const openFilesRef = React.useRef(openFiles)
|
||||
const openFilesRef = useRef(openFiles)
|
||||
openFilesRef.current = openFiles
|
||||
|
||||
// Why: keepCurrentModel / keepCurrent*Model retain Monaco models after unmount
|
||||
// so undo history survives tab switches. When a tab is *closed*, the user has
|
||||
// signalled they're done with the file — dispose the models to reclaim memory
|
||||
// and delete cache entries so a reopened file starts fresh.
|
||||
const prevOpenFilesRef = useRef<Map<string, OpenFile>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
const currentFilesById = new Map(openFiles.map((f) => [f.id, f]))
|
||||
for (const [prevId, prevFile] of prevOpenFilesRef.current) {
|
||||
if (!currentFilesById.has(prevId)) {
|
||||
// Dispose only the kept-alive Monaco state that this tab mode owns.
|
||||
// Why: edit and diff tabs use different retained-model keys, while the
|
||||
// conflict-review surface does not create kept Monaco models today. An
|
||||
// explicit switch makes that ownership boundary visible so future mode
|
||||
// additions do not silently fall through without considering cleanup.
|
||||
switch (prevFile.mode) {
|
||||
case 'edit':
|
||||
// Why: the edit model URI is constructed via monaco.Uri.parse(filePath)
|
||||
// to match what @monaco-editor/react creates internally when the `path`
|
||||
// prop is provided. This convention is version-dependent.
|
||||
monaco.editor.getModel(monaco.Uri.parse(prevFile.filePath))?.dispose()
|
||||
scrollTopCache.delete(prevFile.filePath)
|
||||
// Why: markdown edit tabs cycle through three view modes (source, rich,
|
||||
// preview), each caching scroll under a mode-scoped key. All must be
|
||||
// evicted so a reopened file starts fresh regardless of which mode was
|
||||
// last active.
|
||||
scrollTopCache.delete(`${prevFile.filePath}:rich`)
|
||||
scrollTopCache.delete(`${prevFile.filePath}:preview`)
|
||||
cursorPositionCache.delete(prevFile.filePath)
|
||||
break
|
||||
case 'diff':
|
||||
// Why: kept diff models are keyed by tab id, not file path, because the
|
||||
// same file can appear in multiple diff tabs with different contents.
|
||||
monaco.editor.getModel(monaco.Uri.parse(`diff:original:${prevId}`))?.dispose()
|
||||
monaco.editor.getModel(monaco.Uri.parse(`diff:modified:${prevId}`))?.dispose()
|
||||
diffViewStateCache.delete(prevId)
|
||||
break
|
||||
case 'conflict-review':
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
prevOpenFilesRef.current = currentFilesById
|
||||
}, [openFiles])
|
||||
|
||||
// Load file content when active file changes
|
||||
useEffect(() => {
|
||||
if (!activeFile) {
|
||||
|
||||
@@ -84,8 +84,12 @@ export default function MarkdownPreview({
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
// Snapshot final position synchronously before detach.
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
// Why: During React StrictMode double-mount (or rapid mount/unmount before
|
||||
// react-markdown renders content), scrollHeight equals clientHeight and
|
||||
// scrollTop is 0. Saving that would clobber a valid cached position.
|
||||
if (container.scrollHeight > container.clientHeight || container.scrollTop > 0) {
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
}
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAppStore } from '@/store'
|
||||
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import { scrollTopCache, cursorPositionCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import '@/lib/monaco-setup'
|
||||
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
|
||||
|
||||
@@ -146,6 +146,10 @@ export default function MonacoEditor({
|
||||
}
|
||||
editorInstance.onDidChangeCursorPosition((e) => {
|
||||
setEditorCursorLine(filePath, e.position.lineNumber)
|
||||
setWithLRU(cursorPositionCache, filePath, {
|
||||
lineNumber: e.position.lineNumber,
|
||||
column: e.position.column
|
||||
})
|
||||
})
|
||||
|
||||
// Why: Writing to the Map at 60fps (every scroll frame) is unnecessary since
|
||||
@@ -189,15 +193,21 @@ export default function MonacoEditor({
|
||||
useAppStore.getState().setPendingEditorReveal(null)
|
||||
})
|
||||
} else {
|
||||
const savedCursor = cursorPositionCache.get(filePath)
|
||||
const savedScrollTop = scrollTopCache.get(filePath)
|
||||
if (savedScrollTop !== undefined) {
|
||||
if (savedScrollTop !== undefined || savedCursor) {
|
||||
// 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)
|
||||
if (savedCursor) {
|
||||
editorInstance.setPosition(savedCursor)
|
||||
}
|
||||
if (savedScrollTop !== undefined) {
|
||||
editorInstance.setScrollTop(savedScrollTop)
|
||||
}
|
||||
editorInstance.focus()
|
||||
})
|
||||
} else {
|
||||
@@ -233,6 +243,13 @@ export default function MonacoEditor({
|
||||
const ed = editorRef.current
|
||||
if (ed) {
|
||||
setWithLRU(scrollTopCache, filePath, ed.getScrollTop())
|
||||
const pos = ed.getPosition()
|
||||
if (pos) {
|
||||
setWithLRU(cursorPositionCache, filePath, {
|
||||
lineNumber: pos.lineNumber,
|
||||
column: pos.column
|
||||
})
|
||||
}
|
||||
}
|
||||
cancelScheduledReveal()
|
||||
clearTransientRevealHighlight()
|
||||
@@ -316,6 +333,12 @@ export default function MonacoEditor({
|
||||
}
|
||||
}}
|
||||
path={filePath}
|
||||
// Why: keepCurrentModel preserves the Monaco text model so undo/redo
|
||||
// survives tab switches, but @monaco-editor/react's own view-state Map
|
||||
// would become a second state owner. Orca restores cursor/scroll from
|
||||
// its explicit caches so close/reopen semantics stay under app control.
|
||||
saveViewState={false}
|
||||
keepCurrentModel
|
||||
/>
|
||||
|
||||
{toastNode}
|
||||
|
||||
@@ -32,8 +32,14 @@ export function useEditorScrollRestore(
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
// Snapshot final position synchronously before detach.
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
// Why: During React StrictMode double-mount (or rapid mount/unmount before
|
||||
// Tiptap renders content), the container has zero scrollable height and
|
||||
// scrollTop is 0. Saving that would clobber a valid cached position from
|
||||
// the previous session. Only save when the container was scrollable
|
||||
// (content was rendered) or the user had scrolled.
|
||||
if (container.scrollHeight > container.clientHeight || container.scrollTop > 0) {
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
}
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { setWithLRU, scrollTopCache } from './scroll-cache'
|
||||
import { cursorPositionCache, diffViewStateCache, setWithLRU, scrollTopCache } from './scroll-cache'
|
||||
|
||||
beforeEach(() => {
|
||||
scrollTopCache.clear()
|
||||
cursorPositionCache.clear()
|
||||
diffViewStateCache.clear()
|
||||
})
|
||||
|
||||
describe('setWithLRU', () => {
|
||||
@@ -110,3 +112,23 @@ describe('scrollTopCache', () => {
|
||||
expect(scrollTopCache.size).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffViewStateCache', () => {
|
||||
it('is an empty Map on import', () => {
|
||||
expect(diffViewStateCache).toBeInstanceOf(Map)
|
||||
expect(diffViewStateCache.size).toBe(0)
|
||||
})
|
||||
|
||||
it('works with setWithLRU for diff-tab keys', () => {
|
||||
const diffState = {
|
||||
original: { cursorState: [], viewState: { scrollTop: 10, scrollTopWithoutViewZones: 10, scrollLeft: 0 } },
|
||||
modified: { cursorState: [], viewState: { scrollTop: 20, scrollTopWithoutViewZones: 20, scrollLeft: 0 } },
|
||||
modelState: { unchangedRegions: [] }
|
||||
} as unknown as (typeof diffViewStateCache extends Map<string, infer T> ? T : never)
|
||||
|
||||
setWithLRU(diffViewStateCache, 'diff-tab', diffState)
|
||||
|
||||
expect(diffViewStateCache.get('diff-tab')).toBe(diffState)
|
||||
expect(diffViewStateCache.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
// 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.
|
||||
@@ -33,3 +35,13 @@ export function setWithLRU<K, V>(
|
||||
// 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>()
|
||||
|
||||
// Why: Same rationale as scrollTopCache — module-scoped avoids Zustand
|
||||
// re-renders on every cursor move.
|
||||
export const cursorPositionCache = new Map<string, { lineNumber: number; column: number }>()
|
||||
|
||||
// Why: Diff editors need more than a numeric scroll offset to restore the same
|
||||
// working context. Monaco's diff view state also carries cursor/selection state
|
||||
// for both sides plus diff model state, which matches VS Code's restore path
|
||||
// more closely than Orca's previous scroll-only cache.
|
||||
export const diffViewStateCache = new Map<string, editor.IDiffEditorViewState>()
|
||||
|
||||
Reference in New Issue
Block a user