diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 3bbb175e35..0765dc82eb 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -690,18 +690,56 @@ if (!includeWithoutMain) f.push('library scripts hidden') return f }) - // Complete owner list: every folder (from the folder list, not just loaded - // pages) plus the user prefixes present in the loaded/filtered items. So a - // folder whose items are far down the stream is still a selectable chip. - let owners = $derived( - Array.from( - new Set([ - ...allFolderOwners, - ...allUserOwners, - ...(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? []) - ]) - ).sort() + // Pipeline folders qualify for a chip whenever a pipeline can render: the kind must admit + // one and no label filter may be active, since pipelines carry no labels. Unlike + // `visiblePipelineFolders` this ignores the selected owner — the chips are how you switch + // owners, so they must not narrow to the current one. + let chipPipelineFolders = $derived( + (itemKind === 'all' || itemKind === 'script') && labelFilter == undefined + ? pipelineFolders + : new Set() ) + // Owner chips: only the owners actually holding something the user can see, your own + // space first and the rest most-populated first. A chip for an empty owner filters to + // nothing, and a workspace's full folder and member lists are mostly those, so an owner + // counting 0 gets no chip at all — including your own space. + let owners = $derived.by(() => { + const self = $userStore?.username ? `u/${$userStore.username}` : undefined + const loaded = filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [] + if (ownerCounts == undefined) { + // Counts still in flight: the folder/user lists resolve first, so painting the full + // list here would show the wall this drops and snap to the ranked set a tick later. + if (!archived && ownerCountsRes.loading) return [] + // No counts (archived view, or the request failed): every owner, alphabetically. + return Array.from(new Set([...allFolderOwners, ...allUserOwners, ...loaded])).sort() + } + const counted = new Map(Object.entries(ownerCounts)) + // The pipeline is a row of its own and its member scripts are folded out of the + // count, so it adds one where it renders — as in the tree node's own label. + for (const f of chipPipelineFolders) counted.set(`f/${f}`, (counted.get(`f/${f}`) ?? 0) + 1) + // No owner counts below what the loaded window already shows for it: the endpoint + // leaves pipeline members out, and a listed item must keep its chip. + const onScreen = new Map() + for (const o of loaded) onScreen.set(o, (onScreen.get(o) ?? 0) + 1) + for (const [o, n] of onScreen) counted.set(o, Math.max(counted.get(o) ?? 0, n)) + return ( + [...counted.keys()] + // The user-folder restriction drops other users' rows from the list, so their chips + // would filter to nothing — the same rule `filterItemsPathsBaseOnUserFilters` applies. + .filter( + (o) => + !filterUserFolders || + !filterUserFoldersType || + o.startsWith('f/') || + (filterUserFoldersType === 'u/username and f/*' && o === self) + ) + .sort((a, b) => { + if (a === self) return -1 + if (b === self) return 1 + return (counted.get(b) ?? 0) - (counted.get(a) ?? 0) || cmp(a, b) + }) + ) + }) // Reload from the server whenever an input the endpoint resolves changes: order, // archived/library scope, kind, the selected owner/folder, or entering/leaving // search (see the reload effect below). Only the label filter and fuzzy ranking @@ -717,22 +755,16 @@ treeView && !searching && ownerFilter == undefined && labelFilter == undefined ) // How many runnables each owner (`f/` / `u/`) holds for this user, - // in one request. It labels every node up front — a lazy owner's own count is - // unknown until it's expanded — and lets the tree drop the owners holding - // nothing instead of listing every workspace folder. Owners with none are - // omitted from the response, so an absent key means empty. Skipped outside lazy - // mode (the other modes group already-loaded rows) and in the archived view, - // which the endpoint doesn't count. + // in one request. It labels every tree node up front — a lazy owner's own count is + // unknown until it's expanded — and lets both the tree and the owner chips drop the + // owners holding nothing instead of listing every workspace folder. Owners with none + // are omitted from the response, so an absent key means empty. Fetched in every mode + // (the chips are shown in all of them) except the archived view, which the endpoint + // doesn't count. let ownerCountsRes = resource( - [ - () => $workspaceStore, - () => treeLazyMode, - () => archived, - () => itemKind, - () => includeWithoutMain - ], - async ([ws, lazyMode, showArchived, kind, withoutMain]) => { - if (!ws || !lazyMode || showArchived) return undefined + [() => $workspaceStore, () => archived, () => itemKind, () => includeWithoutMain], + async ([ws, showArchived, kind, withoutMain]) => { + if (!ws || showArchived) return undefined try { const res = await ScriptService.countRunnablesByOwner({ workspace: ws, @@ -741,7 +773,7 @@ }) return res.counts } catch { - // Best-effort: without counts the tree shows every owner, as before. + // Best-effort: without counts the tree and the chips fall back to every owner. return undefined } } @@ -1390,6 +1422,7 @@ syncQuery bind:selectedFilter={ownerFilter} filters={owners} + maxDisplayed={20} bottomMargin={false} /> {#if allLabels.length > 0} diff --git a/frontend/src/lib/components/home/ListFilters.svelte b/frontend/src/lib/components/home/ListFilters.svelte index 34cc8b2cfd..0bbaaebf6b 100644 --- a/frontend/src/lib/components/home/ListFilters.svelte +++ b/frontend/src/lib/components/home/ListFilters.svelte @@ -11,6 +11,9 @@ queryName?: string syncQuery?: boolean bottomMargin?: boolean + // Keep only the first N filters visible, the rest behind a "…" toggle. Unset + // shows every one. + maxDisplayed?: number } let { @@ -19,7 +22,8 @@ resourceType = false, queryName = 'filter', syncQuery = false, - bottomMargin = true + bottomMargin = true, + maxDisplayed }: Props = $props() const queryChange: (value: URL) => void = (url: URL) => { @@ -69,11 +73,26 @@ : [selectedFilter, ...filters] : filters ) + + let expanded = $state(false) + let truncated = $derived.by(() => { + if (maxDisplayed == undefined || filtersAndSelected.length <= maxDisplayed) + return filtersAndSelected + const shown = filtersAndSelected.slice(0, maxDisplayed) + // Clicking the selected chip is how the filter is cleared, so it is never truncated + // away, however far down the order it sits. + if (selectedFilter && !shown.includes(selectedFilter)) shown.push(selectedFilter) + return shown + }) + // Derived from what is actually withheld, so the selected chip kept above never counts + // as hidden — otherwise the toggle offers to reveal a chip that is already on screen. + let hiddenCount = $derived(filtersAndSelected.length - truncated.length) + let displayedFilters = $derived(expanded ? filtersAndSelected : truncated) {#if Array.isArray(filtersAndSelected) && filtersAndSelected.length > 0}
- {#each filtersAndSelected as filter (filter)} + {#each displayedFilters as filter (filter)}
{/each} + {#if hiddenCount > 0} +
+ (expanded = !expanded)} + > + {expanded ? 'Show less' : '…'} + +
+ {/if}
{/if}