mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 08:02:44 +00:00
feat: add a navigation picker after the breadcrumb's last segment
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
8b9222d778
commit
43aab55bfb
@@ -0,0 +1,98 @@
|
||||
<!--
|
||||
@component
|
||||
The chevron after the breadcrumb's last segment: it opens the same picker a session's preview tab
|
||||
uses — the workspace pages, then the folders with their flows, scripts and apps — and picking a
|
||||
row navigates the app there.
|
||||
|
||||
It lands where the page is: an item's folder with the item highlighted, or a page highlighted at
|
||||
the root, so moving to a sibling is one step rather than a drill from the top.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import PreviewRouterPicker, { type Scope } from './sessions/PreviewRouterPicker.svelte'
|
||||
import { pageKey, type PreviewTarget } from './sessions/previewRouter'
|
||||
import { editPathFor, leafKeyFor, viewPathFor } from './workspacePicker'
|
||||
import { base } from '$lib/base'
|
||||
import type { PageHeaderItem } from './pageHeaderRegistry.svelte'
|
||||
|
||||
let {
|
||||
item,
|
||||
pagePath,
|
||||
actingWorkspaceId,
|
||||
iconSize = 14
|
||||
}: {
|
||||
/** The item the page is about, to land in its folder. */
|
||||
item?: PageHeaderItem
|
||||
/** The route of a workspace page, to highlight it among the pages. */
|
||||
pagePath?: string
|
||||
/** Load from, and navigate within, this workspace rather than the one the app points at. */
|
||||
actingWorkspaceId?: string
|
||||
iconSize?: number
|
||||
} = $props()
|
||||
|
||||
let isOpen = $state(false)
|
||||
|
||||
// The tree files a path under its `f/<folder>` or `u/<user>` scope, then one branch per
|
||||
// subfolder, each keyed by its full path — so the item's own folder is its path minus the name.
|
||||
const initialScope = $derived.by<Scope>(() => {
|
||||
const parts = item?.path?.split('/') ?? []
|
||||
return parts.length >= 3 ? { kind: 'all', dir: parts.slice(0, -1).join('/') } : undefined
|
||||
})
|
||||
const initialHighlight = $derived(
|
||||
item?.kind && item.path
|
||||
? leafKeyFor(item.kind, item.path)
|
||||
: pagePath
|
||||
? pageKey(pagePath)
|
||||
: undefined
|
||||
)
|
||||
|
||||
// A page acting on another workspace than the app's (a session in a fork) keeps acting on it:
|
||||
// the route reads `?workspace=` before the store.
|
||||
function withWorkspace(href: string): string {
|
||||
if (!actingWorkspaceId) return href
|
||||
const sep = href.includes('?') ? '&' : '?'
|
||||
return `${href}${sep}workspace=${encodeURIComponent(actingWorkspaceId)}`
|
||||
}
|
||||
|
||||
function hrefFor(target: PreviewTarget): string | undefined {
|
||||
if (target.type === 'page') return target.href
|
||||
if (target.type === 'item') {
|
||||
const path = target.mode === 'view' ? viewPathFor(target.item) : editPathFor(target.item)
|
||||
return `${base}${path}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pick(target: PreviewTarget) {
|
||||
isOpen = false
|
||||
const href = hrefFor(target)
|
||||
if (href) goto(withWorkspace(href))
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
usePointerDownOutside
|
||||
excludeSelectors=".drawer"
|
||||
disableFocusTrap
|
||||
closeOnOtherPopoverOpen
|
||||
bind:isOpen
|
||||
openFocus="[data-workspace-picker-search]"
|
||||
contentClasses="flex flex-col overflow-hidden"
|
||||
class="shrink-0 flex items-center p-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
|
||||
triggerAttrs={{ 'aria-label': 'Navigate to', title: 'Navigate to' }}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<ChevronDown size={iconSize} class="flex-shrink-0" />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<PreviewRouterPicker
|
||||
{initialScope}
|
||||
{initialHighlight}
|
||||
workspaceId={actingWorkspaceId}
|
||||
onPick={pick}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -18,7 +18,7 @@ not this component) — so the lead can run the whole line.
|
||||
import MeltButton from '$lib/components/meltComponents/MeltButton.svelte'
|
||||
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
|
||||
import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte'
|
||||
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
|
||||
import BreadcrumbNavPicker from './BreadcrumbNavPicker.svelte'
|
||||
import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
|
||||
import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
@@ -31,7 +31,7 @@ not this component) — so the lead can run the whole line.
|
||||
import { page } from '$app/state'
|
||||
import { navPageFor } from './sidebar/navPages'
|
||||
import { copyToClipboard, getContrastTextColor } from '$lib/utils'
|
||||
import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker'
|
||||
import { KIND_LABEL_LOWER } from '$lib/components/workspacePicker'
|
||||
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
@@ -236,6 +236,14 @@ not this component) — so the lead can run the whole line.
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet navPicker()}
|
||||
<!-- The page's own name is the last segment, so the chevron after it moves to a sibling page or
|
||||
item. Not in a session's preview band, which must not lead out of the host's workspace. -->
|
||||
{#if !nameOnly}
|
||||
<BreadcrumbNavPicker {item} pagePath={routePage?.path} {actingWorkspaceId} iconSize={ICON} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet slash(extra = '')}
|
||||
<!-- `extra` tops the workspace part's own 4px gap up to the 6px the breadcrumb puts around its
|
||||
other separators, so the slash before a fork is spaced like every other slash on the line. -->
|
||||
@@ -307,10 +315,10 @@ not this component) — so the lead can run the whole line.
|
||||
{/snippet}
|
||||
{#snippet children({ item: menuItem })}
|
||||
<WorkspacePickerBody
|
||||
item={menuItem}
|
||||
collapseFamilies={actingWorkspaceId != undefined}
|
||||
closeMenu={() => workspaceMenu?.close()}
|
||||
/>
|
||||
item={menuItem}
|
||||
collapseFamilies={actingWorkspaceId != undefined}
|
||||
closeMenu={() => workspaceMenu?.close()}
|
||||
/>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
@@ -361,6 +369,7 @@ not this component) — so the lead can run the whole line.
|
||||
{@render pathLabel()}
|
||||
</button>
|
||||
{/if}
|
||||
{#if itemIsLast}{@render navPicker()}{/if}
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{#if pathCopied}
|
||||
<span
|
||||
@@ -386,40 +395,47 @@ not this component) — so the lead can run the whole line.
|
||||
</span>
|
||||
{/if}
|
||||
{:else if section?.kind}
|
||||
<BreadcrumbSegment
|
||||
label={KIND_LABEL_LOWER[section.kind]}
|
||||
icon={sectionKindIcon}
|
||||
extraClass={twMerge(SEGMENT, LAST)}
|
||||
isCurrent
|
||||
initialHighlight={kindKey(section.kind)}
|
||||
initialScope={{ kind: section.kind }}
|
||||
onPick={() => {}}
|
||||
/>
|
||||
<div class="flex items-center min-w-0">
|
||||
<span
|
||||
class={twMerge(SEGMENT, LAST, 'hover:bg-transparent hover:text-emphasis')}
|
||||
aria-current="page"
|
||||
>
|
||||
{@render sectionKindIcon()}
|
||||
<span class="truncate">{KIND_LABEL_LOWER[section.kind]}</span>
|
||||
</span>
|
||||
{@render navPicker()}
|
||||
</div>
|
||||
{:else if section}
|
||||
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
|
||||
<span
|
||||
class={twMerge(
|
||||
SEGMENT,
|
||||
LAST,
|
||||
'hover:bg-transparent hover:text-emphasis',
|
||||
section.content ? '' : 'truncate'
|
||||
)}
|
||||
aria-current="page"
|
||||
>
|
||||
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
|
||||
</span>
|
||||
<div class="flex items-center min-w-0">
|
||||
<span
|
||||
class={twMerge(
|
||||
SEGMENT,
|
||||
LAST,
|
||||
'hover:bg-transparent hover:text-emphasis',
|
||||
section.content ? '' : 'truncate'
|
||||
)}
|
||||
aria-current="page"
|
||||
>
|
||||
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
|
||||
</span>
|
||||
{@render navPicker()}
|
||||
</div>
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{:else if routePage}
|
||||
{@const RouteIcon = routePage.icon}
|
||||
<!-- shrink-0: a page's own name is short and is the one part of the trail worth keeping
|
||||
whole, so the workspace and fork names give way first when the bar is full. -->
|
||||
<span
|
||||
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
|
||||
aria-current="page"
|
||||
>
|
||||
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
|
||||
<span class="truncate">{routePage.label}</span>
|
||||
</span>
|
||||
<div class="flex items-center shrink-0">
|
||||
<span
|
||||
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
|
||||
aria-current="page"
|
||||
>
|
||||
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
|
||||
<span class="truncate">{routePage.label}</span>
|
||||
</span>
|
||||
{@render navPicker()}
|
||||
</div>
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
@@ -12,6 +12,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
import { navDetached } from './sidebar/navDetached.svelte'
|
||||
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
|
||||
import NavBreadcrumb from './NavBreadcrumb.svelte'
|
||||
import BreadcrumbNavPicker from './BreadcrumbNavPicker.svelte'
|
||||
import { BAR_HEIGHT, pageHeader, PHONE_BAR } from './pageHeaderRegistry.svelte'
|
||||
import ContextBridge from './ContextBridge.svelte'
|
||||
import { afterNavigate } from '$app/navigation'
|
||||
@@ -179,6 +180,10 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
{:else}
|
||||
<span class="min-w-0 truncate text-xs font-medium text-emphasis">{item.summary}</span>
|
||||
{/if}
|
||||
<!-- The summary ends the trail here, so the breadcrumb's chevron follows it instead. -->
|
||||
{#if !navHidden}
|
||||
<BreadcrumbNavPicker {item} actingWorkspaceId={content?.actingWorkspaceId} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user