diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index a825323276..42f63054af 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -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/` node). + // Per-folder lazy loading for tree view, keyed by the full path prefix a node covers — + // an owner (`f/` / `u/`) 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/`) OR a user (`u/`) — 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>({}) let treeOwnerItems = $state([]) 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() // 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/` or `u/`. - // `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/`, `u/`, 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 { 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() + 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} ` / `u/` holds for this user, keyed by // full prefix. Known before an owner is expanded, unlike its loaded rows. ownerCounts?: Record - // 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/` / `u/`). + // Lazy loading state, keyed by the full path prefix a node covers: `f/` / + // `u/` for a top-level owner, `/` 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) } } @@ -195,10 +258,13 @@   - {:else if isLazyOwner && ownerState?.hasMore} - ({item.items.length}+ items) + {:else if nodeHasMore} + + ({loadedHere}+ items) {:else} - ({pluralize(item.items.length, ' item')}) + ({pluralize(loadedHere, ' item')}) {/if} @@ -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} - -
{ - 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}) + + + Showing {effectiveMax} of {item.items.length} loaded rows + +
{/if} - {#if ownerKey != undefined} - {#if ownerState?.loading && item.items.length === 0} + {#if nodePrefix != undefined && ownerLoad != undefined} + {#if nodeState?.loading && item.items.length === 0}
Loading…
- {:else if !ownerState?.loading && ownerState?.hasMore && effectiveMax >= item.items.length} - - - + {:else if nodeHasMore && effectiveMax >= item.items.length} +
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) + + Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix} + +
{/if} {/if} diff --git a/frontend/src/lib/components/home/TreeViewRoot.svelte b/frontend/src/lib/components/home/TreeViewRoot.svelte index da3ad985eb..7b785a69db 100644 --- a/frontend/src/lib/components/home/TreeViewRoot.svelte +++ b/frontend/src/lib/components/home/TreeViewRoot.svelte @@ -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 @@ -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 | '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() + }) {#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} + +
+ + {#if nbDisplayed < groupedItems.length} + Showing {nbDisplayed} of {groupedItems.length} folders and users + {:else} + + Not all items are loaded yet + {/if} + + +
+ {/if} - {#if nbDisplayed < groupedItems.length || hasMoreServer} - {Math.min(nbDisplayed, groupedItems.length)} root nodes{hasMoreServer - ? '' - : ` out of ${groupedItems.length}`} - - {/if} {/if} diff --git a/frontend/src/lib/components/home/treeViewUtils.ts b/frontend/src/lib/components/home/treeViewUtils.ts index 6103096547..2196d19409 100644 --- a/frontend/src/lib/components/home/treeViewUtils.ts +++ b/frontend/src/lib/components/home/treeViewUtils.ts @@ -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,