diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 23e73735..b1da1470 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -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 = ({ 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) => ( + + ); + + if (!isLargeFile) { + return
{pierreFile(file.path)}
; + } + + // 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 (
- + + + {pierreFile(`${file.path}:${shikiWorkerPool ? 'pool' : 'pending'}`)} + +
); - }, [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 = ({ mode = 'full' }) => { )} )} - + {!selectedFile ? (
{t('filesView.editor.pickFileFromTree')}
) : (fileLoading || isPdfAssetAuthLoading) ? ( @@ -3980,7 +4029,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { ) ) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? ( - renderShikiFileView(selectedFile, draftContent) + renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer) ) : (
= ({ mode = 'full' }) => {
{renderFloatingFileControls({ exitFullscreenOnly: true })}
- + {(fileLoading || isPdfAssetAuthLoading) ? ( suppressFileLoadingIndicator ?
@@ -4322,7 +4371,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
) : canUseShikiFileView && textViewMode === 'view' ? ( - renderShikiFileView(selectedFile, draftContent) + renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer) ) : (
diff --git a/packages/ui/src/components/views/useFileViewVirtualizer.ts b/packages/ui/src/components/views/useFileViewVirtualizer.ts new file mode 100644 index 00000000..a62fed38 --- /dev/null +++ b/packages/ui/src/components/views/useFileViewVirtualizer.ts @@ -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 `` 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;