Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions frontend/src/features/resources/resources-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@ export const MAX_UPLOAD_TOTAL_SIZE_MB = 2 * 1024;

/** Mirrors `resources.MaxUploadFiles`. */
export const MAX_UPLOAD_FILES_PER_REQUEST = 1000;

/**
* Above this many entries, mounting the FileManager tree unfiltered risks
* freezing the page: the tree has no row virtualization, so building and
* rendering tens of thousands of DOM nodes on first paint blocks the main
* thread (see https://github.com/vxcontrol/pentagi/issues/403). Past this
* size, the Resources page prompts the user to search instead of eagerly
* rendering the whole library.
*/
export const LARGE_LIBRARY_PROMPT_THRESHOLD = 5000;
19 changes: 19 additions & 0 deletions frontend/src/features/resources/resources-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';

import { shouldPromptToSearchLargeLibrary } from './resources-utils';

describe('shouldPromptToSearchLargeLibrary', () => {
it('returns false when the count is at or below the threshold', () => {
expect(shouldPromptToSearchLargeLibrary(5000, false, 5000)).toBe(false);
expect(shouldPromptToSearchLargeLibrary(100, false, 5000)).toBe(false);
});

it('returns true when the count exceeds the threshold and no query is active', () => {
expect(shouldPromptToSearchLargeLibrary(5001, false, 5000)).toBe(true);
expect(shouldPromptToSearchLargeLibrary(100000, false, 5000)).toBe(true);
});

it('returns false once a search query is active, regardless of count', () => {
expect(shouldPromptToSearchLargeLibrary(100000, true, 5000)).toBe(false);
});
});
11 changes: 11 additions & 0 deletions frontend/src/features/resources/resources-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,14 @@ export const buildResourcesDownloadHref = (files: readonly FileNode[]): string =
};

export const pluralizeItems = (count: number): string => (count === 1 ? 'item' : 'items');

/**
* True when the library is large enough that rendering it unfiltered risks
* freezing the page (see `LARGE_LIBRARY_PROMPT_THRESHOLD`), and no search
* query is active yet to narrow the result set down.
*/
export const shouldPromptToSearchLargeLibrary = (
resourceCount: number,
hasActiveQuery: boolean,
threshold: number,
): boolean => !hasActiveQuery && resourceCount > threshold;
74 changes: 54 additions & 20 deletions frontend/src/pages/resources/resources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,16 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/
import { FileDropZone } from '@/components/ui/file-drop-zone';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Spinner } from '@/components/ui/spinner';
import { LARGE_LIBRARY_PROMPT_THRESHOLD } from '@/features/resources/resources-constants';
import { ResourcesCopyDialog } from '@/features/resources/resources-copy-dialog';
import { ResourcesMkdirDialog } from '@/features/resources/resources-mkdir-dialog';
import { ResourcesMoveDialog } from '@/features/resources/resources-move-dialog';
import { buildResourcesDownloadHref, pluralizeItems, toFileNode } from '@/features/resources/resources-utils';
import {
buildResourcesDownloadHref,
pluralizeItems,
shouldPromptToSearchLargeLibrary,
toFileNode,
} from '@/features/resources/resources-utils';
import { useResourcesDelete } from '@/features/resources/use-resources-delete';
import { useResourcesMove } from '@/features/resources/use-resources-move';
import { useResourcesSearch } from '@/features/resources/use-resources-search';
Expand Down Expand Up @@ -433,6 +439,30 @@ function Resources() {
</Empty>
);

// Above LARGE_LIBRARY_PROMPT_THRESHOLD entries, the FileManager tree (which
// isn't virtualized) can freeze the page on first paint. Skip mounting it
// until the user narrows the list down with a search query.
const isLibraryTooLargeToBrowse = shouldPromptToSearchLargeLibrary(
resources.length,
Boolean(search.debouncedQuery.trim()),
LARGE_LIBRARY_PROMPT_THRESHOLD,
);

const largeLibraryState = (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Search />
</EmptyMedia>
<EmptyTitle>Large resource library</EmptyTitle>
<EmptyDescription>
This library has {resources.length.toLocaleString()} entries — too many to browse at once. Search
above to narrow it down.
</EmptyDescription>
</EmptyHeader>
</Empty>
);

// Error surface only when there's no data — a failed background refetch must not blank a working list.
if (error && !hasResources) {
return (
Expand Down Expand Up @@ -544,25 +574,29 @@ function Resources() {
</DropdownMenu>
</div>

<FileManager
actions={fileManagerActions}
bulkActions={fileManagerBulkActions}
className="min-h-0 flex-1"
columns={{
isModifiedVisible: viewOptions.modified,
isSizeVisible: viewOptions.size,
}}
emptyAreaActions={fileManagerEmptyAreaActions}
emptyState={noResourcesState}
files={fileNodes}
isFoldersFirst={viewOptions.foldersFirst}
isLoading={isInitialLoading}
labels={fileManagerLabels}
onExternalFileDrop={handleExternalFileDrop}
onMoveItems={handleMoveItems}
onOpen={handleOpenFile}
search={{ emptyState: noMatchesState, query: search.debouncedQuery }}
/>
{isLibraryTooLargeToBrowse ? (
largeLibraryState
) : (
<FileManager
actions={fileManagerActions}
bulkActions={fileManagerBulkActions}
className="min-h-0 flex-1"
columns={{
isModifiedVisible: viewOptions.modified,
isSizeVisible: viewOptions.size,
}}
emptyAreaActions={fileManagerEmptyAreaActions}
emptyState={noResourcesState}
files={fileNodes}
isFoldersFirst={viewOptions.foldersFirst}
isLoading={isInitialLoading}
labels={fileManagerLabels}
onExternalFileDrop={handleExternalFileDrop}
onMoveItems={handleMoveItems}
onOpen={handleOpenFile}
search={{ emptyState: noMatchesState, query: search.debouncedQuery }}
/>
)}

<ResourcesMkdirDialog
defaultParentPath={mkdirParentOverride ?? ''}
Expand Down