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 { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||||
import { shikiHighlightExtension } from '@/lib/codemirror/shikiHighlight';
|
import { shikiHighlightExtension } from '@/lib/codemirror/shikiHighlight';
|
||||||
import { getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry';
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -311,6 +313,23 @@ const isFileMissingError = (error: unknown): boolean => {
|
|||||||
const MAX_VIEW_CHARS = 200_000;
|
const MAX_VIEW_CHARS = 200_000;
|
||||||
type FileLineEnding = '\n' | '\r\n';
|
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 => {
|
const detectFileLineEnding = (content: string): FileLineEnding => {
|
||||||
let crlf = 0;
|
let crlf = 0;
|
||||||
let lf = 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]);
|
}, [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 (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<PierreFile
|
<VirtualizerContext.Provider value={virtualizer.virtualizer}>
|
||||||
file={{
|
<WorkerPoolContext.Provider value={shikiWorkerPool}>
|
||||||
name: file.name,
|
{pierreFile(`${file.path}:${shikiWorkerPool ? 'pool' : 'pending'}`)}
|
||||||
contents: content,
|
</WorkerPoolContext.Provider>
|
||||||
lang: getLanguageFromExtension(file.path) || undefined,
|
</VirtualizerContext.Provider>
|
||||||
}}
|
|
||||||
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%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [currentTheme.metadata.variant, pierreTheme, wrapLines]);
|
}, [currentTheme.metadata.variant, isLargeFile, largeFileCacheKey, pierreTheme, shikiWorkerPool, wrapLines]);
|
||||||
|
|
||||||
const renderFloatingFileControls = ({
|
const renderFloatingFileControls = ({
|
||||||
exitFullscreenOnly = false,
|
exitFullscreenOnly = false,
|
||||||
@@ -3854,7 +3903,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{!selectedFile ? (
|
||||||
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
|
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
|
||||||
) : (fileLoading || isPdfAssetAuthLoading) ? (
|
) : (fileLoading || isPdfAssetAuthLoading) ? (
|
||||||
@@ -3980,7 +4029,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? (
|
) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? (
|
||||||
renderShikiFileView(selectedFile, draftContent)
|
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}
|
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">
|
<div className="absolute right-4 top-4 z-30">
|
||||||
{renderFloatingFileControls({ exitFullscreenOnly: true })}
|
{renderFloatingFileControls({ exitFullscreenOnly: true })}
|
||||||
</div>
|
</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) ? (
|
{(fileLoading || isPdfAssetAuthLoading) ? (
|
||||||
suppressFileLoadingIndicator
|
suppressFileLoadingIndicator
|
||||||
? <div className="p-4" />
|
? <div className="p-4" />
|
||||||
@@ -4322,7 +4371,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
) : canUseShikiFileView && textViewMode === 'view' ? (
|
) : canUseShikiFileView && textViewMode === 'view' ? (
|
||||||
renderShikiFileView(selectedFile, draftContent)
|
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer)
|
||||||
) : (
|
) : (
|
||||||
<div className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}>
|
<div className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}>
|
||||||
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
|
<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