From 114c0d1b69918e4845268a23585dc614e7ccf4e2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:23:08 -0700 Subject: [PATCH] feat: preserve scroll, cursor, and undo across tab switches (#421) --- .../src/components/editor/DiffViewer.tsx | 51 ++++++++++++++++--- .../src/components/editor/EditorContent.tsx | 2 + .../src/components/editor/EditorPanel.tsx | 51 ++++++++++++++++++- .../src/components/editor/MarkdownPreview.tsx | 8 ++- .../src/components/editor/MonacoEditor.tsx | 29 +++++++++-- .../editor/useEditorScrollRestore.ts | 10 +++- src/renderer/src/lib/scroll-cache.test.ts | 24 ++++++++- src/renderer/src/lib/scroll-cache.ts | 12 +++++ 8 files changed, 171 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index 93c90840380..094a74c436a 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -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(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 (
@@ -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, diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index e5d9e2a620d..23b3c79fe91 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -306,6 +306,8 @@ export function EditorContent({ } return (