Files
windmill/frontend/src/lib/components/PageHeaderBar.svelte
T
9653318c17 feat: add a navigation picker after the breadcrumb's last segment (#11649)
* feat: add a navigation picker after the breadcrumb's last segment

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

* fix: open the navigation picker among the current page's siblings

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

* style: size the navigation chevron like the workspace chevron

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

* style: keep one-line picker lists at one-line row height

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

* fix: show the navigation chevron only once the item's path is known

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

* fix: highlight a draft in the navigation picker by its storage path

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

* fix: key the picker's current item on the header path outside editors

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

* feat: count navigation picker picks in feature_usage

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

* feat: shrink the page header in stages and let the bar fold page actions

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

* feat: fold the runs header through the bar and wrap its filter to a second row

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

* feat: truncate the run id and fold run actions into one menu on a narrow bar

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

* feat: keep an item's summary and drop its path on a narrow page header

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

* feat: show the full summary and path read-only when they cannot be edited

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

* feat: keep secondary detail actions in menus and the star beside the summary

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

* feat: show secondary detail actions as buttons while the bar has room for them

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

* chore: update ee-repo-ref to 346bd5417537b76591465e63b9d15bd081e9228c

This commit updates the EE repository reference after PR #868 was merged in windmill-ee-private.

Previous ee-repo-ref: 581b18cac39a4bee2671fd1aa86f91b9e702e8f5

New ee-repo-ref: 346bd5417537b76591465e63b9d15bd081e9228c

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-10-14 15:51:12 +02:00

394 lines
18 KiB
Svelte

