feat(ui): virtualize large file preview in FilesView
Files above the editable size cap (MAX_VIEW_CHARS) now render their full content through pierre's Virtualizer (viewport-only DOM) with highlighting on the shared Shiki worker pool instead of a 200k-char main-thread-highlighted slice. Also fixes a pierre virtualization deadlock: forcing the virtualized host to viewport height decoupled the IntersectionObserver judgment box from the scroll content height, so a large scroll jump blanked the file (0 lines). The large-file host now keeps no fixed height; the observer always sees it intersecting. Addresses #2868 (part 1: large-file preview virtualization).
This commit is contained in:
@@ -31,7 +31,9 @@ import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/l
|
||||
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||
import { shikiHighlightExtension } from '@/lib/codemirror/shikiHighlight';
|
||||
import { getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry';
|
||||
import { File as PierreFile } from '@pierre/diffs/react';
|
||||
import { File as PierreFile, VirtualizerContext, WorkerPoolContext } from '@pierre/diffs/react';
|
||||
import { useWorkerPool } from '@/contexts/DiffWorkerProvider';
|
||||
import { useFileViewVirtualizer, type FileViewVirtualizer } from './useFileViewVirtualizer';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -311,6 +313,23 @@ const isFileMissingError = (error: unknown): boolean => {
|
||||
const MAX_VIEW_CHARS = 200_000;
|
||||
type FileLineEnding = '\n' | '\r\n';
|
||||
|
||||
// Fast cache key for pierre's line/highlight caches: content-derived (not a
|
||||
// revision counter) so polling reloads and out-of-view changes can never hit
|
||||
// a stale entry. Mirrors the diff viewer's key scheme. Known residual: two
|
||||
// files identical in total length and in the first/last 200 characters can
|
||||
// collide and briefly show stale content; same profile as PierreDiffViewer.
|
||||
function makeContentCacheKey(contents: string): string {
|
||||
const sample = contents.length > 400
|
||||
? `${contents.slice(0, 200)}${contents.slice(-200)}`
|
||||
: contents;
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < sample.length; i += 1) {
|
||||
hash ^= sample.charCodeAt(i);
|
||||
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
|
||||
}
|
||||
return `${contents.length}:${hash.toString(16)}`;
|
||||
}
|
||||
|
||||
const detectFileLineEnding = (content: string): FileLineEnding => {
|
||||
let crlf = 0;
|
||||
let lf = 0;
|
||||
@@ -3163,27 +3182,57 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
});
|
||||
}, [cancel, commentText, deleteDraft, editingDraftId, filesFileDrafts, handleSaveComment, isDragging, lineSelection, selectedFile?.path, setCommentText, startEdit]);
|
||||
|
||||
const renderShikiFileView = React.useCallback((file: FileNode, content: string) => {
|
||||
const mainViewVirtualizer = useFileViewVirtualizer();
|
||||
const fullscreenViewVirtualizer = useFileViewVirtualizer();
|
||||
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
|
||||
// shared Shiki worker pool so large files stay responsive.
|
||||
const isLargeFile = fileContent.length > MAX_VIEW_CHARS;
|
||||
const largeFileCacheKey = React.useMemo(
|
||||
() => (isLargeFile ? makeContentCacheKey(fileContent) : undefined),
|
||||
[fileContent, isLargeFile],
|
||||
);
|
||||
|
||||
const renderShikiFileView = React.useCallback((file: FileNode, content: string, virtualizer: FileViewVirtualizer) => {
|
||||
const fileContents = {
|
||||
name: file.name,
|
||||
contents: content,
|
||||
lang: getLanguageFromExtension(file.path) || undefined,
|
||||
};
|
||||
const pierreFile = (key: string) => (
|
||||
<PierreFile
|
||||
key={key}
|
||||
file={isLargeFile && largeFileCacheKey ? { ...fileContents, cacheKey: `${file.path}:${largeFileCacheKey}` } : fileContents}
|
||||
options={{
|
||||
disableFileHeader: true,
|
||||
overflow: wrapLines ? 'wrap' : 'scroll',
|
||||
theme: pierreTheme,
|
||||
themeType: currentTheme.metadata.variant === 'dark' ? 'dark' : 'light',
|
||||
}}
|
||||
className={isLargeFile ? 'block w-full' : 'block h-full w-full'}
|
||||
style={isLargeFile ? undefined : { height: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!isLargeFile) {
|
||||
return <div className="h-full">{pierreFile(file.path)}</div>;
|
||||
}
|
||||
|
||||
// Large files render through pierre's Virtualizer (viewport-only DOM) and
|
||||
// the shared Shiki worker pool. The pool is created lazily: until it is
|
||||
// ready the key carries a 'pending' suffix so the file remounts with the
|
||||
// worker-backed highlighter instead of silently staying on the main thread.
|
||||
return (
|
||||
<div className="h-full">
|
||||
<PierreFile
|
||||
file={{
|
||||
name: file.name,
|
||||
contents: content,
|
||||
lang: getLanguageFromExtension(file.path) || undefined,
|
||||
}}
|
||||
options={{
|
||||
disableFileHeader: true,
|
||||
overflow: wrapLines ? 'wrap' : 'scroll',
|
||||
theme: pierreTheme,
|
||||
themeType: currentTheme.metadata.variant === 'dark' ? 'dark' : 'light',
|
||||
}}
|
||||
className="block h-full w-full"
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
<VirtualizerContext.Provider value={virtualizer.virtualizer}>
|
||||
<WorkerPoolContext.Provider value={shikiWorkerPool}>
|
||||
{pierreFile(`${file.path}:${shikiWorkerPool ? 'pool' : 'pending'}`)}
|
||||
</WorkerPoolContext.Provider>
|
||||
</VirtualizerContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}, [currentTheme.metadata.variant, pierreTheme, wrapLines]);
|
||||
}, [currentTheme.metadata.variant, isLargeFile, largeFileCacheKey, pierreTheme, shikiWorkerPool, wrapLines]);
|
||||
|
||||
const renderFloatingFileControls = ({
|
||||
exitFullscreenOnly = false,
|
||||
@@ -3854,7 +3903,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
||||
<ScrollableOverlay ref={mainViewVirtualizer.setScroller} 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) ? (
|
||||
@@ -3980,7 +4029,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</div>
|
||||
)
|
||||
) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? (
|
||||
renderShikiFileView(selectedFile, draftContent)
|
||||
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer)
|
||||
) : (
|
||||
<div
|
||||
className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}
|
||||
@@ -4250,7 +4299,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
<div className="absolute right-4 top-4 z-30">
|
||||
{renderFloatingFileControls({ exitFullscreenOnly: true })}
|
||||
</div>
|
||||
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
||||
<ScrollableOverlay ref={fullscreenViewVirtualizer.setScroller} outerClassName="h-full min-w-0" className={cn('h-full min-w-0', isLargeFile && '[overflow-anchor:none]')}>
|
||||
{(fileLoading || isPdfAssetAuthLoading) ? (
|
||||
suppressFileLoadingIndicator
|
||||
? <div className="p-4" />
|
||||
@@ -4322,7 +4371,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : canUseShikiFileView && textViewMode === 'view' ? (
|
||||
renderShikiFileView(selectedFile, draftContent)
|
||||
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer)
|
||||
) : (
|
||||
<div className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}>
|
||||
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Virtualizer } from '@pierre/diffs';
|
||||
|
||||
/**
|
||||
* Owns one pierre `Virtualizer` bound to a scrolling container.
|
||||
*
|
||||
* The instance is created on first render (the constructor does no DOM work)
|
||||
* so a `<PierreFile>` mounted inside a `VirtualizerContext.Provider` already
|
||||
* picks the virtualized path on mount. pierre queues `connect()` calls made
|
||||
* before `setup()` and flushes them once the real scroller element is bound.
|
||||
*
|
||||
* The scroller passed to `setScroller` must be the actual scrolling element:
|
||||
* pierre reads `scrollTop`/`scrollHeight`/client height and applies its scroll
|
||||
* fix on that element.
|
||||
*/
|
||||
export function useFileViewVirtualizer() {
|
||||
const [virtualizer] = useState(() => new Virtualizer());
|
||||
const setupRef = useRef(false);
|
||||
|
||||
const setScroller = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (node == null) {
|
||||
// The scroller was removed (e.g. mobile tree/files toggle or exiting
|
||||
// fullscreen). pierre's setup() no-ops when a root is already bound,
|
||||
// so tear the binding down or the next mount would silently attach to
|
||||
// the stale element and the virtualized file would never update.
|
||||
virtualizer.cleanUp();
|
||||
setupRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (setupRef.current) return;
|
||||
setupRef.current = true;
|
||||
virtualizer.setup(node, node.firstElementChild ?? undefined);
|
||||
},
|
||||
[virtualizer],
|
||||
);
|
||||
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
setupRef.current = false;
|
||||
virtualizer.cleanUp();
|
||||
},
|
||||
[virtualizer],
|
||||
);
|
||||
|
||||
return { virtualizer, setScroller };
|
||||
}
|
||||
|
||||
export type FileViewVirtualizer = ReturnType<typeof useFileViewVirtualizer>;
|
||||
Reference in New Issue
Block a user