From b65e04f01ae1b432d5fb7f70eb6825db14dfdbd7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 9 Sep 2026 23:51:37 +0300 Subject: [PATCH] feat: preserve scroll and cursor positions across file preview views Remember scroll positions for code and markdown previews per file, runtime, mode and surface Restore editor cursor and scroll state when returning to previously opened files Wait for async content to render before restoring, and stop on user scroll --- .../ui/src/components/views/FilesView.tsx | 114 ++++++++++-- .../useFilePreviewScrollPosition.test.tsx | 171 ++++++++++++++++++ .../views/useFilePreviewScrollPosition.ts | 122 +++++++++++++ 3 files changed, 391 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/components/views/useFilePreviewScrollPosition.test.tsx create mode 100644 packages/ui/src/components/views/useFilePreviewScrollPosition.ts diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 3dc8ef7b..5d9159e1 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -37,6 +37,7 @@ import { getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry'; import { File as PierreFile, VirtualizerContext, WorkerPoolContext } from '@pierre/diffs/react'; import { useWorkerPool } from '@/contexts/DiffWorkerProvider'; import { useFileViewVirtualizer, type FileViewVirtualizer } from './useFileViewVirtualizer'; +import { useFilePreviewScrollPosition } from './useFilePreviewScrollPosition'; import { Dialog, DialogContent, @@ -692,6 +693,59 @@ interface FilesViewProps { mode?: 'full' | 'editor-only'; } +type FileEditorPosition = { + scroll: ReturnType; + anchor: number; + head: number; +}; + +// Keep only position metadata, not editor instances or file contents. This +// survives FilesView unmounts without retaining every file visited indefinitely. +const fileEditorPositions = new Map(); +const MAX_FILE_EDITOR_POSITIONS = 100; + +const FilePositionEditor = ({ + positionKey, + onViewReady, + ...props +}: React.ComponentProps & { positionKey: string }) => { + const viewRef = React.useRef(null); + + React.useLayoutEffect(() => () => { + const view = viewRef.current; + if (!view) return; + + // Read before React removes the editor DOM and its scroll offsets collapse. + const { anchor, head } = view.state.selection.main; + fileEditorPositions.delete(positionKey); + fileEditorPositions.set(positionKey, { scroll: view.scrollSnapshot(), anchor, head }); + if (fileEditorPositions.size > MAX_FILE_EDITOR_POSITIONS) { + const oldestKey = fileEditorPositions.keys().next().value; + if (oldestKey !== undefined) fileEditorPositions.delete(oldestKey); + } + }, [positionKey]); + + return ( + { + viewRef.current = view; + const position = fileEditorPositions.get(positionKey); + if (position) { + view.dispatch({ + selection: { + anchor: Math.min(position.anchor, view.state.doc.length), + head: Math.min(position.head, view.state.doc.length), + }, + effects: position.scroll, + }); + } + onViewReady?.(view); + }} + /> + ); +}; + /** * Keeps a token-bearing asset preview (image/HTML/PDF) authenticated. While * `assetKey` is set this registers an active url-token consumer (so runtime-auth @@ -922,6 +976,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); const [loadedFilePath, setLoadedFilePath] = React.useState(null); + const filePositionKey = JSON.stringify([getRuntimeKey(), root, loadedFilePath]); const [draftContent, setDraftContent] = React.useState(''); const [isSaving, setIsSaving] = React.useState(false); @@ -3210,6 +3265,34 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const mainViewVirtualizer = useFileViewVirtualizer(); const fullscreenViewVirtualizer = useFileViewVirtualizer(); + const previewReady = !fileLoading && !fileError && loadedFilePath === selectedFilePath; + const codePreviewActive = previewReady && canUseShikiFileView && textViewMode === 'view' + && !(isJson && jsonViewMode === 'tree'); + const markdownPreviewActive = previewReady && isMarkdown && getMdViewMode() === 'preview'; + const { setScroller: setMainCodeScroller, restore: restoreMainCodeScroll } = useFilePreviewScrollPosition(codePreviewActive ? `${filePositionKey}:code` : null); + const { setScroller: setFullscreenCodeScroller, restore: restoreFullscreenCodeScroll } = useFilePreviewScrollPosition(codePreviewActive ? `${filePositionKey}:code:fullscreen` : null); + const { setScroller: setMainMarkdownScroll } = useFilePreviewScrollPosition(markdownPreviewActive ? `${filePositionKey}:markdown` : null); + const { setScroller: setFullscreenMarkdownScroll } = useFilePreviewScrollPosition(markdownPreviewActive ? `${filePositionKey}:markdown:fullscreen` : null); + const { setScroller: connectMainVirtualizer } = mainViewVirtualizer; + const { setScroller: connectFullscreenVirtualizer } = fullscreenViewVirtualizer; + + const setMainPreviewScroller = React.useCallback((node: HTMLElement | null) => { + connectMainVirtualizer(node); + setMainCodeScroller(node); + }, [connectMainVirtualizer, setMainCodeScroller]); + const setFullscreenPreviewScroller = React.useCallback((node: HTMLElement | null) => { + connectFullscreenVirtualizer(node); + setFullscreenCodeScroller(node); + }, [connectFullscreenVirtualizer, setFullscreenCodeScroller]); + const setMainMarkdownScroller = React.useCallback((node: HTMLDivElement | null) => { + markdownPreviewRef.current = node; + mdPreviewContainerRef.current = node; + setMainMarkdownScroll(node); + }, [setMainMarkdownScroll]); + const setFullscreenMarkdownScroller = React.useCallback((node: HTMLDivElement | null) => { + mdFullscreenPreviewContainerRef.current = node; + setFullscreenMarkdownScroll(node); + }, [setFullscreenMarkdownScroll]); const shikiWorkerPool = useWorkerPool('unified'); // Files above the editable size cap are rendered as a read-only preview; give // them the full file content plus pierre's viewport virtualization and the @@ -3220,7 +3303,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { [fileContent, isLargeFile], ); - const renderShikiFileView = React.useCallback((file: FileNode, content: string, virtualizer: FileViewVirtualizer) => { + const renderShikiFileView = React.useCallback((file: FileNode, content: string, virtualizer: FileViewVirtualizer, restoreScroll: ReturnType['restore']) => { const fileContents = { name: file.name, contents: content, @@ -3235,6 +3318,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { overflow: wrapLines ? 'wrap' : 'scroll', theme: pierreTheme, themeType: currentTheme.metadata.variant === 'dark' ? 'dark' : 'light', + onPostRender: restoreScroll, }} className={isLargeFile ? 'block w-full' : 'block h-full w-full'} style={isLargeFile ? undefined : { height: '100%' }} @@ -3883,7 +3967,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
- + {!selectedFile ? (
{t('filesView.editor.pickFileFromTree')}
) : (fileLoading || isPdfAssetAuthLoading) ? ( @@ -3968,10 +4052,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { // plain div never holds focus. -1 keeps it out of the tab order. tabIndex={-1} onMouseDown={focusMdPreviewContainer} - ref={(node) => { - markdownPreviewRef.current = node; - mdPreviewContainerRef.current = node; - }} + ref={setMainMarkdownScroller} > = ({ mode = 'full' }) => {
) ) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? ( - renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer) + renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer, restoreMainCodeScroll) ) : (
- = ({ mode = 'full' }) => {
{renderFloatingFileControls({ exitFullscreenOnly: true })}
- + {(fileLoading || isPdfAssetAuthLoading) ? ( suppressFileLoadingIndicator ?
@@ -4360,14 +4443,11 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { className="oc-file-preview h-full overflow-auto p-4 outline-none" tabIndex={-1} onMouseDown={focusMdPreviewContainer} - ref={(node) => { - markdownPreviewRef.current = node; - mdFullscreenPreviewContainerRef.current = node; - }} + ref={setFullscreenMarkdownScroller} > {selectedFile ? ( @@ -4404,11 +4484,13 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { />
) : canUseShikiFileView && textViewMode === 'view' ? ( - renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer) + renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer, restoreFullscreenCodeScroll) ) : (
- void) => void; +}) { + const { setScroller, restore } = useFilePreviewScrollPosition(positionKey); + useLayoutEffect(() => { + setScroller(element); + return () => setScroller(null); + }, [element, setScroller]); + useLayoutEffect(() => onReady(restore), [onReady, restore]); + return null; +} + +describe('file preview scroll positions', () => { + let windowInstance: Window; + let root: Root; + let scroller: HTMLDivElement; + let content: HTMLDivElement; + let height: number; + let top: number; + let left: number; + let restore: () => void; + let prefix: string; + let sequence = 0; + const onReady = (callback: () => void) => { restore = callback; }; + + beforeEach(() => { + windowInstance = new Window(); + Object.assign(globalThis, { + window: windowInstance, + document: windowInstance.document, + HTMLElement: windowInstance.HTMLElement, + Event: windowInstance.Event, + MutationObserver: windowInstance.MutationObserver, + ResizeObserver: windowInstance.ResizeObserver, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const host = document.createElement('div'); + scroller = document.createElement('div'); + content = document.createElement('div'); + scroller.append(content); + document.body.append(host, scroller); + root = createRoot(host); + height = 2000; + top = 0; + left = 0; + prefix = `preview-test-${sequence++}`; + Object.defineProperties(scroller, { + scrollTop: { + get: () => top, + set: (value: number) => { top = Math.max(0, Math.min(value, height - 100)); }, + }, + scrollLeft: { + get: () => left, + set: (value: number) => { left = Math.max(0, Math.min(value, 500)); }, + }, + }); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + await windowInstance.happyDOM.close(); + }); + + const render = async (key: string | null) => { + await act(async () => { + root.render(); + }); + }; + const scroll = (nextTop: number, nextLeft = 0) => { + scroller.scrollTop = nextTop; + scroller.scrollLeft = nextLeft; + scroller.dispatchEvent(new Event('scroll')); + }; + + test('keeps positions independent across files, runtimes, modes and surfaces', async () => { + const keys = ['runtime-a:file-a:code', 'runtime-a:file-b:code', 'runtime-b:file-a:code', 'runtime-a:file-a:markdown', 'runtime-a:file-a:code:fullscreen']; + for (const [index, key] of keys.entries()) { + await render(key); + expect(top).toBe(0); + scroll((index + 1) * 150, (index + 1) * 20); + } + for (const [index, key] of keys.entries()) { + await render(key); + expect(top).toBe((index + 1) * 150); + expect(left).toBe((index + 1) * 20); + } + }); + + test('ignores scroll collapse during loading and restores after a full view unmount', async () => { + await render('file'); + scroll(900, 120); + await render(null); + scroll(0); + await act(async () => root.render(null)); + await render('file'); + expect(top).toBe(900); + expect(left).toBe(120); + }); + + test('waits for asynchronous code rendering without saving its clamped offset', async () => { + await render('code'); + scroll(1200); + await render(null); + height = 200; + await render('code'); + expect(top).toBe(100); + scroll(100); + height = 2000; + restore(); + expect(top).toBe(1200); + scroll(700); + restore(); + expect(top).toBe(700); + await render(null); + await render('code'); + expect(top).toBe(700); + }); + + test('restores when lazy Markdown content mounts', async () => { + await render('markdown'); + scroll(1000); + await render(null); + height = 100; + await render('markdown'); + expect(top).toBe(0); + height = 2000; + content.append(document.createElement('p')); + await windowInstance.happyDOM.waitUntilComplete(); + expect(top).toBe(1000); + }); + + test('stops pending restoration when the user starts scrolling', async () => { + await render('markdown'); + scroll(1000); + await render(null); + height = 300; + await render('markdown'); + scroller.dispatchEvent(new Event('wheel')); + scroll(80); + height = 2000; + restore(); + expect(top).toBe(80); + await render(null); + await render('markdown'); + expect(top).toBe(80); + }); + + test('disconnects late content callbacks when leaving the file', async () => { + await render('first'); + scroll(1000); + await render(null); + height = 200; + await render('first'); + await render('second'); + scroll(50); + height = 2000; + content.append(document.createElement('p')); + await windowInstance.happyDOM.waitUntilComplete(); + restore(); + expect(top).toBe(50); + }); +}); diff --git a/packages/ui/src/components/views/useFilePreviewScrollPosition.ts b/packages/ui/src/components/views/useFilePreviewScrollPosition.ts new file mode 100644 index 00000000..7b0609bc --- /dev/null +++ b/packages/ui/src/components/views/useFilePreviewScrollPosition.ts @@ -0,0 +1,122 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { VirtualizedFile, type File } from '@pierre/diffs'; + +type PreviewScrollPosition = { + top: number; + left: number; + line?: { number: number; offset: number }; +}; + +// Runtime, directory, file, preview mode and surface are supplied by FilesView. +// Retain coordinates across view unmounts, without retaining DOM or file data. +const positions = new Map(); +const MAX_POSITIONS = 100; + +export function useFilePreviewScrollPosition(positionKey: string | null) { + const [scroller, setScroller] = useState(null); + const restoreRef = useRef<(() => void) | null>(null); + const rememberRef = useRef<(() => void) | null>(null); + const virtualFileRef = useRef<{ key: string | null; file: VirtualizedFile; node: HTMLElement } | null>(null); + const restore = useCallback((node?: HTMLElement, instance?: File) => { + if (node && instance instanceof VirtualizedFile) { + virtualFileRef.current = { key: positionKey, file: instance, node }; + } + restoreRef.current?.(); + // The scroll event can precede the virtualizer mounting the target lines. + // Capture again after rendering, when a numeric line anchor is available. + rememberRef.current?.(); + }, [positionKey]); + + useLayoutEffect(() => { + if (!scroller || !positionKey) return; + + const target: PreviewScrollPosition = positions.get(positionKey) ?? { top: 0, left: 0 }; + let pending = true; + const getVirtualFile = () => virtualFileRef.current?.key === positionKey ? virtualFileRef.current.file : null; + const getLineElement = (number: number) => virtualFileRef.current?.key === positionKey + ? virtualFileRef.current.node.shadowRoot?.querySelector(`[data-line][data-line-index="${number - 1}"]`) + : null; + + const stopObserving = () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + const restorePosition = () => { + if (!pending) return; + const file = getVirtualFile(); + const linePosition = target.line && file?.getLinePosition(target.line.number); + const lineElement = target.line && getLineElement(target.line.number); + // Estimated heights locate the virtual window; the mounted row supplies + // the final visual offset, including wrapping and Pierre's padding. + const targetTop = lineElement && target.line + ? scroller.scrollTop + lineElement.getBoundingClientRect().top - scroller.getBoundingClientRect().top - target.line.offset + : linePosition && target.line + ? (file?.top ?? 0) + linePosition.top - target.line.offset + : target.top; + scroller.scrollTop = targetTop; + scroller.scrollLeft = target.left; + if ((!target.line || lineElement) && Math.abs(scroller.scrollTop - targetTop) < 1 && Math.abs(scroller.scrollLeft - target.left) < 1) { + pending = false; + stopObserving(); + } + }; + // Markdown can mount through Suspense; code may render asynchronously in + // Pierre's worker. Retry only on content/layout changes, until reachable. + const resizeObserver = new ResizeObserver(restorePosition); + const mutationObserver = new MutationObserver(() => { + if (!pending) return; + for (const child of scroller.children) resizeObserver.observe(child); + restorePosition(); + }); + resizeObserver.observe(scroller); + for (const child of scroller.children) resizeObserver.observe(child); + mutationObserver.observe(scroller, { childList: true, subtree: true }); + + const rememberPosition = () => { + if (pending) return; + const file = getVirtualFile(); + const anchor = file?.getNumericScrollAnchor(scroller.scrollTop - (file.top ?? 0)); + const lineElement = anchor && getLineElement(anchor.lineNumber); + positions.delete(positionKey); + positions.set(positionKey, { + top: scroller.scrollTop, + left: scroller.scrollLeft, + line: anchor && lineElement + ? { number: anchor.lineNumber, offset: lineElement.getBoundingClientRect().top - scroller.getBoundingClientRect().top } + : undefined, + }); + if (positions.size > MAX_POSITIONS) { + const oldestKey = positions.keys().next().value; + if (oldestKey !== undefined) positions.delete(oldestKey); + } + }; + const cancelRestoration = () => { + pending = false; + stopObserving(); + }; + + restoreRef.current = restorePosition; + rememberRef.current = rememberPosition; + restorePosition(); + scroller.addEventListener('scroll', rememberPosition, { passive: true }); + scroller.addEventListener('wheel', cancelRestoration, { passive: true }); + scroller.addEventListener('touchstart', cancelRestoration, { passive: true }); + scroller.addEventListener('pointerdown', cancelRestoration, { passive: true }); + scroller.addEventListener('keydown', cancelRestoration); + + return () => { + restoreRef.current = null; + rememberRef.current = null; + if (virtualFileRef.current?.key === positionKey) virtualFileRef.current = null; + stopObserving(); + scroller.removeEventListener('scroll', rememberPosition); + scroller.removeEventListener('wheel', cancelRestoration); + scroller.removeEventListener('touchstart', cancelRestoration); + scroller.removeEventListener('pointerdown', cancelRestoration); + scroller.removeEventListener('keydown', cancelRestoration); + // Keep the last scroll event, not offsets collapsed by DOM teardown. + }; + }, [positionKey, scroller]); + + return { setScroller, restore }; +}