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:
@@ -59,6 +59,7 @@
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
||||
import { gridColumns } from '$lib/utils/grid';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -148,6 +149,9 @@
|
||||
const viewClass = $derived(
|
||||
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
||||
);
|
||||
/** Content width, for computing the grid's column count to match auto-fill. */
|
||||
let gridWidth = $state(0);
|
||||
const gridCols = $derived(gridColumns(gridWidth));
|
||||
|
||||
// Build the list-view column track from the enabled cells.
|
||||
const columns = $derived(
|
||||
@@ -409,45 +413,35 @@
|
||||
hint={emptyHint}
|
||||
/>
|
||||
{:else}
|
||||
<div class="files-container">
|
||||
<div class={viewClass} style="--files-list-columns: {columns}">
|
||||
<div class="list-header">
|
||||
{#if selectable}
|
||||
<div class="select-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('common.select_all', 'Select all')}
|
||||
checked={allSelected}
|
||||
onchange={toggleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div>{t('files.col_name', 'Name')}</div>
|
||||
{#if showOwner}<div>{t('files.col_owner', 'Owner')}</div>{/if}
|
||||
{#if showPath}<div>{pathLabel ?? t('files.col_path', 'Location')}</div>{/if}
|
||||
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
|
||||
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
|
||||
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
|
||||
{#if onfavorite || actions}<div></div>{/if}
|
||||
</div>
|
||||
|
||||
{#if grouped}
|
||||
<div class="files-container" bind:clientWidth={gridWidth}>
|
||||
{#if grouped}
|
||||
<div class={viewClass} style="--files-list-columns: {columns}">
|
||||
{@render listHeader()}
|
||||
{#each sections as section (section.key)}
|
||||
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
|
||||
{#each section.rows as entry (entry.id)}
|
||||
{@render row(entry)}
|
||||
{/each}
|
||||
{/each}
|
||||
{:else if filesStore.viewMode === 'list'}
|
||||
<!-- Flat list view: only the visible rows are mounted. The spacer keeps
|
||||
the full scroll height so the end-of-list sentinel still fires. -->
|
||||
</div>
|
||||
{:else if filesStore.viewMode === 'list'}
|
||||
<!-- Flat list view: only the visible rows are mounted. The spacer keeps the
|
||||
full scroll height so the end-of-list sentinel still fires. -->
|
||||
<div class="files-list-view" style="--files-list-columns: {columns}">
|
||||
{@render listHeader()}
|
||||
<VirtualList {items} rowHeight={56} key={(e) => e.id} {row} />
|
||||
{:else}
|
||||
{#each items as entry (entry.id)}
|
||||
{@render row(entry)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Grid view: the windowed list's inner element IS the card grid. -->
|
||||
<VirtualList
|
||||
{items}
|
||||
columns={gridCols}
|
||||
rowHeight={240}
|
||||
windowClass="files-grid-view"
|
||||
key={(e) => e.id}
|
||||
{row}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if hasMore}
|
||||
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
|
||||
@@ -459,6 +453,28 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet listHeader()}
|
||||
<div class="list-header">
|
||||
{#if selectable}
|
||||
<div class="select-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('common.select_all', 'Select all')}
|
||||
checked={allSelected}
|
||||
onchange={toggleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div>{t('files.col_name', 'Name')}</div>
|
||||
{#if showOwner}<div>{t('files.col_owner', 'Owner')}</div>{/if}
|
||||
{#if showPath}<div>{pathLabel ?? t('files.col_path', 'Location')}</div>{/if}
|
||||
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
|
||||
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
|
||||
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
|
||||
{#if onfavorite || actions}<div></div>{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if ctxOpen && ctxEntry && contextActions}
|
||||
<div
|
||||
class="rl-ctx-scrim"
|
||||
|
||||
Reference in New Issue
Block a user