mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
feat: one page header across routes, with the workspace picker in it
Every route now wears the same bar above its content: the sidebar toggle, the workspace picker, and whatever the route registers — an item's breadcrumb and summary, or a section name — with the route's own buttons at the far end. The bar's row is level with the sidebar's own top row, which the workspace ⇄ sessions switch now owns, so the two read as one band. A route fills the bar by mounting PageHeaderContent, which registers a getter rather than a snapshot: the layout calls it while rendering, so a path or summary the page edits reaches the bar without an effect keeping two copies in step. Nothing registers yet; the routes follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0536f36566
commit
a5398b71f2
@@ -58,6 +58,9 @@
|
||||
/** Workspace whose items the breadcrumb picker lists; defaults to the operating
|
||||
* workspace (see `useOperatingWorkspace`). */
|
||||
workspaceId?: string
|
||||
/** Lay the summary out after the path on one line, for the page header bar, rather than
|
||||
* stacked under it. */
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -72,7 +75,8 @@
|
||||
summaryEditable = true,
|
||||
pathEditable = true,
|
||||
hidePath = false,
|
||||
workspaceId
|
||||
workspaceId,
|
||||
inline = false
|
||||
}: Props = $props()
|
||||
|
||||
let pathPopoverOpen = $state(false)
|
||||
@@ -145,7 +149,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="inline-block max-w-full align-top group px-2 py-0.5 leading-tight">
|
||||
<div
|
||||
class="max-w-full group px-2 py-0.5 leading-tight {inline
|
||||
? 'flex items-center gap-2 min-w-0'
|
||||
: 'inline-block align-top'}"
|
||||
>
|
||||
<!-- Path row -->
|
||||
{#if !hidePath}
|
||||
<div class="flex items-center max-w-full text-2xs text-secondary font-mono">
|
||||
@@ -255,7 +263,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="max-w-full -mt-[2px]" title={summary}>
|
||||
<div class="max-w-full {inline ? 'min-w-0' : '-mt-[2px]'}" title={summary}>
|
||||
<EditableInput
|
||||
value={summary ?? ''}
|
||||
placeholder="Add a summary..."
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<!--
|
||||
@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 { Menubar } from '$lib/components/meltComponents'
|
||||
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
|
||||
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
|
||||
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
||||
import { pageHeader } from './pageHeaderRegistry.svelte'
|
||||
import { kindKey, KIND_LABEL_LOWER } from '$lib/components/workspacePicker'
|
||||
|
||||
interface Props {
|
||||
/** The sidebar's show/hide control, placed at the very start of the band. */
|
||||
toggle?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let { toggle }: Props = $props()
|
||||
|
||||
const content = $derived(pageHeader.content)
|
||||
const item = $derived(content?.item)
|
||||
const section = $derived(content?.section)
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 h-12 px-2 border-b shrink-0 min-w-0">
|
||||
{@render toggle?.()}
|
||||
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<WorkspaceMenu {createMenu} strictWorkspaceSelect={false} />
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
|
||||
{#if item}
|
||||
<EditorHeader
|
||||
inline
|
||||
kind={item.kind}
|
||||
raw_app={item.raw_app}
|
||||
savedPath={item.savedPath}
|
||||
workspaceId={item.workspaceId}
|
||||
onBehalfOfEmail={item.onBehalfOfEmail}
|
||||
pathEditable={item.pathEditable ?? true}
|
||||
summaryEditable={item.summaryEditable ?? true}
|
||||
onNavigate={item.onNavigate}
|
||||
bind:path={() => item.path, (v) => item.onPathChange?.(v ?? '')}
|
||||
bind:summary={() => item.summary, (v) => item.onSummaryChange?.(v ?? '')}
|
||||
/>
|
||||
{:else if section}
|
||||
<nav aria-label="Breadcrumb" class="contents">
|
||||
<BreadcrumbSegment
|
||||
label={section.kind ? KIND_LABEL_LOWER[section.kind] : section.label}
|
||||
isCurrent
|
||||
initialHighlight={section.kind ? kindKey(section.kind) : undefined}
|
||||
initialScope={section.kind ? { kind: section.kind } : undefined}
|
||||
onPick={() => {}}
|
||||
/>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
{#if content?.actions}
|
||||
<div class="ml-auto flex items-center gap-2 shrink-0">
|
||||
{@render content.actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!--
|
||||
@component
|
||||
Fills the layout's page header from the route that mounts it: the breadcrumb (an item's path, or a
|
||||
section name for a list) and the page's own buttons. Renders nothing itself.
|
||||
|
||||
Only a route page mounts this. An editor nested in a session pane or a drawer must not take over
|
||||
the header, so nesting is decided by the caller rather than guessed from context.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte'
|
||||
|
||||
let { item, section, actions }: PageHeaderContent = $props()
|
||||
|
||||
onMount(() => {
|
||||
const id = pageHeader.register(() => ({ item, section, actions }))
|
||||
return () => pageHeader.release(id)
|
||||
})
|
||||
</script>
|
||||
@@ -21,6 +21,8 @@
|
||||
transitionClass?: string
|
||||
isMobile?: boolean
|
||||
children: any
|
||||
/** The bar every route wears, rendered above the page content and inside the rail's offset. */
|
||||
header?: import('svelte').Snippet
|
||||
onMenuOpen?: () => void
|
||||
disableAi?: boolean
|
||||
// Whether this layout loads the workspace AI config. It gates far more than
|
||||
@@ -41,6 +43,7 @@
|
||||
transitionClass = 'transition-all ease-in-out duration-200',
|
||||
isMobile = false,
|
||||
children,
|
||||
header,
|
||||
onMenuOpen,
|
||||
disableAi,
|
||||
loadAiConfig = true,
|
||||
@@ -113,6 +116,7 @@
|
||||
<main class="flex-1 flex flex-col min-h-0">
|
||||
<div class="relative w-full flex-1 flex flex-col min-h-0">
|
||||
{@render burgerRow()}
|
||||
{@render header?.()}
|
||||
<div class="flex-1 min-h-0">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -142,6 +146,7 @@
|
||||
style:padding-left="{contentPadLeft}rem"
|
||||
>
|
||||
{@render burgerRow()}
|
||||
{@render header?.()}
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Snippet } from 'svelte'
|
||||
import type { WorkspaceItem, WorkspaceItemKind } from './workspacePicker'
|
||||
|
||||
/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */
|
||||
export type PageHeaderItem = {
|
||||
kind: WorkspaceItemKind
|
||||
path: string | undefined
|
||||
/** The item's saved path, so a mid-rename breadcrumb and picker stay coherent. */
|
||||
savedPath?: string
|
||||
summary?: string
|
||||
raw_app?: boolean
|
||||
/** Which workspace the breadcrumb's pickers read (a session editor acts on its fork). */
|
||||
workspaceId?: string
|
||||
pathEditable?: boolean
|
||||
summaryEditable?: boolean
|
||||
onBehalfOfEmail?: string
|
||||
onSummaryChange?: (summary: string) => void
|
||||
onPathChange?: (path: string) => void
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
}
|
||||
|
||||
/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */
|
||||
export type PageHeaderSection = {
|
||||
label: string
|
||||
/** Scopes the segment's picker, when the section maps to a kind of item. */
|
||||
kind?: WorkspaceItemKind
|
||||
}
|
||||
|
||||
export type PageHeaderContent = {
|
||||
item?: PageHeaderItem
|
||||
section?: PageHeaderSection
|
||||
/** Rendered at the right end of the bar — the page's own buttons. */
|
||||
actions?: Snippet
|
||||
}
|
||||
|
||||
// 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 only the first shows. 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, and
|
||||
// a route mounting its successor before unmounting itself cannot flash the wrong path.
|
||||
let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
|
||||
let nextId = 0
|
||||
|
||||
export const pageHeader = {
|
||||
get content(): PageHeaderContent | undefined {
|
||||
return entries[0]?.get()
|
||||
},
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
|
||||
import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte'
|
||||
import PageHeaderBar from '$lib/components/PageHeaderBar.svelte'
|
||||
import NavHandle from '$lib/components/sidebar/NavHandle.svelte'
|
||||
import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes'
|
||||
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
|
||||
@@ -1041,6 +1042,25 @@
|
||||
|
||||
<!-- Windmill brand mark anchoring the sidebar bottom (the header slot is taken
|
||||
by the workspace picker). -->
|
||||
<!-- The page header's first control: one toggle for the sidebar, shown or hidden. -->
|
||||
{#snippet sidebarToggle()}
|
||||
{@const hidden = navDetached.val}
|
||||
<Tooltip class="flex" placement="bottom" small>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-surface-hover"
|
||||
aria-label={hidden ? 'Show sidebar' : 'Hide sidebar'}
|
||||
onclick={() => setDetached(!hidden)}
|
||||
>
|
||||
{#if hidden}
|
||||
<PanelLeft size={16} class="flex-shrink-0 text-hint" />
|
||||
{:else}
|
||||
<PanelLeftDashed size={16} class="flex-shrink-0 text-hint" />
|
||||
{/if}
|
||||
</button>
|
||||
{#snippet text()}{hidden ? 'Show sidebar' : 'Hide sidebar'}{/snippet}
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
|
||||
{#snippet brandMark(collapsed: boolean)}
|
||||
<div class="flex items-center gap-x-1.5 text-xs font-semibold text-emphasis">
|
||||
<WindmillIcon white={darkMode} height="16px" width="16px" />
|
||||
@@ -1198,31 +1218,10 @@
|
||||
class="h-full flex flex-col"
|
||||
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
|
||||
>
|
||||
<!-- Workspace picker as the drawer header (replaces the Windmill logo). -->
|
||||
<div class="flex-shrink-0 px-2 h-12 w-52 flex items-center gap-1">
|
||||
<Menubar class="w-full min-w-0">
|
||||
{#snippet children({ createMenu })}
|
||||
<WorkspaceMenu {createMenu} />
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
{#if detachedFloating}
|
||||
<!-- Same spot as the docked sidebar's detach button, so the two swap in place. -->
|
||||
<Tooltip class="flex" placement="bottom" small>
|
||||
<button
|
||||
class="p-1.5 -mr-1 rounded hover:bg-surface-hover"
|
||||
aria-label="Attach sidebar"
|
||||
onclick={() => setDetached(false)}
|
||||
>
|
||||
<PanelLeft size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
|
||||
</button>
|
||||
{#snippet text()}Attach sidebar{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Top row: the workspace ⇄ sessions switch, as in the docked rail. The
|
||||
workspace picker lives in the page header. -->
|
||||
{#if !embedded && sessionsSwitchShown}
|
||||
<!-- The switch: workspace navigation ⇄ sessions sidebar. -->
|
||||
<div class="px-2 pb-1 w-52">
|
||||
<div class="flex-shrink-0 px-2 h-12 w-52 flex items-center">
|
||||
<SessionModeSwitch
|
||||
mode={sessionMode ? 'session' : 'nav'}
|
||||
onToggle={() => (preserveMenuOnNextNav = true)}
|
||||
@@ -1345,34 +1344,15 @@
|
||||
)}
|
||||
onpointerdown={startSidebarResize}
|
||||
></div>
|
||||
<!-- Workspace picker as the sidebar header (replaces the Windmill logo).
|
||||
Kept in both modes: it scopes which workspace family's sessions
|
||||
the sessions sidebar shows. -->
|
||||
<div class="flex-shrink-0 px-2 h-12 flex items-center gap-1">
|
||||
<Menubar class="w-full min-w-0">
|
||||
{#snippet children({ createMenu })}
|
||||
<WorkspaceMenu {createMenu} {isCollapsed} />
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
{#if !isCollapsed}
|
||||
<!-- Same spot as the detached card's attach button, so the two swap in place.
|
||||
The icon-only rail has no room for it and keeps the footer copy. -->
|
||||
<Tooltip class="flex" placement="bottom" small>
|
||||
<button
|
||||
class="p-1.5 -mr-1 rounded hover:bg-surface-hover"
|
||||
aria-label="Detach sidebar"
|
||||
onclick={() => setDetached(true)}
|
||||
>
|
||||
<PanelLeftDashed size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
|
||||
</button>
|
||||
{#snippet text()}Detach sidebar{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Top row: the workspace ⇄ sessions switch, level with the page header's own
|
||||
row so the two read as one band. The workspace picker itself lives in that
|
||||
header now, which is why nothing names the workspace here. -->
|
||||
{#if !embedded && sessionsSwitchShown}
|
||||
<!-- The switch: workspace navigation ⇄ sessions sidebar. -->
|
||||
<div class="px-2 pb-1 {isCollapsed ? 'flex justify-center' : ''}">
|
||||
<div
|
||||
class="flex-shrink-0 px-2 h-12 flex items-center {isCollapsed
|
||||
? 'justify-center'
|
||||
: ''}"
|
||||
>
|
||||
<SessionModeSwitch mode={sessionMode ? 'session' : 'nav'} {isCollapsed} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1650,7 +1630,13 @@
|
||||
transitionClass={sidebarTransitionClass}
|
||||
isMobile={useDrawer}
|
||||
onMenuOpen={() => navHandleSlot.open()}
|
||||
/>
|
||||
>
|
||||
{#snippet header()}
|
||||
{#if !menuHidden && !devOnly}
|
||||
<PageHeaderBar toggle={sidebarToggle} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AiChatLayout>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user