mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-16 00:03:02 +00:00
feat: list the user's own forks first in fork pickers (#11762)
* feat: list the user's own forks first in fork pickers Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: indent nested fork dividers and order the session rail tree the same way Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1fab283cf9
commit
009482a677
@@ -60,7 +60,7 @@
|
||||
import MenuButton, { sidebarClasses } from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -220,7 +220,7 @@
|
||||
// Workspace tree scoped to the current family (root + its forks), so the rail
|
||||
// only shows the workspaces in the family we're in — not every workspace.
|
||||
const workspaceTreeItems = $derived.by(() => {
|
||||
const all = buildWorkspaceHierarchy($userWorkspaces)
|
||||
const all = buildWorkspaceHierarchy($userWorkspaces, $userStore?.email)
|
||||
const currentRoot = $currentWorkspaceRootId
|
||||
if (!currentRoot) return all
|
||||
return all.filter((i) => workspaceRootId(i.workspace.id, $userWorkspaces) === currentRoot)
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
type UserWorkspace
|
||||
} from '$lib/stores'
|
||||
import {
|
||||
findWorkspaceDescendants,
|
||||
findCanonicalDevWorkspace,
|
||||
findDefaultForkBase,
|
||||
findWorkspaceRoot,
|
||||
buildWorkspaceHierarchy
|
||||
buildWorkspaceHierarchy,
|
||||
workspaceSubtreeRows
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
@@ -102,7 +102,11 @@
|
||||
})
|
||||
const forkableWorkspaces = $derived(forkableWorkspacesProp ?? ownForkable.current)
|
||||
const root = $derived(findWorkspaceRoot(effectiveId, forkableWorkspaces))
|
||||
const forks = $derived(root ? findWorkspaceDescendants(root.id, forkableWorkspaces) : [])
|
||||
// The same hierarchy the sidebar workspace menu builds: forks of forks follow their parent, and the
|
||||
// user's own forks lead each group of siblings.
|
||||
const hierarchy = $derived(buildWorkspaceHierarchy(forkableWorkspaces, $userStore?.email))
|
||||
const forkRows = $derived(root ? workspaceSubtreeRows(root.id, hierarchy) : [])
|
||||
const forks = $derived(forkRows.map((h) => h.workspace))
|
||||
|
||||
// The family's canonical dev workspace, if any — still used for gating (a forking-locked root can be
|
||||
// forked via its dev) and as a selectable base with a "dev" badge.
|
||||
@@ -126,13 +130,10 @@
|
||||
}))
|
||||
)
|
||||
|
||||
// Depth of each workspace within its family (root = 0, its forks = 1, forks of forks = 2, …), from
|
||||
// the shared hierarchy builder — the same one the sidebar workspace menu uses — so the list indents
|
||||
// forks of forks under their parent the same way. `forks` is a DFS of descendants (parent before
|
||||
// child), so indenting each row by its depth nests it under its parent.
|
||||
const familyDepths = $derived(
|
||||
new Map(buildWorkspaceHierarchy(forkableWorkspaces).map((h) => [h.workspace.id, h.depth]))
|
||||
)
|
||||
// Depth of each workspace within its family (root = 0, its forks = 1, forks of forks = 2, …).
|
||||
// `forks` is a DFS (parent before child), so indenting each row by its depth nests it under its
|
||||
// parent, the same way the sidebar menu does.
|
||||
const familyDepths = $derived(new Map(hierarchy.map((h) => [h.workspace.id, h.depth])))
|
||||
// Extra left padding (on top of the row's base px-3) to nest a workspace one step per depth level,
|
||||
// matching the sidebar menu's `depth * 16px`.
|
||||
function indentStyle(id: string): string | undefined {
|
||||
@@ -429,9 +430,16 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#each forks as f, fi (f.id)}
|
||||
{#each forkRows as { workspace: f, separatorBefore }, fi (f.id)}
|
||||
{@const forkIdx = (root ? 1 : 0) + fi}
|
||||
{@const accentStyle = forkAccentStyle(f.color)}
|
||||
{#if separatorBefore}
|
||||
<div
|
||||
class="my-1 border-t border-border-light shrink-0"
|
||||
style:margin-left={`${((familyDepths.get(f.id) ?? 1) - 1) * 16}px`}
|
||||
role="separator"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="relative group shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -136,7 +136,9 @@
|
||||
// (standalone pages such as svix webhook creation), so
|
||||
// there forks must stay directly selectable — list the full hierarchy
|
||||
// unconditionally.
|
||||
const hierarchy = $derived($userWorkspaces ? buildWorkspaceHierarchy($userWorkspaces) : [])
|
||||
const hierarchy = $derived(
|
||||
$userWorkspaces ? buildWorkspaceHierarchy($userWorkspaces, $userStore?.email) : []
|
||||
)
|
||||
const expandedFamilies = new SvelteSet<string>()
|
||||
// Root ids with at least one fork — only they get the expand chevron.
|
||||
// hierarchy is a DFS (parent before child), so a depth>0 row belongs to the
|
||||
@@ -243,9 +245,16 @@
|
||||
<!-- The list scrolls internally so the sections below (+ Workspace,
|
||||
Workspace fork, All workspaces) stay visible however long it gets. -->
|
||||
<div class="py-1 overflow-y-auto max-h-[min(50vh,26rem)]">
|
||||
{#each familyWorkspaces as { workspace, depth, isForked, parentName }}
|
||||
{#each familyWorkspaces as { workspace, depth, isForked, parentName, separatorBefore } (workspace.id)}
|
||||
{@const isActive = $workspaceStore === workspace.id}
|
||||
{@const forkAccent = isForked ? forkAccentStyle(workspace.color) : undefined}
|
||||
{#if separatorBefore}
|
||||
<div
|
||||
class="my-1 border-t border-border-light"
|
||||
style:margin-left={`${(depth - 1) * 16}px`}
|
||||
role="separator"
|
||||
></div>
|
||||
{/if}
|
||||
<!-- Selection is a trailing tick (single-choice picker convention). A
|
||||
collapsed root carries the tick while one of its forks is active —
|
||||
the fork row is hidden and its family is the only trace of the
|
||||
@@ -381,11 +390,7 @@
|
||||
{/if}
|
||||
{#if canManageWorkspace && !strictWorkspaceSelect}
|
||||
<div class="py-1" role="none">
|
||||
<MenuItem
|
||||
href="/workspace_settings?workspace={$workspaceStore}"
|
||||
class={itemClass}
|
||||
{item}
|
||||
>
|
||||
<MenuItem href="/workspace_settings?workspace={$workspaceStore}" class={itemClass} {item}>
|
||||
<Settings size={16} />
|
||||
{(activeWorkspace && ambiguousNames.has(activeWorkspace.name)
|
||||
? activeWorkspace.id
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildWorkspaceHierarchy,
|
||||
devWorkspacesInChainAbove,
|
||||
findDefaultForkBase,
|
||||
findWorkspaceAncestors,
|
||||
findWorkspaceRoot
|
||||
findWorkspaceRoot,
|
||||
workspaceSubtreeRows
|
||||
} from './workspaceHierarchy'
|
||||
import type { UserWorkspace } from '../stores'
|
||||
|
||||
@@ -75,3 +77,39 @@ describe('devWorkspacesInChainAbove', () => {
|
||||
expect(devWorkspacesInChainAbove('prod', nestedFamily)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWorkspaceHierarchy', () => {
|
||||
const mine = { created_by: 'me@x.com' }
|
||||
const theirs = { created_by: 'other@x.com' }
|
||||
const forks = [
|
||||
ws('wm-fork-a', 'prod', theirs),
|
||||
ws('wm-fork-b', 'prod', theirs),
|
||||
ws('wm-fork-c', 'prod', theirs),
|
||||
ws('wm-fork-y', 'prod', mine),
|
||||
ws('wm-fork-z', 'prod', mine)
|
||||
]
|
||||
const ids = (rows: ReturnType<typeof buildWorkspaceHierarchy>) =>
|
||||
rows.map((h) => (h.separatorBefore ? '| ' : '') + h.workspace.id)
|
||||
|
||||
it("puts the user's own forks after the dev workspace, divided from the rest", () => {
|
||||
const all = [root, ws('devws', 'prod', { is_dev_workspace: true, ...mine }), ...forks]
|
||||
expect(ids(workspaceSubtreeRows('prod', buildWorkspaceHierarchy(all, 'me@x.com')))).toEqual([
|
||||
'devws',
|
||||
'wm-fork-y',
|
||||
'wm-fork-z',
|
||||
'| wm-fork-a',
|
||||
'wm-fork-b',
|
||||
'wm-fork-c'
|
||||
])
|
||||
})
|
||||
|
||||
it('draws no divider in a short list', () => {
|
||||
const all = [root, ...forks.slice(2)]
|
||||
expect(ids(buildWorkspaceHierarchy(all, 'me@x.com'))).toEqual([
|
||||
'prod',
|
||||
'wm-fork-y',
|
||||
'wm-fork-z',
|
||||
'wm-fork-c'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,14 +6,26 @@ export interface WorkspaceHierarchyItem {
|
||||
isForked: boolean
|
||||
parentName?: string
|
||||
hasChildren: boolean
|
||||
/** First fork after the caller's own forks among its siblings; pickers draw a divider above it. */
|
||||
separatorBefore?: boolean
|
||||
}
|
||||
|
||||
/** Sibling forks below this count stay undivided: a divider in a short list is noise. */
|
||||
const OWNED_FORKS_SEPARATOR_MIN_SIBLINGS = 5
|
||||
|
||||
/**
|
||||
* Builds a hierarchical structure from a flat array of workspaces.
|
||||
* Supports unlimited nesting levels (fork of fork of fork...).
|
||||
* Returns a flattened array with hierarchy metadata for easy rendering.
|
||||
*
|
||||
* With `currentUserEmail`, the forks that user created lead each sibling group (after the dev
|
||||
* workspace). When every admin and developer is added to new forks, a family lists everyone's forks,
|
||||
* and without this the user's own are scattered alphabetically among them.
|
||||
*/
|
||||
export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceHierarchyItem[] {
|
||||
export function buildWorkspaceHierarchy(
|
||||
workspaces: UserWorkspace[],
|
||||
currentUserEmail?: string | null
|
||||
): WorkspaceHierarchyItem[] {
|
||||
if (!workspaces || workspaces.length === 0) {
|
||||
return []
|
||||
}
|
||||
@@ -45,12 +57,15 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
|
||||
|
||||
const result: WorkspaceHierarchyItem[] = []
|
||||
|
||||
const isOwnedFork = (w: UserWorkspace) => !w.is_dev_workspace && isForkOwner(w, currentUserEmail)
|
||||
|
||||
// Recursively build the hierarchy
|
||||
function addWorkspaceAndChildren(
|
||||
workspace: UserWorkspace,
|
||||
depth: number,
|
||||
isForked: boolean,
|
||||
parentName?: string
|
||||
parentName?: string,
|
||||
separatorBefore?: boolean
|
||||
) {
|
||||
// Add the current workspace
|
||||
result.push({
|
||||
@@ -58,19 +73,31 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
|
||||
depth,
|
||||
isForked,
|
||||
parentName,
|
||||
hasChildren: hasChildrenSet.has(workspace.id)
|
||||
hasChildren: hasChildrenSet.has(workspace.id),
|
||||
separatorBefore
|
||||
})
|
||||
|
||||
// Add its children: the canonical dev workspace first, then throwaway forks by name.
|
||||
const children = childrenMap.get(workspace.id) || []
|
||||
children
|
||||
.sort((a, b) => {
|
||||
if (!!a.is_dev_workspace !== !!b.is_dev_workspace) return a.is_dev_workspace ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
.forEach((child) => {
|
||||
addWorkspaceAndChildren(child, depth + 1, true, workspace.name)
|
||||
})
|
||||
// Add its children: the canonical dev workspace first, then the user's own forks, then the
|
||||
// other forks, each by name.
|
||||
const children = (childrenMap.get(workspace.id) || []).sort((a, b) => {
|
||||
if (!!a.is_dev_workspace !== !!b.is_dev_workspace) return a.is_dev_workspace ? -1 : 1
|
||||
if (isOwnedFork(a) !== isOwnedFork(b)) return isOwnedFork(a) ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
const forkCount = children.filter((c) => !c.is_dev_workspace).length
|
||||
const firstOtherFork =
|
||||
forkCount >= OWNED_FORKS_SEPARATOR_MIN_SIBLINGS && children.some(isOwnedFork)
|
||||
? children.find((c) => !c.is_dev_workspace && !isOwnedFork(c))
|
||||
: undefined
|
||||
children.forEach((child) => {
|
||||
addWorkspaceAndChildren(
|
||||
child,
|
||||
depth + 1,
|
||||
true,
|
||||
workspace.name,
|
||||
child === firstOtherFork || undefined
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Process root workspaces (sorted by name for consistency)
|
||||
@@ -89,6 +116,21 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* The hierarchy rows below `rootId` (the root itself excluded), in the order and with the depths
|
||||
* `buildWorkspaceHierarchy` gives them.
|
||||
*/
|
||||
export function workspaceSubtreeRows(
|
||||
rootId: string,
|
||||
hierarchy: WorkspaceHierarchyItem[]
|
||||
): WorkspaceHierarchyItem[] {
|
||||
const at = hierarchy.findIndex((h) => h.workspace.id === rootId)
|
||||
if (at < 0) return []
|
||||
const rootDepth = hierarchy[at].depth
|
||||
const end = hierarchy.findIndex((h, i) => i > at && h.depth <= rootDepth)
|
||||
return hierarchy.slice(at + 1, end < 0 ? undefined : end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the indentation padding based on depth.
|
||||
* Each level adds 24px of left padding.
|
||||
|
||||
Reference in New Issue
Block a user