perf(frontend): virtualize grid views + the files browser (list & grid)

Extends windowing to the remaining O(n)-DOM surfaces: the card-grid view of
ResourceList (recent / favorites / shared / shared-with-me / trash / search) and
the main file browser (`files/[...path]`), in both list and grid layouts.

- VirtualList gains a real grid mode: its inner window carries the caller's grid
  class (`files-grid-view`) and lays out `columns` cards per windowed row. The
  row pitch is auto-measured (grid card height tracks column width via the 4/3
  aspect-ratio thumbnail) and re-measured on resize.
- `useVirtualWindow` now distinguishes scroll from resize and exposes a
  `resizeTick`, so size-dependent layout (the grid pitch) is recomputed only when
  it can actually change.
- `gridColumns(width)` (new util) mirrors the CSS `auto-fill` / `--grid-card-min`
  / gap so the windowed row count matches the browser's real wrapping exactly;
  shared by both grid callers.
- The files browser flattens folders-then-files into one discriminated `entries`
  list rendered through VirtualList (list: columns=1; grid: columns from width).
  Grouped (swimlane) views stay fully rendered, as before — they're bounded.

ResourceList GRID, headless Chromium (1280x900), synthetic rows, before/after:

  rows  | mount→paint | DOM nodes | JS heap | scroll frame | jank frames
  ------+-------------+-----------+---------+--------------+------------
   1000 | 979→75 ms   | 19k→879   | 16→3 MB | 16→17 ms     | 0→0
   5000 | 4005→67 ms  | 95k→879   | 72→3 MB | 37→17 ms     | 26→0
  20000 |12015→65 ms  | 380k→879  |281→7 MB |197→17 ms     |1249→19

