mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 00:03:52 +00:00
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
230 lines
12 KiB
TypeScript
230 lines
12 KiB
TypeScript
import type { Snippet } from 'svelte'
|
|
import type { WorkspaceItemKind } from './workspacePicker'
|
|
|
|
/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */
|
|
export type PageHeaderItem = {
|
|
/** Absent for an item the picker does not file under a kind — an agent's detail page. The
|
|
* breadcrumb then names the item without claiming it is one of the three. */
|
|
kind?: WorkspaceItemKind
|
|
/** Shown whole. Clicking it opens whatever the page hangs off it with `pathTrigger`, and copies
|
|
* the path where there is none. */
|
|
path: string | undefined
|
|
/** Wraps the band's own path segment in whatever the page wants behind it — its rename
|
|
* popover, anchored there rather than beside the summary, so the editor opens under the half
|
|
* of the name that was clicked. The band passes the path as it renders it and the classes its
|
|
* own segment wears, so the page's control keeps the breadcrumb's look rather than inventing
|
|
* one. Without this the segment copies the path on click. */
|
|
pathTrigger?: Snippet<[Snippet, string]>
|
|
summary?: string
|
|
/** Rendered in place of the plain summary, for a page whose summary is itself a control —
|
|
* a detail page's rename-and-labels popover, an editor's editable title. */
|
|
summaryContent?: Snippet
|
|
}
|
|
|
|
/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */
|
|
export type PageHeaderSection = {
|
|
label: string
|
|
/** The page the section belongs to, when the section is one pane of it — a settings page's
|
|
* active tab. Drawn as a lead segment before `label`. */
|
|
parent?: string
|
|
/** Rendered in place of `label` when the name is itself a control, such as an editable title. */
|
|
content?: Snippet
|
|
/** Scopes the segment's picker, when the section maps to a kind of item. */
|
|
kind?: WorkspaceItemKind
|
|
/** No chevron after the name: the page's siblings are not the workspace's pages and items,
|
|
* which is all that picker offers. A session is one. */
|
|
noNavPicker?: boolean
|
|
}
|
|
|
|
export type PageHeaderContent = {
|
|
item?: PageHeaderItem
|
|
section?: PageHeaderSection
|
|
/** Rendered at the right end of the bar — the page's own buttons. */
|
|
actions?: Snippet
|
|
/** Orders these buttons among the other registrations', ascending; registration order breaks a
|
|
* tie. For a menu that must stay at the far end however many pages register before it. */
|
|
actionsOrder?: number
|
|
/** These actions can give width back under pressure, so the bar lets their box shrink on a
|
|
* phone. Two ways to earn it: a control that is narrower when it has to be (the Runs filter
|
|
* row, whose search field yields), or a set that scrolls inside its own box (the app and
|
|
* pipeline editors, whose rows are too wide for any bar). Without it the box holds its content
|
|
* width, because squeezing a plain row of buttons only pushes them out of the bar. */
|
|
actionsFlexible?: boolean
|
|
/** These actions are not a row of buttons at the far end but a surface of their own, running
|
|
* from the breadcrumb to the bar's right edge: a session in full screen, whose tab strip and
|
|
* controls share one shape that opens into the page below. The bar gives up its right padding
|
|
* to it, since the surface is what meets the edge. */
|
|
actionsFill?: boolean
|
|
/** How many levels past the full row these actions can fold through, each a narrower row than
|
|
* the last. The bar picks the level from the room its breadcrumb leaves once it has dropped
|
|
* all it can, and the page reads it back as `pageHeader.actionFold`. Without it the actions
|
|
* never fold and the breadcrumb gives up width around them. */
|
|
actionFolds?: number
|
|
/** How many of the `actionFolds` levels come before the breadcrumb drops anything: buttons
|
|
* that are there only while the bar has room for everything. */
|
|
earlyActionFolds?: number
|
|
/** Changes whenever the actions may have changed width at a level that is not on screen — a
|
|
* run's Cancel, folded into a menu, going once the job completes. The bar keeps what it
|
|
* measured per version, so a new one is measured afresh rather than judged by stale widths. */
|
|
actionsVersion?: string
|
|
/** A second row of the band, under the breadcrumb and the actions: where a control too wide
|
|
* for one row goes at a fold that has no room for it beside them, rather than being squeezed
|
|
* until it is useless. The band is taller while it is there. */
|
|
belowBar?: Snippet
|
|
/** The fold from which the bar shows `belowBar`. The bar decides rather than the page passing
|
|
* the row in only at that fold: the fold is derived from this registration, so a registration
|
|
* that read it would be a cycle. */
|
|
belowBarFold?: number
|
|
/** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name,
|
|
* like a documentation tooltip, or a control that acts on the thing named — an app's Edit,
|
|
* which at the far end of the bar would be a journey away from what it edits. */
|
|
afterName?: Snippet
|
|
/** The workspace this page acts on, when it is not the one the app is pointed at — a session
|
|
* running in a fork. The breadcrumb then names that workspace, and its fork part shows only
|
|
* when it really is a fork. */
|
|
actingWorkspaceId?: string
|
|
/** Width in px, at the right of the viewport, that this page owns from the top down — a
|
|
* session's side panel. The band stops there instead of running over it, and the band floats
|
|
* above the page rather than pushing it down, so that column starts at the top. */
|
|
barRightInset?: number
|
|
/** The bottom edge under the bar, which the bar owns so a page cannot draw a second one.
|
|
* `onScroll` (the default) shows it only while something is passing under the bar — at the top
|
|
* of a list there is nothing to divide. `always` is for a page whose content starts right
|
|
* under the bar and needs the division at rest: an editor, a detail page, a settings pane.
|
|
* `none` is for a page that genuinely draws its own first line, such as the script editor's
|
|
* toolbar or the flow editor's canvas. */
|
|
separator?: 'onScroll' | 'always' | 'none'
|
|
/** Contexts this registration's snippets look up — its actions, what it hangs after the name,
|
|
* and its summary. They all render under the header rather than under the page that wrote
|
|
* them, so anything the page's tree provides has to travel with them. */
|
|
contexts?: Map<any, any>
|
|
}
|
|
|
|
// The bar belongs to the root layout and is always on screen; a route page fills its breadcrumb
|
|
// and actions by registering here.
|
|
//
|
|
// A registration is a getter, not a snapshot: the layout calls it while rendering, so a path or
|
|
// summary the page edits reaches the bar through the layout's own dependency tracking, with no
|
|
// effect keeping two copies in step.
|
|
//
|
|
// Registrations stack, and what the bar shows is their merge (see `content`). Pages register on
|
|
// mount, so the first entry is the outermost one — an editor nested in a session pane or a drawer
|
|
// is asked to register nothing.
|
|
let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
|
|
let nextId = 0
|
|
|
|
/**
|
|
* A phone's bar: a tighter gutter, and the width below which controls that cannot fold through
|
|
* `actionFolds` — a flexible filter row, a banner — slim down. A phone in portrait is ~390px of
|
|
* bar and a tablet in portrait ~720, which is why the line sits above the tablet.
|
|
*/
|
|
export const PHONE_BAR = 800
|
|
|
|
/** The bar's height, and the padding a page owes the band when the band floats over it rather than
|
|
* sitting in the flow (see `barRightInset`). They are one measurement in two forms: raise one and
|
|
* the other has to follow, or the page's own content disappears under the bar. Written as literal
|
|
* class names so Tailwind sees them. */
|
|
export const BAR_HEIGHT = 'h-12'
|
|
export const BAR_TOP_PAD = 'pt-12'
|
|
/** The same height in px, for the places that animate to the bar's bottom edge rather than
|
|
* standing under it — a transition's `css` and a measurement's fallback take a number. */
|
|
export const BAR_HEIGHT_PX = 48
|
|
|
|
// The bar's own width, written by the bar and read by the pages that fill it. A page's buttons
|
|
// share that row with the breadcrumb, so it is the width they crowd against — not the window's,
|
|
// which the sidebar and a page's side panel both take from.
|
|
let barWidth = $state(0)
|
|
// The bar owns the fold level: it is derived from the bar's own measurements, so the bar hands
|
|
// over a getter rather than writing a copy that an effect would have to keep in step.
|
|
let actionFoldSource = $state<(() => number) | undefined>(undefined)
|
|
|
|
/**
|
|
* One registration's content, or undefined while it cannot answer.
|
|
*
|
|
* An entry is a getter closing over another component's props, evaluated here, in a tree of its
|
|
* own. Nothing orders a parent clearing those props against the child's teardown: a route that
|
|
* drops the `{#if}` around an editor and the value it binds into it does both in one flush, and
|
|
* this derived re-runs on the same flush, before the editor's own cleanup releases its entry. The
|
|
* entry has nothing to say in that window, which is not the same as the page having no header —
|
|
* so it is skipped, and whatever registered before it still stands.
|
|
*/
|
|
function read(e: { get: () => PageHeaderContent }): PageHeaderContent | undefined {
|
|
try {
|
|
return e.get()
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
export const pageHeader = {
|
|
/**
|
|
* The registrations merged, later ones winning field by field: a route sets the frame (where
|
|
* the bar goes) and something nested in it — the active session, say — fills the breadcrumb
|
|
* and the actions.
|
|
*/
|
|
get content(): PageHeaderContent | undefined {
|
|
if (entries.length === 0) return undefined
|
|
const merged: PageHeaderContent = {}
|
|
for (const e of entries) {
|
|
const c = read(e)
|
|
if (!c) continue
|
|
if (c.item !== undefined) merged.item = c.item
|
|
if (c.section !== undefined) merged.section = c.section
|
|
if (c.afterName !== undefined) merged.afterName = c.afterName
|
|
if (c.contexts !== undefined) merged.contexts = c.contexts
|
|
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
|
|
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
|
|
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
|
|
if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill
|
|
if (c.actionFolds !== undefined) merged.actionFolds = c.actionFolds
|
|
if (c.earlyActionFolds !== undefined) merged.earlyActionFolds = c.earlyActionFolds
|
|
if (c.actionsVersion !== undefined) merged.actionsVersion = c.actionsVersion
|
|
if (c.belowBar !== undefined) merged.belowBar = c.belowBar
|
|
if (c.belowBarFold !== undefined) merged.belowBarFold = c.belowBarFold
|
|
if (c.separator !== undefined) merged.separator = c.separator
|
|
}
|
|
return merged
|
|
},
|
|
/**
|
|
* Every registration's actions, in registration order: a page and something inside it can both
|
|
* put buttons in the band, and each renders with the contexts its own registration named.
|
|
*/
|
|
get actions(): { render: Snippet; contexts?: Map<any, any> }[] {
|
|
return entries
|
|
.flatMap((e, i) => {
|
|
const c = read(e)
|
|
return c?.actions
|
|
? [{ render: c.actions, contexts: c.contexts, order: c.actionsOrder ?? 0, i }]
|
|
: []
|
|
})
|
|
.sort((a, b) => a.order - b.order || a.i - b.i)
|
|
},
|
|
/** Width of the bar in px, or 0 before it has been measured. */
|
|
get barWidth(): number {
|
|
return barWidth
|
|
},
|
|
setBarWidth(w: number) {
|
|
barWidth = w
|
|
},
|
|
/** How far the page's actions are folded: 0 for the full row, up to its `actionFolds`. */
|
|
get actionFold(): number {
|
|
return actionFoldSource?.() ?? 0
|
|
},
|
|
/** Called by the bar once; returns the release to call when the bar goes away. */
|
|
provideActionFold(get: () => number): () => void {
|
|
actionFoldSource = get
|
|
return () => {
|
|
if (actionFoldSource === get) actionFoldSource = undefined
|
|
}
|
|
},
|
|
/** Registers on mount and returns the id to release on destroy. */
|
|
register(get: () => PageHeaderContent): number {
|
|
const id = nextId++
|
|
entries = [...entries, { id, get }]
|
|
return id
|
|
},
|
|
release(id: number) {
|
|
entries = entries.filter((e) => e.id !== id)
|
|
}
|
|
}
|