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 }; +}