feat: show far more in the home tree view and say what is not loaded (#10519)

* feat: show far more in the home tree view and say what is not loaded

* feat: let every folder in the home tree page within its own prefix

* fix: count leaves in nested badges and stop transient subtree mounts

* fix: merge nested pages instead of replacing rows an ancestor loaded

* docs: tighten the tree prefix-loading invariants
This commit is contained in:
Ruben Fiszel
2026-08-04 20:41:30 +02:00
committed by GitHub
parent 0d1cb818ee
commit 4254686758
4 changed files with 334 additions and 150 deletions
+114 -81
View File
@@ -346,37 +346,37 @@
}
}
// Per-top-level-folder lazy loading for tree view: expanding a folder loads its
// items on demand (server-scoped to that folder), paginated within the folder,
// instead of relying on the global browse window. Keyed by folder name (the
// `f/<name>` node).
// Per-folder lazy loading for tree view, keyed by the full path prefix a node covers —
// an owner (`f/<name>` / `u/<name>`) or any folder under one — since the listing
// endpoint scopes on an arbitrary `path_start`. That is what lets a subfolder be
// completed on its own instead of only by paging its whole owner.
//
// Every top-level namespace — a folder (`f/<name>`) OR a user (`u/<name>`) — is a
// lazily-loaded owner, keyed here by its full path prefix. Its items live in their
// OWN store (`treeOwnerItems`), never merged into the global browse arrays: those
// advance by a single `serverCursor`, so injecting out-of-window rows there would
// make the flat stream non-contiguous and, once pagination reached those rows,
// duplicate them. Loading users lazily too (rather than sourcing them from the
// loaded window) is why a user node no longer vanishes under a name sort whose
// first page happens to be all folder rows. `treeGen` ties every request to the
// active scope (order/archived/library/kind/workspace); a reset bumps it so an
// in-flight response from a stale scope is discarded.
// Rows for every prefix live in ONE store (`treeOwnerItems`), never in the global
// browse arrays: those advance by a single `serverCursor`, so out-of-window rows there
// would make the flat stream non-contiguous and duplicate once pagination reached them.
//
// `treeGen` ties every request to the active scope (order/archived/library/kind/
// workspace); a reset bumps it so an in-flight response from a stale scope is dropped.
type OwnerLoadState = {
cursor?: string
hasMore: boolean
loading: boolean
loaded: boolean
gen: number
// Total rows fetched for this owner so far (leaves + subfolder rows) — the tree
// node's own children count undercounts because rows nest into subfolders.
count: number
}
// Rows per request when loading a prefix in the tree. Larger than the flat list's
// page because a tree row is a single line and a folder is opened to see what it
// holds — most folders come in whole on the first click.
const OWNER_PAGE_SIZE = 300
// How far one "Load more" will page past rows it already has before giving up and
// leaving the rest to another click (see the catch-up loop in loadOwnerItems).
const OWNER_CATCH_UP_PAGES = 5
let ownerLoad = $state<Record<string, OwnerLoadState>>({})
let treeOwnerItems = $state<ItemType[]>([])
let treeGen = 0
// Owners the user currently has expanded (full path prefixes). A reload re-fetches
// only these: ownerLoad also retains collapsed owners as a cache, so keying reloads
// off its entries would re-request every owner ever opened in this scope.
// Prefixes currently loaded and on screen. A reload re-fetches only these: ownerLoad
// also retains collapsed nodes as a cache, so keying reloads off its entries would
// re-request every folder ever opened in this scope.
let openOwners = new Set<string>()
// The endpoint returns a unified `edited_at` per row; combinedItems derives every
@@ -389,14 +389,14 @@
} as unknown as ItemType
}
// `owner` is the full prefix: `f/<folder>` or `u/<username>`.
// `force` re-fetches an owner's first page even if already loaded (a re-sort /
// re-filter reload uses it to refresh the loaded rows in place).
// `owner` is the full prefix a node covers: `f/<folder>`, `u/<username>`, or any
// folder under one. `force` re-fetches its first page even if already loaded (a
// re-sort / re-filter reload uses it to refresh the loaded rows in place).
async function loadOwnerItems(owner: string, more = false, force = false): Promise<void> {
const ws = $workspaceStore
if (!ws || !$userStore) return
// Track the owner as open first — even a no-op call (re-expanding a cached owner)
// means it's expanded, so later reloads must refresh it.
// Track the prefix as open first — even a no-op call (re-expanding a cached node)
// means it's on screen, so later reloads must refresh it.
openOwners.add(owner)
const st = ownerLoad[owner]
// Only a load for the CURRENT generation blocks a new one. A load left in flight
@@ -410,60 +410,84 @@
hasMore: st?.hasMore ?? false,
loading: true,
loaded: st?.loaded ?? false,
gen,
// A fresh (non-more) load replaces this owner's rows, so its count restarts.
count: more ? (st?.count ?? 0) : 0
gen
}
const { orderBy, orderDesc } = sortToParams(sortOrder)
let res: { items: RunnableItem[]; next_cursor?: string }
try {
res = await ScriptService.listRunnables({
workspace: ws,
orderBy,
orderDesc,
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
kinds: itemKind !== 'all' ? itemKind : undefined,
pathStart: `${owner}/`,
includeDraftOnly: true,
perPage: 100,
cursor: more ? st?.cursor : undefined
})
} catch (e: any) {
if (gen !== treeGen) return
ownerLoad[owner] = { ...ownerLoad[owner], loading: false }
sendUserToast(`Failed to load ${owner}: ${e?.body ?? e?.message ?? e}`, true)
return
}
// The scope moved on while this was in flight (order/archive/library/kind/
// workspace changed and reset the tree); drop the response so stale rows from
// another scope can't appear under the current one.
if (gen !== treeGen) return
// A fresh load REPLACES this owner's rows (drop its previous ones, keep every
// other owner's untouched) so a re-sort/re-filter swaps its items atomically
// without blanking the whole tree; load-more appends to what's already shown.
const prefix = `${owner}/`
const base = more
? treeOwnerItems
: treeOwnerItems.filter((x) => !effectivePath(x).startsWith(prefix))
const have = new Set(base.map(itemKey))
const merged = [...base]
for (const it of res.items ?? []) {
// Pipeline-member scripts are folded into their folder's Pipeline entry, so
// they never render as their own tree leaf (visiblePipelineFolders drives it).
if (it.type === 'script' && it.auto_kind === 'pipeline') continue
if (have.has(itemKey(it))) continue
merged.push({ ...toTreeItem(it), ord: fetchOrd++ })
// Only a forced refresh replaces this prefix's rows, so a re-sort swaps them without
// blanking the tree. Everything else merges: a nested prefix inherits rows from an
// ancestor's pages, and one page of its own can cover fewer of them than are shown —
// replacing would delete rows on the click meant to add them.
const replacing = !more && force
// Merges a page and answers how many rows it actually added. Reads the live store
// each time rather than a snapshot, so a page landing while another prefix loads
// doesn't drop that prefix's rows.
let firstMerge = true
const mergePage = (items: RunnableItem[] | undefined): number => {
const current =
replacing && firstMerge
? treeOwnerItems.filter((x) => !effectivePath(x).startsWith(prefix))
: treeOwnerItems
firstMerge = false
const have = new Set(current.map(itemKey))
const merged = [...current]
let added = 0
for (const it of items ?? []) {
// Pipeline-member scripts are folded into their folder's Pipeline entry, so
// they never render as their own tree leaf (visiblePipelineFolders drives it).
if (it.type === 'script' && it.auto_kind === 'pipeline') continue
if (have.has(itemKey(it))) continue
merged.push({ ...toTreeItem(it), ord: fetchOrd++ })
added++
}
treeOwnerItems = merged
return added
}
treeOwnerItems = merged
ownerLoad[owner] = {
cursor: res.next_cursor,
hasMore: !!res.next_cursor,
loading: false,
loaded: true,
gen,
count: merged.filter((x) => x.path.startsWith(prefix)).length
let cursor = more ? st?.cursor : undefined
let nextCursor: string | undefined
// A nested prefix's own stream restarts at its first row, which an ancestor's pages
// may already have brought in — that page then adds nothing and the click would read
// as broken. Keep paging until one adds something or the stream ends, bounded so a
// single click can't turn into an unbounded fetch loop.
for (let page = 0; page < OWNER_CATCH_UP_PAGES; page++) {
let res: { items: RunnableItem[]; next_cursor?: string }
try {
res = await ScriptService.listRunnables({
workspace: ws,
orderBy,
orderDesc,
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
kinds: itemKind !== 'all' ? itemKind : undefined,
pathStart: prefix,
includeDraftOnly: true,
perPage: OWNER_PAGE_SIZE,
cursor
})
} catch (e: any) {
if (gen !== treeGen) return
ownerLoad[owner] = { ...ownerLoad[owner], loading: false }
sendUserToast(`Failed to load ${owner}: ${e?.body ?? e?.message ?? e}`, true)
return
}
// The scope moved on while this was in flight (order/archive/library/kind/
// workspace changed and reset the tree); drop the response so stale rows from
// another scope can't appear under the current one.
if (gen !== treeGen) return
const added = mergePage(res.items)
nextCursor = res.next_cursor
cursor = nextCursor
if (added > 0 || nextCursor == undefined) break
}
ownerLoad = Object.fromEntries([
// A replacing load dropped every row under this prefix, so a nested folder that
// had paged itself is back to whatever this page holds: its load state has to go
// with its rows. Left behind, a subfolder marked complete would keep an exact
// count and no "Load more" over rows this response truncated. Reloads re-fetch
// the ones still open (see reloadItems), which re-establishes their state.
...Object.entries(ownerLoad).filter(([p]) => !replacing || !p.startsWith(prefix)),
[owner, { cursor: nextCursor, hasMore: !!nextCursor, loading: false, loaded: true, gen }]
])
}
function collapseOwner(owner: string): void {
@@ -496,12 +520,22 @@
ownerLoad = Object.fromEntries(Object.entries(ownerLoad).filter(([o]) => open.has(o)))
}
await loadRunnables(true)
// force: the owners are still marked loaded, so re-fetch their first page and
// swap it in place (loadOwnerItems replaces each owner's rows atomically — the
// old rows stay visible until the new ones arrive, so nothing blanks mid-reorder).
// force: the prefixes are still marked loaded, so re-fetch their first page and
// swap it in place (loadOwnerItems replaces a prefix's rows atomically — the old
// rows stay visible until the new ones arrive, so nothing blanks mid-reorder).
// Shallowest first, one depth at a time: a fresh load drops every row under its
// prefix, so a parent landing after a nested subfolder would wipe the rows that
// subfolder just re-fetched and leave it short until clicked again.
// Awaited so a caller reconciling against the rendered rows sees the reloaded
// tree rather than the pre-reload ones; the per-owner swap stays atomic either way.
await Promise.all(toReload.map((o) => loadOwnerItems(o, false, true)))
// tree rather than the pre-reload ones; each swap stays atomic either way.
const byDepth = new Map<number, string[]>()
for (const p of toReload) {
const d = p.split('/').length
byDepth.set(d, [...(byDepth.get(d) ?? []), p])
}
for (const d of [...byDepth.keys()].sort((a, b) => a - b)) {
await Promise.all((byDepth.get(d) ?? []).map((p) => loadOwnerItems(p, false, true)))
}
}
// For row mutations (create/delete/move/archive), which also change how many
@@ -1673,7 +1707,6 @@
{#key treeKey}
<TreeViewRoot
items={treeSource}
{nbDisplayed}
{collapseAll}
sortCompare={compareItems}
groupDesc={sortOrder === 'name_desc'}
@@ -1682,7 +1715,7 @@
pipelineFolders={visiblePipelineFolders}
allFolders={treeInjectFolders}
allUsers={treeInjectUsers}
ownerCounts={treeLazyMode ? ownerCounts : undefined}
ownerCounts={!searching && labelFilter == undefined ? ownerCounts : undefined}
selfUsername={$userStore?.username}
ownerLoad={treeLazyMode ? ownerLoad : undefined}
onExpandOwner={treeLazyMode ? loadOwnerItems : undefined}
+146 -52
View File
@@ -4,10 +4,11 @@
import { ChevronDown, ChevronUp, Folder, FolderTree, NetworkIcon, User } from 'lucide-svelte'
import Item from './Item.svelte'
import type { FolderItem, ItemType, UserItem } from './treeViewUtils'
import { countLeaves, type FolderItem, type ItemType, type UserItem } from './treeViewUtils'
import { twMerge } from 'tailwind-merge'
import { pluralize } from '$lib/utils'
import { base } from '$lib/base'
import { Button } from '$lib/components/common'
interface Props {
item: ItemType | FolderItem | UserItem
@@ -19,18 +20,26 @@
// How many runnables each `f/<folder>` / `u/<user>` holds for this user, keyed by
// full prefix. Known before an owner is expanded, unlike its loaded rows.
ownerCounts?: Record<string, number>
// Lazy per-owner loading (top-level folders and users only): expanding an owner
// loads its items on demand and paginates within it. `ownerLoad` keys are full
// path prefixes (`f/<name>` / `u/<name>`).
// Lazy loading state, keyed by the full path prefix a node covers: `f/<name>` /
// `u/<name>` for a top-level owner, `<parent>/<subfolder>` deeper. A node paginates
// within its own prefix, so a subfolder can be completed without paging everything
// its owner holds.
ownerLoad?: Record<
string,
{ cursor?: string; hasMore: boolean; loading: boolean; loaded: boolean; count: number }
{ cursor?: string; hasMore: boolean; loading: boolean; loaded: boolean }
>
onExpandOwner?: (owner: string, more?: boolean) => void
onCollapseOwner?: (owner: string) => void
onExpandOwner?: (prefix: string, more?: boolean) => void
onCollapseOwner?: (prefix: string) => void
// Position of this node among the rendered root nodes; "expand all" only
// auto-loads the first EXPAND_ALL_LOAD_LIMIT of them (see the effect below).
rootIndex?: number
// Path prefix of the parent node, so this one can name its own (`ownerLoad` and
// the listing endpoint are both keyed by full prefix). Unset at the top level.
parentPrefix?: string
// The nearest ancestor that was loaded directly still has unloaded pages, so what
// is grouped under this node is only part of it: counts render as "N+" and the
// node offers to load the rest of itself.
ancestorHasMore?: boolean
}
let {
@@ -44,12 +53,14 @@
ownerLoad,
onExpandOwner,
onCollapseOwner,
rootIndex = 0
rootIndex = 0,
parentPrefix,
ancestorHasMore = false
}: Props = $props()
// Bounds the request burst from "expand all": however many root owners are rendered
// (nbDisplayed can grow via "load 30 more"), it fetches at most this many. Lazy
// owners past the cap stay collapsed and load on a single click (see the effect).
// Bounds the request burst from "expand all": however many root owners the tree
// renders (its slice grows as you scroll), it fetches at most this many. Lazy owners
// past the cap stay collapsed and load on a single click (see the effect).
const EXPAND_ALL_LOAD_LIMIT = 20
const isFolderItem = (i: typeof item): i is FolderItem => i && 'folderName' in i
@@ -66,7 +77,11 @@
(pipelineFolders?.has(item.folderName) ?? false)
)
let opened: boolean = $state(true)
// Starts closed and is opened by the collapseAll effect below, which runs after the
// first render. Starting open instead would mount this node's whole loaded subtree for
// that one frame — thousands of rows once a few pages are in, since a node in lazy mode
// renders every loaded row — every time an ancestor opens or remounts.
let opened: boolean = $state(false)
// Full path prefix of this node when it's a top-level owner (folder or user).
let ownerKey = $derived(
@@ -78,29 +93,63 @@
: undefined
: undefined
)
// Full path prefix this node covers, at any depth: the owner at the top level, then
// the parent's prefix plus this folder's segment. It is what both `ownerLoad` and the
// listing endpoint's `pathStart` are keyed by.
let nodePrefix = $derived(
depth === 0
? ownerKey
: parentPrefix != undefined && isFolder(item)
? `${parentPrefix}/${item.folderName}`
: undefined
)
// A top-level owner in lazy mode: its items load on demand into a separate store
// (ownerLoad tracks per-owner state), so its count/pagination differ from a node
// (ownerLoad tracks per-prefix state), so its count/pagination differ from a node
// whose items are already grouped from the loaded window.
let isLazyOwner = $derived(ownerKey != undefined && ownerLoad != undefined)
let ownerState = $derived(ownerKey != undefined ? ownerLoad?.[ownerKey] : undefined)
// This node's own load state — set once it has been loaded directly (an owner on
// expand, a subfolder only when its "Load more" was used).
let nodeState = $derived(nodePrefix != undefined ? ownerLoad?.[nodePrefix] : undefined)
// Whether rows under this node are still incomplete: its own pagination once it has
// been loaded directly, otherwise whatever its nearest loaded ancestor reports.
let nodeHasMore = $derived(nodeState?.loaded ? nodeState.hasMore : ancestorHasMore)
// Rows loaded under this node. Its children count a subfolder as one entry, which
// would label a folder holding 133 rows in one subfolder as "1 item"; every count this
// node shows (badge and pager alike) means leaves, so they can't contradict.
let loadedHere = $derived(isFolder(item) || isUser(item) ? countLeaves(item) : 0)
// The owner's runnable count alone (no pipeline row), so it lines up with the rows
// actually fetched for it — what the partial-load footer compares against. An owner
// missing from `ownerCounts` holds nothing (the response omits those), so it reads as
// 0 rather than unknown. Floored by what is already rendered under the node: the count
// can come in under that (it can miss an item shared individually out of a folder the
// viewer isn't in), and a total below the rows beneath it reads as a bug. The owner
// chips floor the same way.
let ownerTotal = $derived(
ownerKey != undefined && ownerCounts != undefined
? Math.max(ownerCounts[ownerKey] ?? 0, loadedHere)
: undefined
)
// What this owner renders, known without expanding it. Preferred over the loaded
// rows, which are one page deep and count a subfolder as a single child. An owner
// missing from `ownerCounts` holds nothing (the response omits those), so it reads
// as 0 rather than unknown. The pipeline entry is a row of its own and its member
// scripts are excluded from the count, so it adds one where it renders.
let ownerCount = $derived.by(() => {
if (ownerKey == undefined || ownerCounts == undefined) return undefined
return (ownerCounts[ownerKey] ?? 0) + (hasPipeline ? 1 : 0)
})
// rows, which are one page deep and count a subfolder as a single child. The pipeline
// entry is a row of its own and its member scripts are excluded from the count, so it
// adds one where it renders.
let ownerCount = $derived(
ownerTotal != undefined ? ownerTotal + (hasPipeline ? 1 : 0) : undefined
)
let showMax = $state(15)
// A lazy owner paginates server-side ("Load more"), so when opened on its own it
// shows all its already-loaded rows (no second, confusing client "Show more").
// EXCEPT under "expand all" (collapseAll=false), which opens every visible owner at
// once — rendering all of each would be thousands of rows and freeze the tab — so
// there we cap to the client slice and let "Show more" reveal the rest per owner.
// How many children a node renders before "Show more". Kept well above a screenful
// so a subfolder's contents don't look truncated, but bounded: under "expand all"
// this applies to every open owner at once (see effectiveMax).
let showMax = $state(30)
// A node that paginates server-side ("Load more") shows all its already-loaded rows
// when opened on its own, so there is one control to reach the rest and not a second,
// confusing client "Show more" in front of it. EXCEPT under "expand all"
// (collapseAll=false), which opens every visible node at once — rendering all of each
// would be thousands of rows and freeze the tab — so there we cap to the client slice
// and let "Show more" reveal the rest per node.
let effectiveMax = $derived(
isLazyOwner && ownerState != undefined && (isFolder(item) || isUser(item))
ownerLoad != undefined && nodePrefix != undefined && (isFolder(item) || isUser(item))
? collapseAll
? item.items.length
: Math.min(item.items.length, showMax)
@@ -141,7 +190,9 @@
// stays registered and every later reload re-fetches it forever. Remounting
// re-registers it (see the collapseAll effect above).
onDestroy(() => {
const key = untrack(() => ownerKey)
// nodePrefix, not ownerKey: a subfolder that was loaded directly is tracked under
// its own prefix and has to be untracked the same way.
const key = untrack(() => nodePrefix)
if (key != undefined) onCollapseOwner?.(key)
})
@@ -155,9 +206,21 @@
if (now - lastToggle < 300) return
lastToggle = now
opened = !opened
if (ownerKey != undefined) {
if (opened) onExpandOwner?.(ownerKey)
else onCollapseOwner?.(ownerKey)
if (opened) {
// A top-level owner loads on expand. A subfolder's rows come from an ancestor's
// pages, so opening one must not fire a request per subfolder — its own "Load
// more" is the explicit way to complete it. One exception, and it costs nothing:
// a subfolder that has paged itself — `nodeState` exists once it has, including
// while that first load is still in flight — re-registers so later reloads keep
// refreshing the rows it is showing, instead of letting a parent refresh silently
// truncate them. loadOwnerItems starts no second request for either state.
if (nodePrefix != undefined && (ownerKey != undefined || nodeState != undefined))
onExpandOwner?.(nodePrefix)
} else if (nodePrefix != undefined) {
// Any depth: a closed node stays mounted, so without this a subfolder that
// had paged itself would keep being re-fetched by every later reload while
// none of its rows are on screen.
onCollapseOwner?.(nodePrefix)
}
}
</script>
@@ -195,10 +258,13 @@
<!-- Lazy owner not expanded yet and no count for it: its true item count
is unknown until loaded, so showing "(0 items)" would be misleading. -->
&nbsp;
{:else if isLazyOwner && ownerState?.hasMore}
({item.items.length}+ items)
{:else if nodeHasMore}
<!-- Partial: this node still has pages to load (its own once it has been
loaded directly, otherwise its ancestor's), so what is grouped under
it is only what has arrived so far. -->
({loadedHere}+ items)
{:else}
({pluralize(item.items.length, ' item')})
({pluralize(loadedHere, ' item')})
{/if}
</div>
</div>
@@ -232,6 +298,11 @@
{collapseAll}
item={subItem}
{pipelineFolders}
{ownerLoad}
{onExpandOwner}
{onCollapseOwner}
parentPrefix={nodePrefix}
ancestorHasMore={nodeHasMore}
on:scriptChanged
on:flowChanged
on:appChanged
@@ -242,33 +313,56 @@
/>
{/each}
{#if effectiveMax < item.items.length}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="text-center text-xs py-2 text-secondary cursor-pointer hover:text-primary"
onclick={() => {
showMax += Math.min(30, item.items.length - showMax)
}}
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(depth + 1) * 16}px;"
>
Show more ({showMax}/{item.items.length})
<!-- Rows, not items: this slices the node's own entries, where a subfolder
is one row standing for everything under it. -->
<span class="text-xs text-secondary">
Showing {effectiveMax} of {item.items.length} loaded rows
</span>
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => {
showMax += Math.min(30, item.items.length - showMax)
}}
>
Show more
</Button>
</div>
{/if}
{#if ownerKey != undefined}
{#if ownerState?.loading && item.items.length === 0}
{#if nodePrefix != undefined && ownerLoad != undefined}
{#if nodeState?.loading && item.items.length === 0}
<!-- Show the spinner only on the first load, when there's nothing yet. A
re-sort/re-filter re-fetch keeps the old rows visible and swaps them
in place, so flashing "Loading…" under them would just be noise. -->
<div class="text-center text-xs py-2 text-secondary">Loading…</div>
{:else if !ownerState?.loading && ownerState?.hasMore && effectiveMax >= item.items.length}
<!-- Fetch the next server page only once every already-loaded row is shown
(in "expand all" the client "Show more" above reveals those first). -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{:else if nodeHasMore && effectiveMax >= item.items.length}
<!-- Every folder pages within its own prefix, so completing a subfolder
doesn't mean paging everything its owner holds. Shown only once every
already-loaded row is (in "expand all" the client "Show more" above
reveals those first), and spelling out the counts is the point:
without them this reads as an optional extra rather than as rows
still missing. -->
<div
class="text-center text-xs py-2 text-primary cursor-pointer hover:text-emphasis"
onclick={() => ownerKey != undefined && onExpandOwner?.(ownerKey, true)}
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(depth + 1) * 16}px;"
>
Load more in {ownerKey} ({ownerState?.count ?? item.items.length} loaded)
<span class="text-xs text-secondary">
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix}
</span>
<Button
unifiedSize="sm"
variant="subtle"
loading={nodeState?.loading}
on:click={() =>
nodePrefix != undefined &&
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false)}
>
Load more
</Button>
</div>
{/if}
{/if}
@@ -2,11 +2,11 @@
import { untrack } from 'svelte'
import TreeView from './TreeView.svelte'
import { groupItems, type ItemType } from './treeViewUtils'
import { Button } from '$lib/components/common'
interface Props {
collapseAll: boolean
showCode: (path: string, summary: string) => void
nbDisplayed: number
items: ItemType[] | undefined
isSearching?: boolean
pipelineFolders?: Set<string>
@@ -33,7 +33,7 @@
selfUsername?: string
ownerLoad?: Record<
string,
{ cursor?: string; hasMore: boolean; loading: boolean; loaded: boolean; count: number }
{ cursor?: string; hasMore: boolean; loading: boolean; loaded: boolean }
>
onExpandOwner?: (owner: string, more?: boolean) => void
onCollapseOwner?: (owner: string) => void
@@ -42,7 +42,6 @@
let {
collapseAll,
showCode,
nbDisplayed = $bindable(),
items,
isSearching = false,
pipelineFolders,
@@ -59,6 +58,17 @@
onCollapseOwner
}: Props = $props()
// How many root nodes render at once. A root node is a collapsed owner row that
// fetches nothing until expanded, so a large slice costs a row each and no request
// — and an owner sliced off the end is indistinguishable from one that doesn't
// exist, so keep it well above the number of folders a workspace typically has.
const ROOT_PAGE = 100
// Ceiling on what scrolling alone reveals: root rows aren't virtualized, so on a
// workspace with thousands of owners one long scroll gesture would otherwise mount
// every one of them. Past this the footer stays put and its button reveals the rest.
const AUTO_REVEAL_LIMIT = 500
let nbDisplayed = $state(ROOT_PAGE)
let groupedItems: ReturnType<typeof groupItems> | 'loading' = $state('loading')
$effect(() => {
items
@@ -140,6 +150,29 @@
groupedItems = grouped
})
})
let footerEl: HTMLDivElement | undefined = $state()
// Reveal the next slice of root nodes as the footer comes into view. Only the
// client-side slice auto-grows — those nodes are already grouped and render
// collapsed, so this issues no request; paging the server stays behind the button.
$effect(() => {
const el = footerEl
if (!el) return
const observer = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return
const grouped = groupedItems
if (!Array.isArray(grouped) || nbDisplayed >= grouped.length) return
if (nbDisplayed >= AUTO_REVEAL_LIMIT) return
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, grouped.length)
// Revealing more doesn't change whether the footer intersects, so no further
// callback would fire and scrolling would stall with rows left unrevealed.
// Re-observing re-delivers the current intersection after the rows render.
observer.unobserve(el)
observer.observe(el)
})
observer.observe(el)
return () => observer.disconnect()
})
</script>
{#if groupedItems === 'loading'}
@@ -162,6 +195,7 @@
{item}
{pipelineFolders}
{ownerCounts}
ancestorHasMore={hasMoreServer}
{ownerLoad}
{onExpandOwner}
{onCollapseOwner}
@@ -174,19 +208,34 @@
/>
{/if}
{/each}
{#if nbDisplayed < groupedItems.length || hasMoreServer}
<!-- Last row of the tree's own frame, not a caption under it: what is missing
has to read as part of the list to be noticed at all. -->
<div
bind:this={footerEl}
class="px-4 py-3 flex flex-row items-center justify-between gap-4 bg-surface-secondary"
>
<span class="text-xs text-secondary">
{#if nbDisplayed < groupedItems.length}
Showing {nbDisplayed} of {groupedItems.length} folders and users
{:else}
<!-- Scoped to one owner: the tree groups the paged browse stream, so what
is missing is items, not root nodes. -->
Not all items are loaded yet
{/if}
</span>
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => {
if (nbDisplayed < groupedItems.length)
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, groupedItems.length)
else onLoadMore?.()
}}
>
{nbDisplayed < groupedItems.length ? 'Show more' : 'Load more'}
</Button>
</div>
{/if}
</div>
{#if nbDisplayed < groupedItems.length || hasMoreServer}
<span class="text-xs font-normal text-secondary"
>{Math.min(nbDisplayed, groupedItems.length)} root nodes{hasMoreServer
? ''
: ` out of ${groupedItems.length}`}
<button
class="ml-4 text-xs font-normal text-primary hover:text-emphasis"
onclick={() => {
if (nbDisplayed < groupedItems.length) nbDisplayed += 30
else onLoadMore?.()
}}>load 30 more</button
></span
>
{/if}
{/if}
@@ -42,6 +42,14 @@ export function effectivePath(item: {
return (item.draft_only && item.draft_path) || item.path
}
/** Rows loaded under a node, counting nested subfolders' rows rather than the subfolder
* as one child — what "N items are on screen here" means to someone reading the tree. */
export function countLeaves(node: FolderItem | UserItem): number {
let n = 0
for (const child of node.items) n += 'items' in child ? countLeaves(child) : 1
return n
}
function insertItemInFolder(
root: (ItemType | FolderItem | UserItem)[],
item: ItemType,