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
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 23:51:37 +03:00
parent a052941e3d
commit b65e04f01a
3 changed files with 391 additions and 16 deletions
+98 -16
View File
@@ -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<EditorView['scrollSnapshot']>;
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<string, FileEditorPosition>();
const MAX_FILE_EDITOR_POSITIONS = 100;
const FilePositionEditor = ({
positionKey,
onViewReady,
...props
}: React.ComponentProps<typeof CodeMirrorEditor> & { positionKey: string }) => {
const viewRef = React.useRef<EditorView | null>(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 (
<CodeMirrorEditor
{...props}
onViewReady={(view) => {
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<FilesViewProps> = ({ mode = 'full' }) => {
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(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<FilesViewProps> = ({ 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<FilesViewProps> = ({ 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<typeof useFilePreviewScrollPosition>['restore']) => {
const fileContents = {
name: file.name,
contents: content,
@@ -3235,6 +3318,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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<FilesViewProps> = ({ mode = 'full' }) => {
</div>
<div className="flex-1 min-h-0 min-w-0 relative">
<ScrollableOverlay ref={mainViewVirtualizer.setScroller} outerClassName="h-full min-w-0" className={cn('h-full min-w-0', isLargeFile && '[overflow-anchor:none]')}>
<ScrollableOverlay ref={setMainPreviewScroller} outerClassName="h-full min-w-0" className={cn('h-full min-w-0', isLargeFile && '[overflow-anchor:none]')}>
{!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
) : (fileLoading || isPdfAssetAuthLoading) ? (
@@ -3968,10 +4052,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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}
>
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
@@ -4035,14 +4116,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
)
) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? (
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer)
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer, restoreMainCodeScroll)
) : (
<div
className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}
ref={editorWrapperRef}
>
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
<CodeMirrorEditor
<FilePositionEditor
key={filePositionKey}
positionKey={filePositionKey}
value={draftContent}
onChange={setDraftContent}
readOnly={!canEdit}
@@ -4305,7 +4388,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<div className="absolute right-4 top-4 z-30">
{renderFloatingFileControls({ exitFullscreenOnly: true })}
</div>
<ScrollableOverlay ref={fullscreenViewVirtualizer.setScroller} outerClassName="h-full min-w-0" className={cn('h-full min-w-0', isLargeFile && '[overflow-anchor:none]')}>
<ScrollableOverlay ref={setFullscreenPreviewScroller} outerClassName="h-full min-w-0" className={cn('h-full min-w-0', isLargeFile && '[overflow-anchor:none]')}>
{(fileLoading || isPdfAssetAuthLoading) ? (
suppressFileLoadingIndicator
? <div className="p-4" />
@@ -4360,14 +4443,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ 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 ? (
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
containerRef={mdFullscreenPreviewContainerRef}
filePath={selectedFile.path}
fileContent={fileContent}
/>
@@ -4404,11 +4484,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/>
</div>
) : canUseShikiFileView && textViewMode === 'view' ? (
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer)
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer, restoreFullscreenCodeScroll)
) : (
<div className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}>
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
<CodeMirrorEditor
<FilePositionEditor
key={`${filePositionKey}:fullscreen`}
positionKey={`${filePositionKey}:fullscreen`}
value={draftContent}
onChange={setDraftContent}
readOnly={!canEdit}
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import React, { act, useLayoutEffect } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useFilePreviewScrollPosition } from './useFilePreviewScrollPosition';
function Preview({ positionKey, element, onReady }: {
positionKey: string | null;
element: HTMLElement;
onReady: (restore: () => 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(<Preview positionKey={key === null ? null : `${prefix}:${key}`} element={scroller} onReady={onReady} />);
});
};
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);
});
});
@@ -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<string, PreviewScrollPosition>();
const MAX_POSITIONS = 100;
export function useFilePreviewScrollPosition(positionKey: string | null) {
const [scroller, setScroller] = useState<HTMLElement | null>(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<HTMLElement>(`[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 };
}