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:
gaojunran
2026-08-22 02:33:28 +08:00
parent be234548fa
commit ae1c6de470
2 changed files with 120 additions and 22 deletions
+71 -22
View File
@@ -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>;