feat: group other users' spaces under a collapsible row in the home tree

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eAE5cpKPdtH9DNJm5TjcH
This commit is contained in:
Ruben Fiszel
2026-09-10 20:38:01 +02:00
co-authored by Claude Opus 5
parent 946756ae83
commit 5f75564fa8
3 changed files with 128 additions and 21 deletions
@@ -1849,6 +1849,7 @@
allUsers={treeInjectUsers}
ownerCounts={!searching && labelFilter == undefined ? ownerCounts : undefined}
selfUsername={$userStore?.username}
groupOtherUsers={treeLazyMode}
ownerLoad={treeLazyMode ? ownerLoad : undefined}
onExpandOwner={treeLazyMode ? loadOwnerItems : undefined}
onCollapseOwner={treeLazyMode ? collapseOwner : undefined}
@@ -43,6 +43,10 @@
// 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
// Visual nesting on top of `depth`. `depth` stays semantic (0 is a top-level owner
// that loads lazily), so an owner shown inside a grouping row is indented through
// this rather than by raising its depth.
indent?: number
}
let {
@@ -59,9 +63,12 @@
rootIndex = 0,
showEditButton = true,
parentPrefix,
ancestorHasMore = false
ancestorHasMore = false,
indent = 0
}: Props = $props()
let visualDepth = $derived(depth + indent)
// 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).
@@ -256,7 +263,7 @@
>
<div
class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')}
style={depth > 0 ? `padding-left: ${depth * 16}px;` : ''}
style={visualDepth > 0 ? `padding-left: ${visualDepth * 16}px;` : ''}
>
<div class="flex justify-center items-center">
{#if isUser(item)}
@@ -310,7 +317,7 @@
<a
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
style="padding-left: {(depth + 1) * 16}px;"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
<span class="text-xs font-medium text-emphasis">Pipeline</span>
@@ -335,12 +342,13 @@
{showCode}
{showEditButton}
depth={depth + 1}
{indent}
/>
{/each}
{#if effectiveMax < item.items.length}
<div
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;"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<!-- Rows, not items: this slices the node's own entries, where a subfolder
is one row standing for everything under it. -->
@@ -377,7 +385,7 @@
as rows still missing. -->
<div
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;"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<span class="text-xs text-secondary">
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix}
@@ -427,6 +435,6 @@
on:appChanged
on:rawAppChanged
on:reload
{depth}
depth={visualDepth}
/>
{/if}
@@ -1,8 +1,10 @@
<script lang="ts">
import { untrack } from 'svelte'
import TreeView from './TreeView.svelte'
import { groupItems, type ItemType } from './treeViewUtils'
import { countLeaves, groupItems, type ItemType, type UserItem } from './treeViewUtils'
import { Button } from '$lib/components/common'
import { ChevronDown, ChevronUp, Users } from 'lucide-svelte'
import { getLocalSetting, pluralize, storeLocalSetting } from '$lib/utils'
interface Props {
collapseAll: boolean
@@ -38,6 +40,10 @@
onExpandOwner?: (owner: string, more?: boolean, opts?: { all?: boolean }) => void
onCollapseOwner?: (owner: string) => void
showEditButton?: boolean
// Tuck every user space but `selfUsername`'s into one collapsible "Other users" row
// under it. Only for browsing: a search or filter must not hide its matches behind
// a closed row.
groupOtherUsers?: boolean
}
let {
@@ -57,7 +63,8 @@
ownerLoad,
onExpandOwner,
onCollapseOwner,
showEditButton = true
showEditButton = true,
groupOtherUsers = false
}: Props = $props()
// How many root nodes render at once. A root node is a collapsed owner row that
@@ -153,6 +160,65 @@
})
})
type RootNode = ReturnType<typeof groupItems>[number]
// `loadRank` is what "expand all" caps its requests by (see TreeView's rootIndex).
// Other users rank after every folder, so opening their row can't push the folders
// out of the auto-loaded set.
type RootRow =
| { kind: 'node'; node: RootNode; nested: boolean; loadRank: number }
| { kind: 'otherUsers'; users: number; items: number | undefined }
const OTHER_USERS_OPEN_SETTING_NAME = 'homeTreeOtherUsersOpen'
let otherUsersOpen = $state(getLocalSetting(OTHER_USERS_OPEN_SETTING_NAME) == 'true')
function toggleOtherUsers() {
otherUsersOpen = !otherUsersOpen
storeLocalSetting(OTHER_USERS_OPEN_SETTING_NAME, otherUsersOpen ? 'true' : undefined)
}
function isOtherUser(node: RootNode): node is UserItem {
return 'username' in node && node.username !== selfUsername
}
let rows: RootRow[] = $derived.by(() => {
if (!Array.isArray(groupedItems)) return []
const others =
groupOtherUsers && selfUsername != undefined ? groupedItems.filter(isOtherUser) : []
if (others.length === 0) {
return groupedItems.map(
(node, i): RootRow => ({ kind: 'node', node, nested: false, loadRank: i })
)
}
// `groupItems` orders users before folders, so with the others taken out the viewer's
// own space is what leads.
const main = groupedItems.filter((g) => !isOtherUser(g))
const lead = main.filter((g) => 'username' in g)
const rest = main.filter((g) => !('username' in g))
const items =
ownerCounts != undefined
? others.reduce(
(sum, u) => sum + Math.max(ownerCounts[`u/${u.username}`] ?? 0, countLeaves(u)),
0
)
: undefined
return [
...lead.map((node, i): RootRow => ({ kind: 'node', node, nested: false, loadRank: i })),
{ kind: 'otherUsers', users: others.length, items },
...(otherUsersOpen
? others.map(
(node, i): RootRow => ({ kind: 'node', node, nested: true, loadRank: main.length + i })
)
: []),
...rest.map(
(node, i): RootRow => ({ kind: 'node', node, nested: false, loadRank: lead.length + i })
)
]
})
// Owner rows only, for the footer: the "Other users" row is neither a folder nor a user.
let ownerRowCount = $derived(rows.filter((r) => r.kind === 'node').length)
let shownOwnerRowCount = $derived(
rows.slice(0, nbDisplayed).filter((r) => r.kind === 'node').length
)
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
@@ -162,10 +228,9 @@
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 >= rows.length) return
if (nbDisplayed >= AUTO_REVEAL_LIMIT) return
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, grouped.length)
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, rows.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.
@@ -188,13 +253,46 @@
</div>
{:else}
<div class="border rounded-md bg-surface-tertiary">
{#each groupedItems.slice(0, nbDisplayed) as item, rootIndex ('folderName' in item ? `f__${item.folderName}` : 'username' in item ? `u__${item.username}` : `i__${item.type}__${item.path}`)}
{#if item}
{#each rows.slice(0, nbDisplayed) as row (row.kind === 'otherUsers' ? 'other_users' : 'folderName' in row.node ? `f__${row.node.folderName}` : 'username' in row.node ? `u__${row.node.username}` : `i__${row.node.type}__${row.node.path}`)}
{#if row.kind === 'otherUsers'}
<!-- Same shape as an owner row, so it reads as part of the tree. It only reveals
the user rows under it: each still loads its own items when opened. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={toggleOtherUsers}
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
>
<div class="flex flex-row items-center gap-4">
<Users size={16} class="text-secondary" />
<div>
<span class="whitespace-nowrap text-xs text-emphasis font-semibold">Other users</span>
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
({pluralize(row.users, 'user')}{row.items != undefined
? ` · ${pluralize(row.items, 'item')}`
: ''})
</div>
</div>
</div>
<button
class="w-full flex flex-row-reverse"
aria-label="Other users"
aria-expanded={otherUsersOpen}
>
{#if otherUsersOpen}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
</div>
{:else}
<TreeView
{rootIndex}
rootIndex={row.loadRank}
indent={row.nested ? 1 : 0}
{isSearching}
{collapseAll}
{item}
item={row.node}
{pipelineFolders}
{ownerCounts}
ancestorHasMore={hasMoreServer}
@@ -211,7 +309,7 @@
/>
{/if}
{/each}
{#if nbDisplayed < groupedItems.length || hasMoreServer}
{#if nbDisplayed < rows.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
@@ -219,8 +317,8 @@
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
{#if nbDisplayed < rows.length}
Showing {shownOwnerRowCount} of {ownerRowCount} folders and users
{:else}
<!-- Scoped to one owner: the tree groups the paged browse stream, so what
is missing is items, not root nodes. -->
@@ -231,12 +329,12 @@
unifiedSize="sm"
variant="subtle"
on:click={() => {
if (nbDisplayed < groupedItems.length)
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, groupedItems.length)
if (nbDisplayed < rows.length)
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, rows.length)
else onLoadMore?.()
}}
>
{nbDisplayed < groupedItems.length ? 'Show more' : 'Load more'}
{nbDisplayed < rows.length ? 'Show more' : 'Load more'}
</Button>
</div>
{/if}