mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-07 00:02:18 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
{: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,
|
||||
|
||||
Reference in New Issue
Block a user