<!--
@component
The bar every route wears, above the page content and beside the sidebar: the sidebar toggle, the
workspace picker, then whatever the route registered — an item's breadcrumb and summary, or a
section name — and the route's own buttons at the far end.
The row's height matches the sidebar's own header row, so the two read as one band.
-->
<script lang="ts">
import { ChevronDown, PanelLeft } from 'lucide-svelte'
import { Button } from '$lib/components/common'
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'
import { twMerge } from 'tailwind-merge'
import { onDestroy } from 'svelte'
import type { Attachment } from 'svelte/attachments'
import { BAR_STAGES, FLOOR, fitBar, type BarFit, type TrailMeasures } from './pageHeaderStages'
let {
navHidden = false,
panelled = false
}: {
navHidden?: boolean
/** The sidebar is a panel over the page rather than a rail beside it — detached, or a
* window too narrow to seat one. Either way this bar carries the switch that opens it. */
panelled?: boolean
} = $props()
const content = $derived(pageHeader.content)
const actions = $derived(pageHeader.actions)
const item = $derived(content?.item)
const section = $derived(content?.section)
const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary))
/** A summary control can be a placeholder offering to add one; the text is what names the item. */
const summaryNamesItem = $derived(!!item?.summary?.trim())
const pickerAfterSummary = $derived(hasSummary && !navHidden && !!item?.path)
/** A phone's bar: a tighter gutter around the same row. */
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
// What the bar drops, and when the page's actions fold, follows from the widths of what is in
// it (see pageHeaderStages): measured here and in NavBreadcrumb, never guessed per page.
/** `gap-1` between the bar's boxes. */
const GAP = 4
/** Actions that shrink or fill the bar have no width of their own to plan around: the trail
* leaves them this much and they take whatever is left. */
const FLEXIBLE_ACTIONS_RESERVE = 240
let trailMeasures = $state<TrailMeasures>({ trail: [], family: 0, fork: 0, name: 0 })
/** The summary held at its floor, with the dot before it. */
let summaryFloorWidth = $state(0)
let summaryTextWidth = $state(0)
/** A page's own summary control, last seen uncapped. */
let summaryContentWidth = $state(0)
let handleWidth = $state(0)
/** The actions box at each fold level it has been seen at, for this page. */
let actionWidthsByVersion = $state<Record<string, (number | undefined)[]>>({})
let actionsEpoch = $state(0)
const actionsVersion = $derived(content?.actionsVersion ?? '')
const actionWidths = $derived(actionWidthsByVersion[actionsVersion] ?? [])
const handleShown = $derived((navDetached.val || panelled) && !navHidden)
// The bar's own padding, in px, as the classes on the row below set it.
const padLeft = $derived(navHidden ? 16 : phone ? 4 : 8)
const padRight = $derived(content?.actionsFill ? 0 : phone ? 8 : 16)
const actionsRigid = $derived(!content?.actionsFlexible && !content?.actionsFill)
const FULL: BarFit = { stage: 0, fold: 0, caps: {}, scopesGive: false }
const fit = $derived.by((): BarFit => {
// Unmeasured counts as wide: the bar measures itself on mount, and starting narrow would fold
// a page's buttons for one frame and pop them back out the next.
if (pageHeader.barWidth === 0) return FULL
return fitBar({
barWidth: pageHeader.barWidth,
chrome: padLeft + padRight + (handleShown ? handleWidth + GAP : 0),
trail: trailMeasures.trail,
summary: BAR_STAGES.map(() => (hasSummary ? GAP + summaryFloorWidth : 0)),
actions:
actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE],
folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0,
earlyFolds: content?.earlyActionFolds,
summaryNamesItem,
natural: {
family: trailMeasures.family,
fork: trailMeasures.fork,
summary: hasSummary ? (item?.summaryContent ? summaryContentWidth : summaryTextWidth) : 0,
name: trailMeasures.name
}
})
})
const stage = $derived(BAR_STAGES[fit.stage])
/** The level alone: `fit` is a new object on every recompute, and the attachment re-measuring
* on each one would feed itself. */
const fold = $derived(fit.fold)
onDestroy(pageHeader.provideActionFold(() => fold))
// Re-attached whenever the fold level, the page or the actions' version changes, so the first
// reading there is taken even when the row comes out exactly as wide as it was before.
// The page's actions can change on screen — a run's Cancel goes once the job completes — and
// the other levels most likely changed with them, so a change here drops their widths: kept, a
// stale wider one would hold the row folded for good. The bar measures them again by trying.
function recordActions(fold: number, width: number) {
const version = actionsVersion
const widths = actionWidthsByVersion[version] ?? []
const seen = widths[fold]
// Unchanged, nothing is written: a write recomputes `fit`, and a reading that always wrote
// kept the bar measuring every frame.
if (seen === width) return
const next = seen !== undefined ? [] : [...widths]
next[fold] = width
actionWidthsByVersion[version] = next
}
function measureActions(
fold: number,
_epoch: number,
_version: string
): Attachment<HTMLElement> {
return (node) => {
const ro = new ResizeObserver(() => recordActions(fold, GAP + node.offsetWidth))
ro.observe(node)
return () => ro.disconnect()
}
}
// Until the row has been seen at the level the bar picked, it is laid out but not shown: the bar
// may find it does not fit and fold it further, and that should not flash.
const actionsUnmeasured = $derived(actionsRigid && actionWidths[fit.fold] === undefined)
// The band's edge is there to say "something is passing under me", so it is drawn only while
// something is. Scroll events do not bubble, but they do capture, so one listener on the window
// sees every box under the band — which matters because most pages scroll inside their own
// (the audit table, a detail page's panes) and never move this layout's own scroller.
let scrolledUnder = $state(false)
/** Sub-pixel layout and a border or two, between a box's top and the content box's. */
const AT_THE_TOP_PX = 4
function onScrollCapture(e: Event) {
const el = e.target as HTMLElement | null
if (!el || el.nodeType !== 1) return
const box = document.getElementById('content')
if (!box || !box.contains(el)) return
// The answer this event would give, before measuring anything. While it matches what the
// band already draws there is nothing to decide — which is the steady state of a scroll,
// and the two rects below each force a synchronous layout. This listener captures every
// scroller in the app (Monaco, the audit table, a run's logs), about once a frame while any
// of them moves, so the common path has to stay off the layout.
const next = el.scrollTop > 0
if (next === scrolledUnder) return
// Only a box that starts where the content does has anything passing under the band. The
// audit page's list begins 285px lower, behind its own chart and column headers, and a
// select's option list floats wherever it opens: neither moves anything under the header.
const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top
if (top > AT_THE_TOP_PX) return
scrolledUnder = next
}
// A page left while scrolled would otherwise hand its edge to the next one, which may have
// nothing to scroll at all. Its actions' widths go with it.
afterNavigate(({ from, to }) => {
scrolledUnder = false
if (from?.url.pathname !== to?.url.pathname) {
actionWidthsByVersion = {}
actionsEpoch++
}
})
const separator = $derived(pageHeader.content?.separator ?? 'onScroll')
const showEdge = $derived(separator === 'always' || (separator === 'onScroll' && scrolledUnder))
</script>
<svelte:window onscrollcapture={onScrollCapture} />
<!-- The band owns its bottom edge, so a page never draws a second one under it. A page says which
rule it wants (see `separator` in the registry); the default shows the edge only while
something is passing under the bar. An inset shadow rather than a border: a border would make
the row 45px and every page below it would move a pixel the moment the edge appeared. -->
<div
data-page-header
bind:clientWidth={() => pageHeader.barWidth, (w) => pageHeader.setBarWidth(w)}
class={twMerge(
'flex flex-col shrink-0 min-w-0 bg-surface transition-shadow duration-150',
showEdge &&
'shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_0_-1px_0_0_#374151] [html.github-dark_&]:shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))]'
)}
>
<div
class={twMerge(
'flex items-center gap-1 min-w-0',
// No flex-wrap. A wrapping row never squeezes anything — flex moves an item to the next line
// instead of taking it below its content width — so wrapping here would hand the trail a
// line of its own and leave it whole. What needs a line of its own asks for one through
// `belowBar`, at a fold the bar picks.
phone ? `${BAR_HEIGHT} pl-1 pr-2` : `${BAR_HEIGHT} pl-2 pr-4`,
// The filling surface is what meets the right edge, so the bar keeps no gutter of its own
// there.
content?.actionsFill && 'pr-0',
// The small left padding is there to sit a button against: the sidebar handle, or the
// workspace disc, both of which carry their own visual margin. Embedded there is neither,
// and the page's name would start as bare text 8px from the edge.
navHidden && 'pl-4'
)}
>
{#if (navDetached.val || panelled) && !navHidden}
<!-- Reveals the hidden sidebar, and only that: the card follows the pointer, holding while it
is on the handle, on the card, or in a menu either opened, and sliding back out when it
leaves. A click or tap pins it instead, until another one here, a press on the page or Esc
puts it back. Attaching
it for good belongs to the toggle in the sidebar's own footer, where detaching it
happened. Docked, the sidebar speaks for itself and nothing leads the bar. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-nav-handle
bind:clientWidth={handleWidth}
onmouseenter={() => navHandleSlot.open()}
onmouseleave={() => navHandleSlot.scheduleClose()}
>
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
does better than a label popping up over it. -->
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: PanelLeft }}
aria-label="Show sidebar"
selected={navHandleSlot.pinned}
aria-pressed={navHandleSlot.pinned}
onclick={() => navHandleSlot.toggle()}
/>
</div>
{/if}
<!-- The breadcrumb's parts are rigid and the bar sizes them (see `fit`): it drops whole parts
and caps the names left, so this box only shrinks past what they add up to on a bar too
narrow for even the last stage.
An embed (`navHidden`) keeps the page's own name but drops the workspace part around it:
the trail would offer to navigate the host's workspace, while the name is what says which
page the controls beside it belong to. -->
<div class="flex min-w-0">
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
the tree that named it, and the rename editor in there asks that tree who the acting user
is before it offers to rename anything. -->
<!-- Each fragment the band renders comes from another tree and reads that tree's state. The
registry already tolerates a registration that cannot answer while its owner's data is
being cleared (see `read`); the same window reaches rendering, and an error here would
otherwise travel to the root and blank the app rather than the one fragment. A
boundary per fragment keeps that to the fragment. -->
<svelte:boundary
onerror={(e) => console.error('page header: breadcrumb failed to render', e)}
>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
<NavBreadcrumb
{item}
{section}
{stage}
caps={fit.caps}
scopesGive={fit.scopesGive}
bind:measures={trailMeasures}
nameOnly={navHidden}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
/>
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{#if item && hasSummary}
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
editor tells the reader what they can already see. -->
<div class="flex items-center gap-1 shrink-0">
<!-- A dot after the path: the summary names the same item the path just located, it is not
another level of it. With the path dropped it follows the workspace, and the slash is
the trail's own separator. Fixed width, so either fits the width measured below. -->
<span class="shrink-0 w-2 text-center text-hint/40 text-xs" aria-hidden="true"
>{stage.path || !summaryNamesItem ? '·' : '/'}</span
>
{#if item.summaryContent}
<!-- Read only while uncapped: capped, the box says how wide the bar let it be, not how
wide the page's control wants to be. -->
<div
class="flex min-w-0 overflow-hidden"
style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`}
bind:clientWidth={
() => summaryContentWidth,
(w) => fit.caps.summary === undefined && (summaryContentWidth = w)
}
>
<svelte:boundary
onerror={(e) => console.error('page header: summary failed to render', e)}
>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render item.summaryContent()}
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{:else}
<span
class="truncate text-xs font-medium text-emphasis"
style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`}
>{item.summary}</span
>
{/if}
<!-- The summary ends the trail here, so the breadcrumb's chevron follows it instead. Not
before the path lands: the picker opens in the item's folder, which it reads once. -->
{#if pickerAfterSummary}
<BreadcrumbNavPicker {item} actingWorkspaceId={content?.actingWorkspaceId} />
{/if}
</div>
{/if}
{#if actions.length > 0}
<!-- A plain row of buttons holds its width (shrink-0): squeezing it only pushes buttons out
of the bar, so the bar makes room by dropping breadcrumb parts and then by asking the
page to fold (`actionFolds`). Flexible actions (the Runs filter row) are a control that
can give width back, which it can only do if this box may shrink. -->
<!-- `actionsFill`: the actions are a surface rather than a row, so they take the rest of the
bar and run to its right edge, and they stretch its full height because that surface
sits on the bottom edge. -->
<div
{@attach measureActions(fold, actionsEpoch, actionsVersion)}
class={twMerge(
'flex',
// pl-4: the gap between the breadcrumb and the filling surface is bare bar, so it
// belongs here rather than inside that surface, where it would be filled.
content?.actionsFill
? 'flex-1 min-w-0 self-stretch items-stretch pl-4'
: 'ml-auto items-center',
!content?.actionsFill && (actionsRigid ? 'shrink-0' : 'min-w-0'),
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4'),
actionsUnmeasured && 'invisible'
)}
>
{#each actions as entry, i (i)}
<svelte:boundary
onerror={(e) => console.error('page header: actions failed to render', e)}
>
{#key entry.contexts}
<ContextBridge contexts={entry.contexts}>
{@render entry.render()}
</ContextBridge>
{/key}
</svelte:boundary>
{/each}
</div>
{/if}
{#if item && hasSummary}
<!-- The summary at its floor, which is what a stage that keeps it needs; and whole, which is
what the bar caps it from. A page's own control stands in at the width last seen. -->
<div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert>
<div class="flex items-center gap-1 w-max" bind:clientWidth={summaryFloorWidth}>
<span class="shrink-0 w-2 text-center text-hint/40 text-xs">·</span>
{#if item.summaryContent}
<span style="width: {Math.min(summaryContentWidth, FLOOR.summary)}px"></span>
{:else}
<span class="truncate text-xs font-medium" style="max-width: {FLOOR.summary}px"
>{item.summary}</span
>
{/if}
{#if pickerAfterSummary}
<span class="flex items-center h-9 px-1.5 shrink-0"><ChevronDown size={14} /></span>
{/if}
</div>
<span
class="inline-block whitespace-nowrap text-xs font-medium"
bind:clientWidth={summaryTextWidth}>{item.summary ?? ''}</span
>
</div>
{/if}
</div>
{#if content?.belowBar && fit.fold >= (content.belowBarFold ?? 0)}
<!-- Same gutter as the row above, so what moved down lines up with the breadcrumb's start. -->
<div
class={twMerge('flex items-center pb-2', phone ? 'px-2' : 'pl-3 pr-4', navHidden && 'pl-4')}
>
<svelte:boundary
onerror={(e) => console.error('page header: second row failed to render', e)}
>
{#key content?.contexts}
<ContextBridge contexts={content?.contexts}>
{@render content.belowBar()}
</ContextBridge>
{/key}
</svelte:boundary>
</div>
{/if}
</div>