feat: hide empty owners from the homepage chips and cap them at 20 (#10360)

* feat: hide empty owners from the homepage chips and cap them at 20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address review nits on the homepage owner chips

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-27 16:56:00 +02:00
committed by GitHub
parent 6e56ce11db
commit 4145e6f162
2 changed files with 94 additions and 29 deletions
@@ -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<string>()
)
// 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<string, number>(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<string, number>()
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/<folder>` / `u/<user>`) 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}
@@ -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)
</script>
{#if Array.isArray(filtersAndSelected) && filtersAndSelected.length > 0}
<div class={`gap-2 w-full flex flex-wrap ${bottomMargin ? 'my-4' : 'mt-4'}`}>
{#each filtersAndSelected as filter (filter)}
{#each displayedFilters as filter (filter)}
<div>
<Badge
class="inline-flex items-center gap-1 align-middle"
@@ -104,5 +123,18 @@
</Badge>
</div>
{/each}
{#if hiddenCount > 0}
<div>
<Badge
class="inline-flex items-center gap-1 align-middle"
color={'transparent'}
clickable
title={expanded ? 'Show fewer' : `Show ${hiddenCount} more`}
onclick={() => (expanded = !expanded)}
>
{expanded ? 'Show less' : '…'}
</Badge>
</div>
{/if}
</div>
{/if}