Rendered DOM and heap are flat (O(visible)) regardless of dataset size; mount is
~185x faster and scroll holds ~60fps. Verified visually mid-scroll (5 columns,
4/3 thumbnail tiles, cards land at the expected indices). The files browser
shares the same VirtualList path (it can't be mounted headless — it depends on
$app routing/session — so it's validated via svelte-check + the production build
+ the shared, separately-benchmarked component).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
Claude
2026-06-19 14:48:37 +00:00
parent ccb85f53c0
commit 8794324c3d
5 changed files with 171 additions and 86 deletions
@@ -34,6 +34,7 @@
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import FileViewer from '$lib/components/FileViewer.svelte';
import ListToolbar from '$lib/components/ListToolbar.svelte';
import VirtualList from '$lib/components/VirtualList.svelte';
import MoveDialog from '$lib/components/MoveDialog.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import WopiEditor from '$lib/components/WopiEditor.svelte';
@@ -51,6 +52,7 @@
} from '$lib/stores/files.svelte';
import { formatBytes } from '$lib/utils/format';
import { formatDate, iconNameFromClass } from '$lib/utils/display';
import { gridColumns } from '$lib/utils/grid';
// The URL rest param is the trail of folder ids from home's children down.
// /files → home root; /files/a/b → folder b inside a inside home.
@@ -798,6 +800,16 @@
/** Flat id order matching how rows are displayed (folders then files). */
const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]);
// Folders-then-files as one ordered, discriminated list so the (flat) view can
// be windowed by a single VirtualList. Content width drives the grid columns.
type Entry = { kind: 'folder'; folder: FolderItem } | { kind: 'file'; file: FileItem };
const entries = $derived<Entry[]>([
...sortedFolders.map((folder) => ({ kind: 'folder' as const, folder })),
...sortedFiles.map((file) => ({ kind: 'file' as const, file }))
]);
const entryKey = (e: Entry): string => (e.kind === 'folder' ? e.folder.id : e.file.id);
let gridWidth = $state(0);
// ── Group-by / swimlanes ─────────────────────────────────────────────────
// Mirrors GROUP_BY_DEFS in static/js/app/filesView.js: a flat list ('') plus
// Type / Size / Modified date / Created date dimensions. Folders always group
@@ -1088,49 +1100,10 @@
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
/>
{:else}
<div class="files-container">
<div class={viewClass}>
<div class="list-header">
<div class="list-header-checkbox">
<input
type="checkbox"
aria-label={t('files.select_all', 'Select all')}
checked={selectedCount > 0 && selectedCount === totalCount}
indeterminate={selectedCount > 0 && selectedCount < totalCount}
onchange={toggleSelectAll}
/>
</div>
{#each [{ f: 'name', l: t('files.col_name', 'Name') }, { f: 'owner', l: t('files.col_owner', 'Owner') }, { f: 'type', l: t('files.col_type', 'Type') }, { f: 'size', l: t('files.col_size', 'Size') }, { f: 'modified_at', l: t('files.col_modified', 'Modified') }] as col (col.f)}
{#if col.f === 'owner'}
<div class="list-header-owner">{col.l}</div>
{:else}
<button
class="list-header-sort"
class:is-active={sortField === col.f}
data-sort-field={col.f}
onclick={() => toggleSort(col.f as SortField)}
>
{col.l}
{#if sortField === col.f}
<Icon
name={sortDir === 1 ? 'arrow-down' : 'arrow-up'}
class="list-header-sort__arrow"
/>
{/if}
</button>
{/if}
{/each}
<div></div>
</div>
{#if groupBy === ''}
{#each sortedFolders as folder (folder.id)}
{@render folderRow(folder)}
{/each}
{#each sortedFiles as file (file.id)}
{@render fileRow(file)}
{/each}
{:else}
<div class="files-container" bind:clientWidth={gridWidth}>
{#if groupBy !== ''}
<div class={viewClass}>
{@render fileListHeader()}
{#each groups as group (group.key)}
<div class="resource-list__swimlane-header">{group.label}</div>
{#each group.folders as folder (folder.id)}
@@ -1140,12 +1113,71 @@
{@render fileRow(file)}
{/each}
{/each}
{/if}
</div>
</div>
{:else if filesStore.viewMode === 'list'}
<!-- Flat list: only the rows near the viewport are mounted. -->
<div class="files-list-view">
{@render fileListHeader()}
<VirtualList items={entries} rowHeight={56} key={entryKey} row={entryRow} />
</div>
{:else}
<!-- Grid: the windowed list's inner element IS the card grid. -->
<VirtualList
items={entries}
columns={gridColumns(gridWidth)}
rowHeight={240}
windowClass="files-grid-view"
key={entryKey}
row={entryRow}
/>
{/if}
</div>
{/if}
</div>
{#snippet fileListHeader()}
<div class="list-header">
<div class="list-header-checkbox">
<input
type="checkbox"
aria-label={t('files.select_all', 'Select all')}
checked={selectedCount > 0 && selectedCount === totalCount}
indeterminate={selectedCount > 0 && selectedCount < totalCount}
onchange={toggleSelectAll}
/>
</div>
{#each [{ f: 'name', l: t('files.col_name', 'Name') }, { f: 'owner', l: t('files.col_owner', 'Owner') }, { f: 'type', l: t('files.col_type', 'Type') }, { f: 'size', l: t('files.col_size', 'Size') }, { f: 'modified_at', l: t('files.col_modified', 'Modified') }] as col (col.f)}
{#if col.f === 'owner'}
<div class="list-header-owner">{col.l}</div>
{:else}
<button
class="list-header-sort"
class:is-active={sortField === col.f}
data-sort-field={col.f}
onclick={() => toggleSort(col.f as SortField)}
>
{col.l}
{#if sortField === col.f}
<Icon
name={sortDir === 1 ? 'arrow-down' : 'arrow-up'}
class="list-header-sort__arrow"
/>
{/if}
</button>
{/if}
{/each}
<div></div>
</div>
{/snippet}
{#snippet entryRow(e: Entry)}
{#if e.kind === 'folder'}
{@render folderRow(e.folder)}
{:else}
{@render fileRow(e.file)}
{/if}
{/snippet}
{#snippet folderRow(folder: FolderItem)}
<div
class="file-item"