mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 08:02:58 +00:00
feat: shrink the page header in stages and fold page actions by measured width (#11732)
* 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> * fix: fold embedded detail headers, refresh stale fold widths, keep the path beside a summary placeholder Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: drop the duplicate path on read-only detail pages and re-measure folded actions when they change Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: re-measure the runs header when its hidden counters change width Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * revert: drop the runs header's counter version, the badges do not change its width Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: stop the header re-measuring its actions every frame while idle 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
c7ee201655
commit
3857ce3693
@@ -40,7 +40,7 @@
|
||||
import { getContext, onDestroy, setContext, untrack, type Snippet } from 'svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import CenteredPage from './CenteredPage.svelte'
|
||||
import { Button } from './common'
|
||||
@@ -230,19 +230,14 @@
|
||||
let pathSnapshot = $state<string | undefined>(undefined)
|
||||
|
||||
let topbarWidth = $state(0)
|
||||
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
|
||||
// the trail and the summary take ~560px before either truncates, and the full group — the
|
||||
// dropdown, Diff, the preview trio and Deploy — is ~610px.
|
||||
const compactBelow = $derived(ownsPageHeader ? 1200 : 720)
|
||||
const compactTopbar = $derived.by(() => {
|
||||
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
|
||||
return w > 0 && w < compactBelow
|
||||
})
|
||||
// A phone's bar holds the trail and Deploy: the diff joins the menu and the preview buttons
|
||||
// stand down, since there is no room to run a flow beside its own name.
|
||||
const phoneTopbar = $derived(
|
||||
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
|
||||
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
|
||||
// given all it can; anywhere else this editor's own top bar is theirs alone.
|
||||
const compactTopbar = $derived(
|
||||
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
|
||||
)
|
||||
// The second fold holds the trail and Deploy: the diff joins the menu and the preview buttons
|
||||
// stand down, since there is no room to run a flow beside its own name.
|
||||
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
|
||||
|
||||
const diffEnabled = $derived(customUi?.topBar?.diff != false)
|
||||
// Nothing to compare against until a deployed version exists.
|
||||
@@ -1588,6 +1583,7 @@
|
||||
// Frozen while the rename popover is open so the trail holds still as the user types.
|
||||
kind: 'flow',
|
||||
path: pathSnapshot ?? $pathStore,
|
||||
summary: flowStore.val.summary,
|
||||
summaryContent: flowSummary,
|
||||
// Gated like the summary's own editor: a host that allows neither field keeps the
|
||||
// band's segment, which copies the path.
|
||||
@@ -1597,6 +1593,7 @@
|
||||
: undefined
|
||||
}}
|
||||
actions={flowHeaderActions}
|
||||
actionFolds={2}
|
||||
contexts={headerContexts}
|
||||
separator="none"
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,6 @@ not this component) — so the lead can run the whole line.
|
||||
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
|
||||
import { Menu, Menubar } from '$lib/components/meltComponents'
|
||||
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 { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
|
||||
@@ -33,14 +32,25 @@ not this component) — so the lead can run the whole line.
|
||||
import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker'
|
||||
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
import {
|
||||
BAR_STAGES,
|
||||
FLOOR,
|
||||
shownPathLevels,
|
||||
type Part,
|
||||
type TrailMeasures,
|
||||
type TrailStage
|
||||
} from './pageHeaderStages'
|
||||
|
||||
let {
|
||||
item,
|
||||
section,
|
||||
afterName,
|
||||
actingWorkspaceId,
|
||||
narrow = false,
|
||||
nameOnly = false
|
||||
nameOnly = false,
|
||||
stage = BAR_STAGES[0],
|
||||
caps = {},
|
||||
scopesGive = false,
|
||||
measures = $bindable()
|
||||
}: {
|
||||
item?: PageHeaderItem
|
||||
section?: PageHeaderSection
|
||||
@@ -49,9 +59,14 @@ not this component) — so the lead can run the whole line.
|
||||
afterName?: Snippet
|
||||
/** The workspace the page acts on, when it differs from the one the app is pointed at. */
|
||||
actingWorkspaceId?: string
|
||||
/** The bar is short of room: the path holds a tighter cap, so it cannot claim a third of the
|
||||
* line before anything else has given way. The names truncate at every width either way. */
|
||||
narrow?: boolean
|
||||
/** Which parts the bar has dropped for room (see pageHeaderStages). */
|
||||
stage?: TrailStage
|
||||
/** Max widths the bar holds the names to, once dropping whole parts is not enough. */
|
||||
caps?: Partial<Record<Part, number>>
|
||||
/** The folders above the name truncate too: even the name at its last floor does not fit. */
|
||||
scopesGive?: boolean
|
||||
/** Filled in for the bar to choose a stage with; left undefined, nothing is measured. */
|
||||
measures?: TrailMeasures
|
||||
/** Only the page's own name, with no workspace part and no picker — the band inside a
|
||||
* session's preview frame, where the workspace is the host's and leading the reader out of
|
||||
* it is exactly what that band must not offer. The page still has to say what it is. */
|
||||
@@ -145,6 +160,9 @@ not this component) — so the lead can run the whole line.
|
||||
// The summary is the band's, not the breadcrumb's (see PageHeaderBar), so the path reads as the
|
||||
// last segment only for an item that has no summary to follow it.
|
||||
const itemIsLast = $derived(!!item && !item.summaryContent && !item.summary)
|
||||
/** Only the summary's text names the item: a control offering to add one does not, and the path
|
||||
* then stays at every stage. */
|
||||
const summaryNamesItem = $derived(!!item?.summary?.trim())
|
||||
|
||||
// No hover fill: the chevron beside it is what reacts to the pointer. The accent stays on the
|
||||
// fork's own name inside, so the family's name is not painted as if it were the fork.
|
||||
@@ -156,15 +174,8 @@ not this component) — so the lead can run the whole line.
|
||||
twMerge(SEGMENT, 'hover:bg-transparent', workspaceIsLast && !showFork ? LAST : LEAD)
|
||||
)
|
||||
|
||||
// Capped: a draft's path is a 40-character uuid slug, and the trail yields so grudgingly that
|
||||
// one would push a page's buttons off the end of the bar.
|
||||
const pathSegment = $derived(
|
||||
twMerge(
|
||||
SEGMENT,
|
||||
'gap-1.5 overflow-hidden',
|
||||
itemIsLast ? LAST : LEAD,
|
||||
narrow ? 'max-w-[11rem]' : 'max-w-[16rem]'
|
||||
)
|
||||
twMerge(SEGMENT, 'gap-1.5 overflow-hidden', itemIsLast ? LAST : LEAD)
|
||||
)
|
||||
// Copying is the one thing a segment does that leaves no mark on the page, so it is the one
|
||||
// without a hover fill: the pointer and the confirmation under it are the affordance. A segment
|
||||
@@ -191,6 +202,18 @@ not this component) — so the lead can run the whole line.
|
||||
const scopeKind = $derived(item?.path?.split('/')[0])
|
||||
/** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */
|
||||
const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean))
|
||||
|
||||
// The off-screen copies stand in a fixed-width box for what a page renders into the trail: a
|
||||
// second render of a page's snippet would be a second live copy of its controls.
|
||||
let afterNameWidth = $state(0)
|
||||
let sectionContentWidth = $state(0)
|
||||
|
||||
/** A measured copy holds each name at its floor; the trail on screen at the bar's cap. */
|
||||
function capStyle(part: Part, measure: boolean): string | undefined {
|
||||
if (measure) return `max-width: ${FLOOR[part]}px`
|
||||
const cap = caps[part]
|
||||
return cap === undefined ? undefined : `max-width: ${cap}px`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet workspaceDisc()}
|
||||
@@ -241,89 +264,129 @@ not this component) — so the lead can run the whole line.
|
||||
<span class={twMerge('shrink-0 text-hint/40 text-xs', extra)} aria-hidden="true">/</span>
|
||||
{/snippet}
|
||||
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
|
||||
>
|
||||
{#snippet workspaceMenu_(measure: boolean)}
|
||||
{#if measure}
|
||||
<span class="flex items-center h-9 px-1.5 shrink-0">
|
||||
<ChevronDown size={ICON} class="flex-shrink-0" />
|
||||
</span>
|
||||
{:else}
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu
|
||||
bind:this={workspaceMenu}
|
||||
{createMenu}
|
||||
usePointerDownOutside
|
||||
placement="bottom-start"
|
||||
class="h-auto"
|
||||
contentStyle="margin-left: {-nameWidth}px"
|
||||
>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MeltButton
|
||||
meltElement={trigger}
|
||||
class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
|
||||
title="Switch workspace"
|
||||
>
|
||||
<ChevronDown size={ICON} class="flex-shrink-0" />
|
||||
</MeltButton>
|
||||
{/snippet}
|
||||
{#snippet children({ item: menuItem })}
|
||||
<WorkspacePickerBody
|
||||
item={menuItem}
|
||||
collapseFamilies={actingWorkspaceId != undefined}
|
||||
closeMenu={() => workspaceMenu?.close()}
|
||||
/>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet trail(f: TrailStage, measure: boolean)}
|
||||
{@const scopeTitle = showFork ? `${familyName} / ${scopeName}` : familyName}
|
||||
{#if !nameOnly}
|
||||
<!-- One part for where the user is: the name of the workspace they are standing in — a fork
|
||||
included, in its own colour — leading home, and one chevron whose picker changes it. The
|
||||
picker lists the families and expands one to reach its forks, so a fork needs no part of
|
||||
its own here. -->
|
||||
<!-- `shrink-[999]`: the whole trail can give width back, and this part gives it first. Flex
|
||||
takes it from the parts in proportion to what they ask for, so a factor this far above
|
||||
the rest means the names here are eaten down to their icons before the page's own name
|
||||
loses a character. -->
|
||||
<div class="flex items-center min-w-0 shrink-[999]">
|
||||
included, in its own colour — leading home, and one chevron whose picker changes it. The
|
||||
picker lists the families and expands one to reach its forks, so a fork needs no part of
|
||||
its own here. Its parts never shrink on their own: the bar drops them whole and caps the
|
||||
names left, in the order pageHeaderStages sets. -->
|
||||
<div class="flex items-center shrink-0">
|
||||
<!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two
|
||||
boxes reacting to one pass of the pointer read as two controls fighting. -->
|
||||
boxes reacting to one pass of the pointer read as two controls fighting. -->
|
||||
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
|
||||
the name to line its left edge up with the disc. -->
|
||||
<div class="flex items-center min-w-0" bind:clientWidth={nameWidth}>
|
||||
<a
|
||||
href={homeHref}
|
||||
class={scopeChipClass}
|
||||
title={showFork ? `${familyName} / ${scopeName}` : familyName}
|
||||
the name to line its left edge up with the disc. -->
|
||||
<div
|
||||
class="flex items-center"
|
||||
bind:clientWidth={() => nameWidth, (w) => !measure && (nameWidth = w)}
|
||||
>
|
||||
<svelte:element
|
||||
this={measure ? 'span' : 'a'}
|
||||
href={measure ? undefined : homeHref}
|
||||
class={f.wsButton ? 'flex items-center gap-1 pl-1 py-0.5 rounded' : scopeChipClass}
|
||||
title={scopeTitle}
|
||||
>
|
||||
<!-- The family's name goes before the fork's: the fork is where the work happens, the
|
||||
family is which product it is part of, and of the two that is the one a reader can
|
||||
do without. Both truncate to nothing rather than disappearing at a breakpoint —
|
||||
what is left then is what the part cannot be read without, the disc in the
|
||||
workspace's own colour, the fork mark and the environment. The hover title
|
||||
carries the names whole, and the picker beside it names them all. -->
|
||||
<span class="flex items-center gap-1 min-w-0 shrink-[999]">
|
||||
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
|
||||
</span>
|
||||
{#if showFork}
|
||||
{@render slash('mx-0.5')}
|
||||
<span class={forkChipClass} style={forkAccent}>
|
||||
<GitFork size={ICON} class="flex-shrink-0" />
|
||||
<span class="truncate">{scopeName}</span>
|
||||
{@render envBadgeMark()}
|
||||
{#if f.wsButton}
|
||||
<!-- Down to its disc, the part still leads home: the fork it stands in rides on the
|
||||
disc as a mark, and the environment stays beside it. -->
|
||||
<span class="relative flex shrink-0">
|
||||
{@render workspaceDisc()}
|
||||
{#if showFork}
|
||||
<span
|
||||
class={twMerge(
|
||||
'absolute -right-1 -bottom-0.5 size-3.5 rounded-full grid place-items-center ring-2 ring-surface',
|
||||
forkAccent
|
||||
? 'bg-[color:var(--fork-accent-badge)] text-white'
|
||||
: 'bg-surface-sunken text-primary'
|
||||
)}
|
||||
style={forkAccent}
|
||||
>
|
||||
<GitFork size={9} />
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
{@render envBadgeMark()}
|
||||
{:else}
|
||||
<!-- The family's name goes before the fork's: the fork is where the work happens,
|
||||
the family is which product it is part of, and of the two that is the one a
|
||||
reader can do without. Outside a fork it is the workspace's own name and stays
|
||||
until the part is down to its disc. -->
|
||||
<span class="flex items-center gap-1 shrink-0">
|
||||
{@render workspaceDisc()}
|
||||
{#if f.familyName || !showFork}
|
||||
<span class="truncate" style={capStyle('family', measure)}>{familyName}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if showFork}
|
||||
{@render slash('mx-0.5')}
|
||||
<span class={twMerge(forkChipClass, 'shrink-0')} style={forkAccent}>
|
||||
<GitFork size={ICON} class="flex-shrink-0" />
|
||||
{#if f.forkName}
|
||||
<span class="truncate" style={capStyle('fork', measure)}>{scopeName}</span>
|
||||
{/if}
|
||||
{@render envBadgeMark()}
|
||||
</span>
|
||||
{:else}
|
||||
{@render envBadgeMark()}
|
||||
{/if}
|
||||
{/if}
|
||||
</a>
|
||||
</svelte:element>
|
||||
</div>
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu
|
||||
bind:this={workspaceMenu}
|
||||
{createMenu}
|
||||
usePointerDownOutside
|
||||
placement="bottom-start"
|
||||
class="h-auto"
|
||||
contentStyle="margin-left: {-nameWidth}px"
|
||||
>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MeltButton
|
||||
meltElement={trigger}
|
||||
class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
|
||||
title="Switch workspace"
|
||||
>
|
||||
<ChevronDown size={ICON} class="flex-shrink-0" />
|
||||
</MeltButton>
|
||||
{/snippet}
|
||||
{#snippet children({ item: menuItem })}
|
||||
<WorkspacePickerBody
|
||||
item={menuItem}
|
||||
collapseFamilies={actingWorkspaceId != undefined}
|
||||
closeMenu={() => workspaceMenu?.close()}
|
||||
/>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
{@render workspaceMenu_(measure)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !nameOnly && (item || section || routePage)}
|
||||
<!-- The summary that follows is the item's name for a person, so on a short bar it is the path
|
||||
that goes (see `TrailStage.path`). What the page hangs after the name stays. -->
|
||||
{@const pathShown = f.path || !summaryNamesItem}
|
||||
{#if !nameOnly && (item ? pathShown : section || routePage)}
|
||||
{@render slash()}
|
||||
{/if}
|
||||
|
||||
{#if item}
|
||||
{#if item.path}
|
||||
{#if !pathShown}
|
||||
{@render afterNameSlot(measure)}
|
||||
{:else if item.path}
|
||||
{@const shown = shownPathLevels(pathLevels, f)}
|
||||
<!-- The path whole, as one label rather than a row of pickers: it is what a person reads
|
||||
to know which item this is, and what they reach for to paste somewhere else. -->
|
||||
<div class="relative flex items-center min-w-0">
|
||||
@@ -334,19 +397,28 @@ not this component) — so the lead can run the whole line.
|
||||
{:else}
|
||||
{@render folderIcon()}
|
||||
{/if}
|
||||
{#each pathLevels as level, i (i)}
|
||||
{#each shown as level, i (i)}
|
||||
{#if i > 0}{@render slash()}{/if}
|
||||
<!-- The scopes above the name absorb the squeeze, the name itself last: a path is
|
||||
read from its end, and which folder a thing is in is answerable from the
|
||||
picker while the thing's own name is not. -->
|
||||
<span
|
||||
class={i === pathLevels.length - 1
|
||||
? 'truncate'
|
||||
: 'truncate min-w-0 shrink-[999] max-w-[8rem]'}>{level}</span
|
||||
>
|
||||
{#if i === shown.length - 1}
|
||||
<span class="truncate shrink-0" style={capStyle('name', measure)}>{level}</span>
|
||||
{:else if level === '…' && !f.middleLevels}
|
||||
<span class="shrink-0 text-tertiary">…</span>
|
||||
{:else}
|
||||
<!-- The folder or user gives width only once the name is down to a few
|
||||
characters: it is short and says whose thing this is, while a name long
|
||||
enough to need truncating still reads from its first words. -->
|
||||
<span
|
||||
class={twMerge(
|
||||
'truncate max-w-[8rem]',
|
||||
scopesGive && !measure ? 'min-w-0' : 'shrink-0'
|
||||
)}>{level}</span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
{#if item.pathTrigger}
|
||||
{#if measure}
|
||||
<span class={pathSegment}>{@render pathLabel()}</span>
|
||||
{:else if item.pathTrigger}
|
||||
<!-- The page hangs its own rename off this segment, anchored here so the editor
|
||||
opens under the path rather than beside the summary. It wears the segment's
|
||||
own classes, so a clickable path reads the same as one that only copies. -->
|
||||
@@ -361,8 +433,8 @@ not this component) — so the lead can run the whole line.
|
||||
{@render pathLabel()}
|
||||
</button>
|
||||
{/if}
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{#if pathCopied}
|
||||
{@render afterNameSlot(measure)}
|
||||
{#if pathCopied && !measure}
|
||||
<span
|
||||
class="absolute left-0 top-full mt-1 z-[6000] rounded-md border bg-surface px-2 py-1 text-2xs text-secondary shadow-md whitespace-nowrap"
|
||||
role="status"
|
||||
@@ -386,15 +458,21 @@ 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={() => {}}
|
||||
/>
|
||||
{#if measure}
|
||||
<span class={twMerge('inline-flex items-center px-1', SEGMENT, LAST)}>
|
||||
{@render sectionKindIcon()}<span>{KIND_LABEL_LOWER[section.kind]}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<BreadcrumbSegment
|
||||
label={KIND_LABEL_LOWER[section.kind]}
|
||||
icon={sectionKindIcon}
|
||||
extraClass={twMerge(SEGMENT, LAST)}
|
||||
isCurrent
|
||||
initialHighlight={kindKey(section.kind)}
|
||||
initialScope={{ kind: section.kind }}
|
||||
onPick={() => {}}
|
||||
/>
|
||||
{/if}
|
||||
{:else if section}
|
||||
{#if section.parent}
|
||||
<!-- Gives width back with the workspace part, before the section's own name does. -->
|
||||
@@ -405,30 +483,96 @@ not this component) — so the lead can run the whole line.
|
||||
</span>
|
||||
{@render slash()}
|
||||
{/if}
|
||||
<!-- 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>
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{#if section.content && measure}
|
||||
<span class="shrink-0" style="width: {sectionContentWidth}px"></span>
|
||||
{:else}
|
||||
<!-- 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 ? '' : 'shrink-0'
|
||||
)}
|
||||
aria-current={measure ? undefined : 'page'}
|
||||
bind:clientWidth={
|
||||
() => sectionContentWidth, (w) => !measure && section.content && (sectionContentWidth = w)
|
||||
}
|
||||
>
|
||||
<!-- A plain label is the page's name, so it truncates the way an item's name does, in
|
||||
its turn: a run's id, left to flex, is squeezed to nothing under the buttons. -->
|
||||
{#if section.content}{@render section.content()}{:else}<span
|
||||
class="truncate"
|
||||
style={capStyle('name', measure)}>{section.label}</span
|
||||
>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{@render afterNameSlot(measure)}
|
||||
{: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"
|
||||
aria-current={measure ? undefined : 'page'}
|
||||
>
|
||||
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
|
||||
<span class="truncate">{routePage.label}</span>
|
||||
</span>
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{@render afterNameSlot(measure)}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet afterNameSlot(measure: boolean)}
|
||||
{#if afterName}
|
||||
{#if measure}
|
||||
<span class="shrink-0" style="width: {afterNameWidth}px"></span>
|
||||
{:else}
|
||||
<span class="flex items-center shrink-0" bind:clientWidth={afterNameWidth}>
|
||||
{@render afterName()}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
|
||||
>
|
||||
{@render trail(stage, false)}
|
||||
</nav>
|
||||
|
||||
{#if measures}
|
||||
<!-- The trail at every stage, each name at its floor, for the bar to choose a stage with; and
|
||||
each name whole, for it to cap them with. Same snippet, same classes, so the widths are the
|
||||
ones the trail on screen would take. -->
|
||||
<div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert>
|
||||
{#each BAR_STAGES as s, i (i)}
|
||||
<nav
|
||||
class="flex items-center gap-1.5 w-max text-xs font-normal text-primary"
|
||||
bind:clientWidth={measures.trail[i]}
|
||||
>
|
||||
{@render trail(s, true)}
|
||||
</nav>
|
||||
{/each}
|
||||
<!-- Always rendered, empty where the page has no such part: a width of 0 is how the bar
|
||||
knows the part is not there. -->
|
||||
<span
|
||||
class={twMerge(scopeChipClass, 'inline-block whitespace-nowrap p-0')}
|
||||
bind:clientWidth={measures.family}>{nameOnly ? '' : familyName}</span
|
||||
>
|
||||
<span
|
||||
class={twMerge(forkChipClass, 'inline-block whitespace-nowrap p-0')}
|
||||
bind:clientWidth={measures.fork}>{!nameOnly && showFork ? scopeName : ''}</span
|
||||
>
|
||||
<span
|
||||
class={twMerge('inline-block whitespace-nowrap text-xs', itemIsLast || !item ? LAST : LEAD)}
|
||||
bind:clientWidth={measures.name}
|
||||
>{item?.path
|
||||
? (pathLevels[pathLevels.length - 1] ?? '')
|
||||
: !item && section && !section.kind && !section.content
|
||||
? section.label
|
||||
: ''}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -16,6 +16,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
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,
|
||||
@@ -33,15 +36,99 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
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())
|
||||
|
||||
// Below this the trail holds a tighter cap on the path it draws. The names in it truncate on
|
||||
// their own at any width (see NavBreadcrumb), so this is not a breakpoint the look changes at —
|
||||
// it only stops a long path from claiming a third of a short bar before the squeeze starts.
|
||||
const NARROW_BAR = 1000
|
||||
const narrow = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < NARROW_BAR)
|
||||
/** A phone's bar: the trail gives way before the page's buttons do (see the markup). */
|
||||
/** 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
|
||||
@@ -69,8 +156,14 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
scrolledUnder = next
|
||||
}
|
||||
// A page left while scrolled would otherwise hand its edge to the next one, which may have
|
||||
// nothing to scroll at all.
|
||||
afterNavigate(() => (scrolledUnder = false))
|
||||
// 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))
|
||||
@@ -86,145 +179,205 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
data-page-header
|
||||
bind:clientWidth={() => pageHeader.barWidth, (w) => pageHeader.setBarWidth(w)}
|
||||
class={twMerge(
|
||||
'flex items-center gap-1 shrink-0 min-w-0 bg-surface transition-shadow duration-150',
|
||||
// One row at every width. 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, which is the opposite of what a narrow bar
|
||||
// wants: the trail truncating from its start.
|
||||
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',
|
||||
'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))]'
|
||||
)}
|
||||
>
|
||||
{#if (navDetached.val || panelled) && !navHidden}
|
||||
<!-- Reveals the hidden sidebar, and only that: the card follows the pointer, holding while it
|
||||
<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
|
||||
onmouseenter={() => navHandleSlot.open()}
|
||||
onmouseleave={() => navHandleSlot.scheduleClose()}
|
||||
>
|
||||
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
|
||||
<!-- 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}
|
||||
<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 yields width more slowly than the page's controls (shrink-[0.3]) but it does
|
||||
yield, at every width: the trail truncates from its start, so the squeeze is spent on the
|
||||
workspace and the folders before it ever reaches the name of the thing the page is about.
|
||||
The 5rem floor is what is left when every name in it has gone — the workspace disc, the
|
||||
fork mark and the environment badge, which is as small as the part can be and still say
|
||||
where the user is.
|
||||
<!-- 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.
|
||||
The 0.3 only holds against a summary or an actions box that shrinks too: alone, a factor
|
||||
sum below 1 makes flex hand out only that share of the overflow, and the bar overflows
|
||||
instead. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex shrink-[0.3] min-w-[5rem]',
|
||||
!hasSummary &&
|
||||
(actions.length === 0 || (phone && !content?.actionsFlexible && !content?.actionsFill)) &&
|
||||
'shrink'
|
||||
)}
|
||||
>
|
||||
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
|
||||
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
|
||||
<!-- 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}
|
||||
{narrow}
|
||||
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={twMerge('flex items-center gap-1 min-w-0', phone && 'shrink')}>
|
||||
<!-- A dot rather than a slash: the summary names the same item the path just located, it
|
||||
is not another level of it. Inside this box so that it narrows with the summary. -->
|
||||
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
|
||||
{#if item.summaryContent}
|
||||
<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>
|
||||
{:else}
|
||||
<span class="min-w-0 truncate text-xs font-medium text-emphasis">{item.summary}</span>
|
||||
{/if}
|
||||
<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}
|
||||
|
||||
{#if actions.length > 0}
|
||||
<!-- min-w-0, not shrink-0: a page whose actions are a filter row (Runs) puts a control in
|
||||
here that can give width back, and it can only do that if this box may shrink. A phone
|
||||
only tightens the gutter — pages fold their own buttons into a menu at that width, and
|
||||
a box that refused to shrink would make the one flexible field take its max instead. -->
|
||||
<!-- `actionsFill`: the actions are a surface rather than a row, so they take the rest of the
|
||||
{#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}
|
||||
</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}
|
||||
</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',
|
||||
// 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 && (phone && !content?.actionsFlexible ? 'shrink-0' : 'min-w-0'),
|
||||
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4')
|
||||
)}
|
||||
class={twMerge('flex items-center pb-2', phone ? 'px-2' : 'pl-3 pr-4', navHidden && 'pl-4')}
|
||||
>
|
||||
{#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}
|
||||
<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>
|
||||
|
||||
@@ -18,6 +18,11 @@ the header, so nesting is decided by the caller rather than guessed from context
|
||||
actionsOrder,
|
||||
actionsFlexible,
|
||||
actionsFill,
|
||||
actionFolds,
|
||||
earlyActionFolds,
|
||||
actionsVersion,
|
||||
belowBar,
|
||||
belowBarFold,
|
||||
separator,
|
||||
contexts,
|
||||
actingWorkspaceId,
|
||||
@@ -33,6 +38,11 @@ the header, so nesting is decided by the caller rather than guessed from context
|
||||
actionsOrder,
|
||||
actionsFlexible,
|
||||
actionsFill,
|
||||
actionFolds,
|
||||
earlyActionFolds,
|
||||
actionsVersion,
|
||||
belowBar,
|
||||
belowBarFold,
|
||||
separator,
|
||||
contexts,
|
||||
actingWorkspaceId,
|
||||
|
||||
@@ -35,9 +35,8 @@
|
||||
import RunsQueue from '$lib/components/runs/RunsQueue.svelte'
|
||||
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { RUNS_PATH } from '$lib/components/sessions/previewPaths'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte'
|
||||
import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte'
|
||||
import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte'
|
||||
@@ -612,7 +611,12 @@
|
||||
openedRunId = undefined
|
||||
}
|
||||
let resolutionNote = $state('')
|
||||
const phone = $derived(!hosted && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
|
||||
// The bar folds this row (`actionFolds` below) once its breadcrumb has given all it can: first
|
||||
// the counters, which are a glance and repeat the chart below; then the session button; then
|
||||
// the filter field moves to a row of its own rather than shrinking past usable. Hosted, the row
|
||||
// is the page's own and wraps instead.
|
||||
const fold = $derived(hosted ? 0 : pageHeader.actionFold)
|
||||
const filterBelow = $derived(fold >= 3)
|
||||
</script>
|
||||
|
||||
{#snippet runsHint()}
|
||||
@@ -624,18 +628,34 @@
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
|
||||
{#snippet runsFilter(cls: string)}
|
||||
<FilterSearchbar
|
||||
class={cls}
|
||||
schema={runsFilterSearchbarSchema}
|
||||
presets={buildRunsFilterPresets({
|
||||
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
|
||||
isAdminsWorkspace: $operatingWorkspace === 'admins'
|
||||
})}
|
||||
bind:value={filters.val}
|
||||
placeholder="Filter runs..."
|
||||
size="sm"
|
||||
autofocus={!hosted}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet filterRow()}
|
||||
{@render runsFilter('relative w-full min-w-0')}
|
||||
{/snippet}
|
||||
|
||||
{#snippet headerActions()}
|
||||
<!-- The page's own row inside the band's actions box: these controls are denser than a page's
|
||||
two or three buttons, so they take a wider gap than the band's own. `flex-1 min-w-0` passes
|
||||
the band's flexibility down to the search field, the one control here that gives width
|
||||
back; the rest keep theirs or drop out of the row entirely at a width. -->
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0 {hosted ? 'flex-wrap justify-end' : ''}">
|
||||
two or three buttons, so they take a wider gap than the band's own. Every control keeps its
|
||||
width: the bar measures the row and folds it rather than squeezing it. -->
|
||||
<div class="flex items-center gap-3 {hosted ? 'flex-wrap justify-end' : ''}">
|
||||
<!-- Always the icon form: the bar has room for two counters, never for the two labelled
|
||||
blocks. pr-2 on top of the row's own gap because the counts hang outside their icons,
|
||||
so without it the badge butts against the first filter.
|
||||
A phone's bar has room for the filter field and the timeframe and nothing else: the
|
||||
counters are a glance, and the same numbers are on the chart below. -->
|
||||
<div class="flex items-center pr-2 {phone ? 'hidden' : ''}">
|
||||
so without it the badge butts against the first filter. -->
|
||||
<div class="flex items-center pr-2 {fold >= 1 ? 'hidden' : ''}">
|
||||
<RunsQueue
|
||||
success={filters.val.status ?? null}
|
||||
{queue_count}
|
||||
@@ -806,32 +826,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The one control in the band that gives way: everything else keeps the width it asked
|
||||
for, or drops out of the row at a width, and this takes what is left. -->
|
||||
<FilterSearchbar
|
||||
class={twMerge(
|
||||
// A width of its own, and only ever less. As `flex-1` the field was whatever the row
|
||||
// had left over, so a chip appearing or a filter committing resized it under the
|
||||
// cursor and the last pixels came off the breadcrumb. 18rem is its width whenever the
|
||||
// row fits, and it still shrinks when the row runs short, which is what keeps the row
|
||||
// inside a box the bar lets shrink (`actionsFlexible`). `min-w-0` is what makes that
|
||||
// shrink unconditional: a flex item's floor is its min-content width, which the chips
|
||||
// inside the field raise, so without it a typed filter would push the field wider
|
||||
// again exactly when the row has no room to give.
|
||||
'relative w-[18rem] min-w-0 max-w-full',
|
||||
// A phone's bar cannot spare that much before the timeframe beside it.
|
||||
phone && 'w-[10rem]'
|
||||
)}
|
||||
schema={runsFilterSearchbarSchema}
|
||||
presets={buildRunsFilterPresets({
|
||||
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
|
||||
isAdminsWorkspace: $operatingWorkspace === 'admins'
|
||||
})}
|
||||
bind:value={filters.val}
|
||||
placeholder="Filter runs..."
|
||||
size="sm"
|
||||
autofocus={!hosted}
|
||||
/>
|
||||
<!-- A width of its own, never what the row has left over: as `flex-1` a chip appearing or a
|
||||
filter committing resized it under the cursor. `shrink-0` keeps it at that width, which
|
||||
is the row's width the bar measures and folds. -->
|
||||
{#if !filterBelow}{@render runsFilter('relative w-[18rem] shrink-0')}{/if}
|
||||
<TimeframeSelect
|
||||
unifiedSize="sm"
|
||||
onClick={() => jobsLoader?.loadJobs(true)}
|
||||
@@ -842,7 +840,7 @@
|
||||
<!-- The filters are shallow-routed, so the search has to come off `window.location` at click
|
||||
time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized
|
||||
preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. -->
|
||||
<div class={phone || hosted ? 'hidden' : 'contents'}>
|
||||
<div class={fold >= 2 || hosted ? 'hidden' : 'contents'}>
|
||||
<OpenInSessionButton
|
||||
source={{
|
||||
page: () => RUNS_PATH + window.location.search,
|
||||
@@ -923,14 +921,18 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex flex-col" bind:clientWidth={innerWidth}>
|
||||
<!-- `actionsFlexible`: the filter row holds a search field that can give width back, so the
|
||||
bar lets this box shrink on a phone instead of holding its content width. -->
|
||||
{#if hosted}
|
||||
<div class="flex px-4 pt-3 pb-2">
|
||||
{@render headerActions()}
|
||||
</div>
|
||||
{:else}
|
||||
<PageHeaderContent afterName={runsHint} actions={headerActions} actionsFlexible />
|
||||
<PageHeaderContent
|
||||
afterName={runsHint}
|
||||
actions={headerActions}
|
||||
actionFolds={3}
|
||||
belowBar={filterRow}
|
||||
belowBarFold={3}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Graph -->
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
import EditorHeader from './EditorHeader.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import Modal from './common/modal/Modal.svelte'
|
||||
@@ -206,19 +206,14 @@
|
||||
let pathSnapshot = $state<string | undefined>(undefined)
|
||||
|
||||
let topbarWidth = $state(0)
|
||||
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
|
||||
// the trail and the summary take ~560px before either truncates, and the full button group is
|
||||
// ~515px — about 1100px between them, plus slack for a longer path than most.
|
||||
const compactBelow = $derived(ownsPageHeader ? 1200 : 900)
|
||||
const compactTopbar = $derived.by(() => {
|
||||
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
|
||||
return w > 0 && w < compactBelow
|
||||
})
|
||||
// A phone's bar holds the trail and one button. Diff and Settings join the menu that compact
|
||||
// already shows, and Deploy stays out: it is what this page is for.
|
||||
const phoneTopbar = $derived(
|
||||
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
|
||||
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
|
||||
// given all it can; anywhere else this editor's own top bar is theirs alone.
|
||||
const compactTopbar = $derived(
|
||||
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 900
|
||||
)
|
||||
// The second fold holds the trail and one button. Diff and Settings join the menu that compact
|
||||
// already shows, and Deploy stays out: it is what this page is for.
|
||||
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
|
||||
|
||||
// The workspace this editor operates on: deploy, save-draft, trigger loading
|
||||
// and the AutosaveIndicator all target it. Falls back to the full-page
|
||||
@@ -2316,6 +2311,7 @@
|
||||
// route binds here and drops the `{#if}` around this editor in one flush, which
|
||||
// invalidates the band before this component is torn down.
|
||||
path: pathSnapshot ?? script?.path,
|
||||
summary: script?.summary,
|
||||
summaryContent: scriptSummary,
|
||||
// Gated like the summary's own editor: a host that allows neither field keeps the
|
||||
// band's segment, which copies the path.
|
||||
@@ -2326,6 +2322,7 @@
|
||||
}}
|
||||
afterName={scriptMarks}
|
||||
actions={scriptHeaderActions}
|
||||
actionFolds={2}
|
||||
separator="none"
|
||||
/>
|
||||
{:else}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { emptyString, isOwner } from '$lib/utils'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import { Alert, Button, CopyButton } from '$lib/components/common'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
@@ -326,14 +326,62 @@
|
||||
<!-- Nothing to open, but the host still drew this into a slot of its own: give back what it
|
||||
handed over rather than this component's own block, which belongs in a row it owns. -->
|
||||
{@render label()}
|
||||
{:else if compact}
|
||||
<!-- Nothing to edit, but the band truncates the summary and, on a short bar, drops the path:
|
||||
the same click shows both whole, to read and to copy. -->
|
||||
<Popover
|
||||
class={triggerClass}
|
||||
placement="bottom-start"
|
||||
contentClasses="p-4"
|
||||
triggerAttrs={{
|
||||
title: emptyString(summary) ? path : summary,
|
||||
'aria-label': 'Show summary and path'
|
||||
}}
|
||||
usePointerDownOutside
|
||||
excludeSelectors=".drawer"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<span class="block truncate text-xs font-medium text-emphasis">
|
||||
{emptyString(summary) ? (path ?? '') : summary}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-4 w-[420px] max-w-[calc(100vw-2rem)]">
|
||||
<div>
|
||||
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
|
||||
{#if emptyString(summary)}
|
||||
<p class="text-xs text-tertiary italic">No summary</p>
|
||||
{:else}
|
||||
<p class="text-xs text-primary break-words">{summary}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div class="pb-1 text-xs font-semibold text-emphasis">Path</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="min-w-0 text-xs font-mono text-secondary break-all">{path}</span>
|
||||
<CopyButton value={path ?? ''} title="Copy path" />
|
||||
</div>
|
||||
</div>
|
||||
{#if labels?.length || inheritedLabels?.length}
|
||||
<div class="flex flex-wrap items-center gap-0.5">
|
||||
{#each labels ?? [] as labelText}
|
||||
<Badge color="blue" verySmall class="px-1" title="Label: {labelText}"
|
||||
>{labelText}</Badge
|
||||
>
|
||||
{/each}
|
||||
<InheritedLabels labels={inheritedLabels} />
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-2xs text-tertiary">You can't edit this {kind}'s summary or path.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<div class="min-w-0 truncate flex items-center {compact ? '' : 'flex-col px-2'}">
|
||||
{#if !emptyString(summary) && !compact}
|
||||
<div class="min-w-0 truncate flex items-center flex-col px-2">
|
||||
{#if !emptyString(summary)}
|
||||
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
|
||||
{/if}
|
||||
<span
|
||||
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} text-emphasis truncate"
|
||||
>
|
||||
<span class="text-sm font-semibold text-emphasis truncate">
|
||||
{emptyString(summary) ? (path ?? '') : summary}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
|
||||
import { editPathFor } from '$lib/components/workspacePicker'
|
||||
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
|
||||
@@ -261,24 +261,16 @@
|
||||
|
||||
// Top-bar responsive collapse — container width, not viewport.
|
||||
let topbarWidth = $state(0)
|
||||
// In the page header the buttons share the row with the breadcrumb, so they shed the canvas
|
||||
// toggles and fold Debug runs into the menu sooner: the whole group is ~840px and the trail
|
||||
// with the summary takes ~430px of the same row.
|
||||
// Measured on the band rather than estimated: with the canvas toggles shown the actions alone
|
||||
// are 826-911px and the trail with the summary another ~500, so the row overflows the bar at
|
||||
// every width up to about 1470 — 1350 was not high enough either. Below this the toggles stand
|
||||
// down and the menu carries all three, which is what keeps them reachable rather than merely
|
||||
// out of the way.
|
||||
const compactBelow = $derived(ownsPageHeader ? 1500 : 720)
|
||||
const compactTopbar = $derived.by(() => {
|
||||
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
|
||||
return w > 0 && w < compactBelow
|
||||
})
|
||||
// A phone's bar holds the trail and Deploy. Which panels are open, the export and the
|
||||
// editor/preview switch join the menu compact already shows.
|
||||
const phoneTopbar = $derived(
|
||||
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
|
||||
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
|
||||
// given all it can; anywhere else this editor's own top bar is theirs alone. Folded, the canvas
|
||||
// toggles stand down and the menu carries them and Debug runs, which keeps them reachable
|
||||
// rather than merely out of the way.
|
||||
const compactTopbar = $derived(
|
||||
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
|
||||
)
|
||||
// The second fold holds the trail and Deploy. Which panels are open, the export and the
|
||||
// editor/preview switch join the menu compact already shows.
|
||||
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
|
||||
|
||||
function closeSaveDrawer() {
|
||||
saveDrawerOpen = false
|
||||
@@ -1010,10 +1002,12 @@
|
||||
// Frozen while the rename popover is open so the trail holds still as the user types.
|
||||
kind: 'app',
|
||||
path: pathSnapshot ?? (newEditedPath || $appPath || newPath || undefined),
|
||||
summary: $summary,
|
||||
summaryContent: appSummary,
|
||||
pathTrigger: appPathTrigger
|
||||
}}
|
||||
actions={appHeaderActions}
|
||||
actionFolds={2}
|
||||
contexts={headerContexts}
|
||||
separator="always"
|
||||
/>
|
||||
|
||||
@@ -17,14 +17,16 @@
|
||||
|
||||
type MainButton = {
|
||||
label: string
|
||||
/** Shown under the label once the button has collapsed into a menu. */
|
||||
/** Shown under the label once the button is a row of a menu. */
|
||||
description?: string
|
||||
buttonProps: ButtonProps
|
||||
/** Where the button lands below the `lg` breakpoint, where the bar cannot hold every
|
||||
* button beside the summary: the ellipsis menu, or the dropdown of the enabled bar button
|
||||
* labelled `dropdownOf` (the menu when there is none). Unset keeps it in the bar at every
|
||||
* width. */
|
||||
narrow?: 'menu' | { dropdownOf: string }
|
||||
/** A secondary action: a bar button while the bar has room for every one, then, from the
|
||||
* first fold, in the ellipsis menu or in the dropdown of the enabled bar button labelled
|
||||
* `dropdownOf` (the menu when there is none). Unset makes it a main button, which goes to
|
||||
* the menu only on the folds after that. */
|
||||
placement?: 'menu' | { dropdownOf: string }
|
||||
/** In its `placement` at every width, never a bar button of its own. */
|
||||
alwaysPlaced?: boolean
|
||||
}
|
||||
|
||||
type ButtonProps = any
|
||||
@@ -70,6 +72,9 @@
|
||||
wide?: boolean
|
||||
/** Controls ahead of the menu, such as the way an agent's page runs it. */
|
||||
leading_actions?: import('svelte').Snippet
|
||||
/** Marks that belong to the item's name, such as its favorite star: drawn right after the
|
||||
* summary rather than among the badges and buttons at the far end. */
|
||||
name_marks?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -89,7 +94,8 @@
|
||||
trigger_badges,
|
||||
ownsPageHeader = false,
|
||||
wide = true,
|
||||
leading_actions
|
||||
leading_actions,
|
||||
name_marks
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -98,26 +104,50 @@
|
||||
// no error handler and says so itself.
|
||||
const renameKind = $derived(itemKind ?? errorHandlerKind)
|
||||
|
||||
// Two reasons the row can be too tight, and either one collapses it. The host knows when its
|
||||
// own box is narrow while the window is not — a session's preview panel — and says so through
|
||||
// `wide`. The band knows its own width, which the host cannot see: these buttons share that row
|
||||
// with the breadcrumb, and the trail and summary want ~580px against the set's ~530. Unmeasured
|
||||
// (0) counts as wide, because the bar measures itself on mount and starting narrow would pop
|
||||
// the buttons out of the menu a frame later.
|
||||
// Measured on a flow's page: a 1192px bar leaves the last button 4px past the edge, so the row
|
||||
// has to fold before that rather than at the ~1150 the two parts add up to on paper.
|
||||
const COLLAPSE_BELOW = 1250
|
||||
const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
|
||||
const wideRow = $derived(wide && roomInBar)
|
||||
// The band folds the buttons once its breadcrumb has given all it can (`actionFolds` below):
|
||||
// first the secondary ones go to their `placement`, then every bar button but the last, which
|
||||
// is what the page is for (Edit), then that one too. Outside the band — a session's preview
|
||||
// panel — no bar measures the row, but the host knows when its own box is narrow while the
|
||||
// window is not and says so through `wide`: the secondary buttons fold there, as at the first
|
||||
// fold, and the error handler's toggle goes to the menu.
|
||||
const fold = $derived(ownsPageHeader ? pageHeader.actionFold : wide ? 0 : 1)
|
||||
const wideRow = $derived(wide && fold <= 1)
|
||||
|
||||
const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow))
|
||||
const rowButtons = $derived(mainButtons.filter((b) => !b.placement))
|
||||
const barButtons = $derived(
|
||||
fold === 0
|
||||
? mainButtons.filter((b) => !b.alwaysPlaced)
|
||||
: fold === 1
|
||||
? rowButtons
|
||||
: fold === 2
|
||||
? rowButtons.slice(-1)
|
||||
: []
|
||||
)
|
||||
|
||||
function dropdownHost(btn: MainButton): MainButton | undefined {
|
||||
if (typeof btn.narrow !== 'object') return undefined
|
||||
const label = btn.narrow.dropdownOf
|
||||
if (typeof btn.placement !== 'object' || barButtons.includes(btn)) return undefined
|
||||
const label = btn.placement.dropdownOf
|
||||
return barButtons.find((b) => b.label === label && !b.buttonProps.disabled)
|
||||
}
|
||||
|
||||
/** What the menu holds of the buttons: the folded bar buttons, each followed by what its
|
||||
* dropdown would have held, then the secondary actions with no bar button to hang from. */
|
||||
const menuButtons = $derived.by(() => {
|
||||
const folded = rowButtons.filter((b) => !barButtons.includes(b))
|
||||
const homeless = mainButtons.filter(
|
||||
(b) => b.placement && !barButtons.includes(b) && !dropdownHost(b)
|
||||
)
|
||||
const childOf = (host: MainButton) =>
|
||||
homeless.filter(
|
||||
(b) => typeof b.placement === 'object' && b.placement.dropdownOf === host.label
|
||||
)
|
||||
const placed = new Set(folded.flatMap(childOf))
|
||||
return [
|
||||
...folded.flatMap((host) => [host, ...childOf(host)]),
|
||||
...homeless.filter((b) => !placed.has(b))
|
||||
]
|
||||
})
|
||||
|
||||
async function toggleErrorHandler() {
|
||||
if (!errorHandlerKind || !scriptOrFlowPath) return
|
||||
const next = await toggleWorkspaceErrorHandler(
|
||||
@@ -130,7 +160,7 @@
|
||||
}
|
||||
|
||||
const allMenuItems: Item[] = $derived([
|
||||
...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
|
||||
...menuButtons.map((b) => ({
|
||||
displayName: b.label,
|
||||
description: b.description,
|
||||
icon: b.buttonProps.startIcon,
|
||||
@@ -155,12 +185,11 @@
|
||||
action: item.onclick,
|
||||
disabled: item.disabled,
|
||||
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
|
||||
separatorTop: i === 0 && !wideRow
|
||||
separatorTop: i === 0 && (menuButtons.length > 0 || (!wideRow && !!errorHandlerKind))
|
||||
}))
|
||||
])
|
||||
|
||||
function dropdownItemsOf(host: MainButton) {
|
||||
if (wideRow) return undefined
|
||||
const items = mainButtons
|
||||
.filter((b) => dropdownHost(b) === host)
|
||||
.map((b) => ({
|
||||
@@ -176,15 +205,18 @@
|
||||
</script>
|
||||
|
||||
{#snippet summaryContent()}
|
||||
<SummaryPathDisplay
|
||||
{summary}
|
||||
{path}
|
||||
bind:labels
|
||||
{inheritedLabels}
|
||||
{onSaved}
|
||||
kind={renameKind}
|
||||
compact
|
||||
/>
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<SummaryPathDisplay
|
||||
{summary}
|
||||
{path}
|
||||
bind:labels
|
||||
{inheritedLabels}
|
||||
{onSaved}
|
||||
kind={renameKind}
|
||||
compact
|
||||
/>
|
||||
{#if name_marks}<span class="flex items-center shrink-0">{@render name_marks()}</span>{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- The same editor as the summary's, hung off the band's path segment so it opens under the path.
|
||||
@@ -272,12 +304,17 @@
|
||||
item={{
|
||||
kind: errorHandlerKind,
|
||||
path,
|
||||
// Read-only, the summary control falls back to the path when there is no summary, so the
|
||||
// path is what names the item there and the trail's own copy can go.
|
||||
summary: onSaved ? summary : summary || path,
|
||||
summaryContent,
|
||||
// Only where this page can save one: without `onSaved` the editor behind it has nothing
|
||||
// to write, and the band keeps its own segment, which copies the path.
|
||||
pathTrigger: onSaved ? pathTrigger : undefined
|
||||
}}
|
||||
{actions}
|
||||
actionFolds={3}
|
||||
earlyActionFolds={1}
|
||||
contexts={headerContexts}
|
||||
separator="always"
|
||||
/>
|
||||
@@ -301,6 +338,7 @@
|
||||
kind={renameKind}
|
||||
/>
|
||||
</div>
|
||||
{@render name_marks?.()}
|
||||
{@render badges('md')}
|
||||
</div>
|
||||
<div class="flex gap-1 items-center pr-4">
|
||||
|
||||
@@ -369,7 +369,7 @@
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new flow from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: operatingHref(`/flows/add?template=${flow.path}`),
|
||||
onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`),
|
||||
@@ -390,7 +390,7 @@
|
||||
buttons.push({
|
||||
label: editInForkLabel(workspace, $userWorkspaces),
|
||||
description: editInForkDescription('flow', workspace, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('flow', flow.path, workspace),
|
||||
onClick: (e: Event | undefined) => editInFork(e, flow.path),
|
||||
@@ -418,7 +418,7 @@
|
||||
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => flowHistory?.open(),
|
||||
unifiedSize: 'md',
|
||||
@@ -435,7 +435,8 @@
|
||||
if (!actingUser?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
alwaysPlaced: true,
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
@@ -682,6 +683,12 @@
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet name_marks()}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace && flow && workspace === $workspaceStore}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trigger_badges()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
@@ -698,12 +705,6 @@
|
||||
small={false}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if workspace && flow}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace === $workspaceStore}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
{/if}
|
||||
<OnBehalfOfBadge
|
||||
onBehalfOf={flow?.on_behalf_of}
|
||||
onBehalfOfEmail={flow?.on_behalf_of_email}
|
||||
|
||||
@@ -532,7 +532,7 @@
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new script from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: operatingHref(`/scripts/add?template=${script.path}`),
|
||||
onClick: interceptNav(onNavigate, `/scripts/add?template=${script.path}`),
|
||||
@@ -553,7 +553,7 @@
|
||||
buttons.push({
|
||||
label: editInForkLabel(workspace, $userWorkspaces),
|
||||
description: editInForkDescription('script', workspace, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('script', script.path, workspace),
|
||||
onClick: (e: Event | undefined) => editInFork(e, script.path),
|
||||
@@ -586,7 +586,7 @@
|
||||
if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) {
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => {
|
||||
versionsDrawerOpen = !versionsDrawerOpen
|
||||
@@ -602,7 +602,8 @@
|
||||
if (!actingUser?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
alwaysPlaced: true,
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromScript(script.path, script.summary, script.schema)
|
||||
@@ -870,6 +871,12 @@
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet name_marks()}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace && script && workspace === $workspaceStore}
|
||||
<Star kind="script" path={script.path} summary={script.summary} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trigger_badges()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
@@ -887,12 +894,6 @@
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if workspace && script}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace === $workspaceStore}
|
||||
<Star kind="script" path={script.path} summary={script.summary} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if script?.auto_kind === 'wac'}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
<PageHeaderContent
|
||||
item={{
|
||||
path: pathSnapshot ?? shownPath,
|
||||
summary: draft?.state?.description,
|
||||
summaryContent: agentSummary,
|
||||
// Only where the rename behind it can be saved: a reader who cannot write this agent
|
||||
// keeps the segment that copies the path, rather than an editor with nothing to edit.
|
||||
|
||||
@@ -52,6 +52,26 @@ export type PageHeaderContent = {
|
||||
* 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. */
|
||||
@@ -91,10 +111,9 @@ let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
|
||||
let nextId = 0
|
||||
|
||||
/**
|
||||
* Below this bar width a page keeps one button — the thing the page is for — and folds the rest
|
||||
* into the menu it already shows when compact. A phone in portrait is ~390px of bar and a tablet
|
||||
* in portrait ~720; the breadcrumb with its workspace disc takes ~150 of either, which is why the
|
||||
* line sits above the tablet rather than between the two.
|
||||
* 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
|
||||
|
||||
@@ -112,6 +131,9 @@ export const BAR_HEIGHT_PX = 48
|
||||
// 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.
|
||||
@@ -151,6 +173,11 @@ export const pageHeader = {
|
||||
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
|
||||
@@ -176,6 +203,17 @@ export const pageHeader = {
|
||||
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++
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BAR_STAGES, fitBar, type BarMeasure } from './pageHeaderStages'
|
||||
|
||||
// Each stage 50px narrower than the last, so the bar width alone says which one fits.
|
||||
const trail = BAR_STAGES.map((_, i) => 600 - i * 50)
|
||||
const base: BarMeasure = {
|
||||
barWidth: 2000,
|
||||
chrome: 0,
|
||||
trail,
|
||||
summary: BAR_STAGES.map(() => 0),
|
||||
actions: [300, 200, 100],
|
||||
folds: 2,
|
||||
natural: { family: 0, fork: 0, summary: 0, name: 0 }
|
||||
}
|
||||
|
||||
describe('fitBar', () => {
|
||||
it('picks the first stage that fits beside the full row of actions', () => {
|
||||
expect(fitBar({ ...base, barWidth: 900 }).stage).toBe(0)
|
||||
expect(fitBar({ ...base, barWidth: 800 })).toMatchObject({ stage: 2, fold: 0 })
|
||||
})
|
||||
|
||||
it('folds the actions only once the breadcrumb is at its last stage, and keeps it there', () => {
|
||||
// 350 + 300 overflows a 600px bar; 350 + 200 fits. Stage 0 would fit beside the folded row
|
||||
// too, but the parts a narrowing bar dropped do not come back when the buttons fold.
|
||||
expect(fitBar({ ...base, barWidth: 600 })).toMatchObject({ stage: 5, fold: 1 })
|
||||
expect(fitBar({ ...base, barWidth: 400 })).toMatchObject({ stage: 5, fold: 2 })
|
||||
})
|
||||
|
||||
it('spends early folds before the breadcrumb drops anything', () => {
|
||||
// 600 + 200 fits an 800px bar at the first fold; without early folds stage 2 would have
|
||||
// dropped parts beside the full row instead.
|
||||
expect(fitBar({ ...base, barWidth: 800, earlyFolds: 1 })).toMatchObject({ stage: 0, fold: 1 })
|
||||
// Past that, the breadcrumb gives way at the early fold before the later folds start.
|
||||
expect(fitBar({ ...base, barWidth: 700, earlyFolds: 1 })).toMatchObject({ stage: 2, fold: 1 })
|
||||
})
|
||||
|
||||
it('tries a level it has not seen at the width of the next one folded further', () => {
|
||||
// Only the second fold has been measured: the levels before it are assumed no wider, so the
|
||||
// bar tries the full row first and goes on to the first fold once it has seen it overflow.
|
||||
const unseen = { ...base, barWidth: 600, actions: [undefined, undefined, 100] }
|
||||
expect(fitBar(unseen).fold).toBe(0)
|
||||
expect(fitBar({ ...unseen, actions: [300, undefined, 100] }).fold).toBe(1)
|
||||
})
|
||||
|
||||
it("takes the squeeze from the path's name before the summary", () => {
|
||||
const fit = fitBar({
|
||||
...base,
|
||||
barWidth: 360,
|
||||
actions: [0],
|
||||
folds: 0,
|
||||
trail: trail.map((w) => w - 400),
|
||||
summary: BAR_STAGES.map(() => 100),
|
||||
natural: { family: 0, fork: 0, summary: 300, name: 300 }
|
||||
})
|
||||
// Stage 0 at its floors is 300px; whole, the name and the summary add 140 and 100, so 180
|
||||
// has to go: all 140 the name has above its floor, then 40 from the summary.
|
||||
expect(fit).toMatchObject({ stage: 0, caps: { name: 160, summary: 260 }, scopesGive: false })
|
||||
})
|
||||
|
||||
it('keeps the summary and drops the path when only one of them fits', () => {
|
||||
const natural = { family: 0, fork: 0, summary: 300, name: 300 }
|
||||
// Stage 2 is the first without the path: the name is no longer a part the bar sizes.
|
||||
const fit = fitBar({ ...base, barWidth: 520, natural, actions: [0], folds: 0 })
|
||||
expect(fit.stage).toBe(2)
|
||||
expect(fit.caps.name).toBeUndefined()
|
||||
// A placeholder offering to add a summary names nothing: the path stays a part the bar sizes.
|
||||
const placeholder = fitBar({
|
||||
...base,
|
||||
barWidth: 520,
|
||||
natural,
|
||||
actions: [0],
|
||||
folds: 0,
|
||||
summaryNamesItem: false
|
||||
})
|
||||
expect(placeholder.caps.name).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* How the page header gives up width. Whole parts drop in a fixed order, and the bar shows the
|
||||
* first stage whose parts, each held at its floor width, fit beside the page's actions. Which
|
||||
* stage that is depends on the names in the bar rather than on a pixel breakpoint. Past the last
|
||||
* stage the page's actions fold, one level at a time; past their last level the item's name and
|
||||
* its summary truncate, then the folders above it.
|
||||
*/
|
||||
|
||||
/** What the breadcrumb itself drops. */
|
||||
export type TrailStage = {
|
||||
/** Inside a fork, the family's name before the fork's. Outside one it is the workspace's own
|
||||
* name and stays until `wsButton`. */
|
||||
familyName: boolean
|
||||
/** The item's path. Dropped only for an item that has a summary: the summary is what a
|
||||
* person calls the thing, while its path is where it is filed. Without one, the path is the
|
||||
* item's only name and stays. */
|
||||
path: boolean
|
||||
forkName: boolean
|
||||
/** The workspace part as its disc alone, still leading home, beside its picker's chevron. */
|
||||
wsButton: boolean
|
||||
/** The folders between the first one and the name; collapsed, they read as `…`. */
|
||||
middleLevels: boolean
|
||||
}
|
||||
|
||||
export type BarStage = TrailStage & { label: string }
|
||||
|
||||
export const BAR_STAGES: BarStage[] = [
|
||||
{
|
||||
label: 'Full',
|
||||
familyName: true,
|
||||
path: true,
|
||||
forkName: true,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Family name dropped',
|
||||
familyName: false,
|
||||
path: true,
|
||||
forkName: true,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Path dropped for the summary',
|
||||
familyName: false,
|
||||
path: false,
|
||||
forkName: true,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Fork name dropped',
|
||||
familyName: false,
|
||||
path: false,
|
||||
forkName: false,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Workspace as its disc',
|
||||
familyName: false,
|
||||
path: false,
|
||||
forkName: false,
|
||||
wsButton: true,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Middle folders collapsed',
|
||||
familyName: false,
|
||||
path: false,
|
||||
forkName: false,
|
||||
wsButton: true,
|
||||
middleLevels: false
|
||||
}
|
||||
]
|
||||
|
||||
export type Part = 'family' | 'fork' | 'summary' | 'name'
|
||||
|
||||
/** The trail at each stage with its names at their floors, and each name's own width. */
|
||||
export type TrailMeasures = { trail: number[]; family: number; fork: number; name: number }
|
||||
|
||||
/** The width a part may truncate to before the next stage drops something whole. The summary's
|
||||
* is wide: below it the path goes rather than the summary losing most of what it says. */
|
||||
export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 200, name: 160 }
|
||||
/** Past the last stage and fold, the item's name and then its summary keep this much before the
|
||||
* folders give any. */
|
||||
export const SQUEEZED_FLOOR = 40
|
||||
|
||||
/** Middle folders collapsed to one `…`; the first folder and the name always stay. */
|
||||
export function shownPathLevels(levels: string[], stage: TrailStage): string[] {
|
||||
const scopes = levels.slice(0, -1)
|
||||
if (stage.middleLevels || scopes.length <= 1) return levels
|
||||
return [scopes[0], '…', levels[levels.length - 1]]
|
||||
}
|
||||
|
||||
/** Whether a part is on screen at a stage. A natural width of 0 means the page has no such part. */
|
||||
export function partVisible(
|
||||
part: Part,
|
||||
stage: BarStage,
|
||||
natural: Record<Part, number>,
|
||||
summaryNamesItem = true
|
||||
): boolean {
|
||||
if (natural[part] <= 0) return false
|
||||
switch (part) {
|
||||
case 'summary':
|
||||
return true
|
||||
case 'family':
|
||||
// Outside a fork the family's name is the workspace's: it goes only with the whole part.
|
||||
return !stage.wsButton && (stage.familyName || natural.fork <= 0)
|
||||
case 'fork':
|
||||
return !stage.wsButton && stage.forkName
|
||||
case 'name':
|
||||
return stage.path || natural.summary <= 0 || !summaryNamesItem
|
||||
}
|
||||
}
|
||||
|
||||
export type BarMeasure = {
|
||||
barWidth: number
|
||||
/** The bar's padding and the sidebar handle: what is there at every stage. */
|
||||
chrome: number
|
||||
/** Per stage: the breadcrumb with each name at its floor. */
|
||||
trail: number[]
|
||||
/** Per stage: the summary at its floor with the separator before it, 0 where there is none. */
|
||||
summary: number[]
|
||||
/** Per fold level: the actions with the gap before them, undefined for a level not yet seen. */
|
||||
actions: (number | undefined)[]
|
||||
/** How many levels past the full row the page's actions can fold through. */
|
||||
folds: number
|
||||
/** How many of those levels fold before the breadcrumb drops anything: buttons that are there
|
||||
* only while there is room for everything. */
|
||||
earlyFolds?: number
|
||||
natural: Record<Part, number>
|
||||
/** The summary is the item's own text, not a placeholder offering to add one: only then does it
|
||||
* name the item well enough for the path to go. */
|
||||
summaryNamesItem?: boolean
|
||||
}
|
||||
|
||||
export type BarFit = {
|
||||
stage: number
|
||||
fold: number
|
||||
/** Max widths for the parts that give width, in the order they give it. */
|
||||
caps: Partial<Record<Part, number>>
|
||||
/** Even the name at its squeezed floor does not fit: the folders above it truncate too. */
|
||||
scopesGive: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Flex shrinks every part in proportion, so however lopsided the factors, the name gives a few
|
||||
* pixels while the summary still has room, and a few pixels are an ellipsis. The parts are rigid
|
||||
* instead and the deficit is taken from them in a strict order, each down to its floor.
|
||||
*/
|
||||
export function fitBar(m: BarMeasure): BarFit {
|
||||
// A level not yet seen is assumed as narrow as the next one folded further: trying it is how it
|
||||
// gets measured, and once it has been, the bar never picks it again unless it fits.
|
||||
const actionsAt = (fold: number) => {
|
||||
for (let f = fold; f < m.actions.length; f++)
|
||||
if (m.actions[f] !== undefined) return m.actions[f]!
|
||||
return 0
|
||||
}
|
||||
const last = BAR_STAGES.length - 1
|
||||
const floorWidth = (s: number, f: number) =>
|
||||
m.chrome + (m.trail[s] ?? 0) + (m.summary[s] ?? 0) + actionsAt(f)
|
||||
|
||||
const early = Math.min(m.earlyFolds ?? 0, m.folds)
|
||||
let stage = last
|
||||
let fold = m.folds
|
||||
choose: {
|
||||
for (let f = 0; f < early; f++) {
|
||||
if (floorWidth(0, f) <= m.barWidth) {
|
||||
stage = 0
|
||||
fold = f
|
||||
break choose
|
||||
}
|
||||
}
|
||||
for (let s = 0; s <= last; s++) {
|
||||
if (floorWidth(s, early) <= m.barWidth) {
|
||||
stage = s
|
||||
fold = early
|
||||
break choose
|
||||
}
|
||||
}
|
||||
// The other levels fold only once the breadcrumb has given all it can, so a narrowing bar
|
||||
// only ever loses things: the parts it dropped do not come back when the buttons fold.
|
||||
for (let f = early + 1; f <= m.folds; f++) {
|
||||
if (floorWidth(last, f) <= m.barWidth) {
|
||||
fold = f
|
||||
break choose
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const at = BAR_STAGES[stage]
|
||||
// The summary gives last: it is what a person calls the item, so the path's name goes first.
|
||||
const parts = (['family', 'fork', 'name', 'summary'] as Part[]).filter((p) =>
|
||||
partVisible(p, at, m.natural, m.summaryNamesItem ?? true)
|
||||
)
|
||||
const slack = (p: Part) => Math.max(0, m.natural[p] - Math.min(m.natural[p], FLOOR[p]))
|
||||
// The stage was measured with its parts at their floors; this is its width with them whole.
|
||||
let deficit = floorWidth(stage, fold) + parts.reduce((s, p) => s + slack(p), 0) - m.barWidth
|
||||
const caps: Partial<Record<Part, number>> = {}
|
||||
const take = (p: Part, floor: number) => {
|
||||
if (deficit <= 0 || !parts.includes(p)) return
|
||||
const current = caps[p] ?? m.natural[p]
|
||||
const give = Math.min(deficit, Math.max(0, current - Math.min(m.natural[p], floor)))
|
||||
if (give > 0) caps[p] = current - give
|
||||
deficit -= give
|
||||
}
|
||||
for (const p of parts) take(p, FLOOR[p])
|
||||
take('name', SQUEEZED_FLOOR)
|
||||
take('summary', SQUEEZED_FLOOR)
|
||||
return { stage, fold, caps, scopesGive: deficit > 0 }
|
||||
}
|
||||
@@ -80,7 +80,7 @@
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
@@ -318,20 +318,15 @@
|
||||
|
||||
// Top-bar responsive collapse — container width, not viewport.
|
||||
let topbarWidth = $state(0)
|
||||
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
|
||||
// the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff,
|
||||
// Jobs, Export, AI and Deploy — is ~560px.
|
||||
const compactBelow = $derived(ownsPageHeader ? 1350 : 720)
|
||||
const compactTopbar = $derived.by(() => {
|
||||
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
|
||||
return w > 0 && w < compactBelow
|
||||
})
|
||||
// A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
|
||||
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
|
||||
// in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always.
|
||||
const phoneTopbar = $derived(
|
||||
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
|
||||
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
|
||||
// given all it can; anywhere else this editor's own top bar is theirs alone.
|
||||
const compactTopbar = $derived(
|
||||
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
|
||||
)
|
||||
// The second fold keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
|
||||
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
|
||||
// in the menu, Jobs from `compactTopbar` (true at the second fold too) and the export always.
|
||||
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
|
||||
|
||||
// Top-bar button size + bar height. Condensed (session preview) uses the
|
||||
// smallest well-supported unified size (`sm`) so the bar is thinner.
|
||||
@@ -914,8 +909,8 @@
|
||||
</a>
|
||||
— a public page that needs no login and can be embedded in an iframe. Host the JSON anywhere
|
||||
it can be fetched (S3, GitHub raw, your docs site) and link
|
||||
<span class="font-mono">/replay?src=<url></span> to have it load itself. Windmill
|
||||
keeps no copy.
|
||||
<span class="font-mono">/replay?src=<url></span> to have it load itself. Windmill keeps
|
||||
no copy.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
@@ -956,10 +951,12 @@
|
||||
// rename popover is open so the trail holds still as the user types.
|
||||
kind: 'app',
|
||||
path: pathSnapshot ?? (newEditedPath || appPath || newPath || undefined),
|
||||
summary,
|
||||
summaryContent: appSummary,
|
||||
pathTrigger: rawAppPathTrigger
|
||||
}}
|
||||
actions={rawAppHeaderActions}
|
||||
actionFolds={2}
|
||||
contexts={headerContexts}
|
||||
separator="none"
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
isHubFlowPath,
|
||||
isFlowPreview,
|
||||
isNotFlow,
|
||||
isScriptPreview
|
||||
isScriptPreview,
|
||||
type Item
|
||||
} from '$lib/utils'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
|
||||
@@ -714,10 +715,197 @@
|
||||
onDestroy(resetFavicon)
|
||||
}
|
||||
|
||||
// The bar shares its row with the breadcrumb, and this page's name is a uuid that gives way
|
||||
// grudgingly, so the buttons drop their labels rather than push each other off the end.
|
||||
// Embedded, the buttons have a row of their own that wraps.
|
||||
const compactBar = $derived(!embedded && pageHeader.barWidth > 0 && pageHeader.barWidth < 1150)
|
||||
// The bar folds these (`actionFolds` below) once its breadcrumb has given all it can: first the
|
||||
// buttons drop their labels, then the plain actions go into one menu. Embedded, the buttons
|
||||
// have a row of their own that wraps.
|
||||
const compactBar = $derived(!embedded && pageHeader.actionFold >= 1)
|
||||
const actionsInMenu = $derived(!embedded && pageHeader.actionFold >= 2)
|
||||
|
||||
const isScriptJob = $derived(job?.job_kind === 'script')
|
||||
const isRunnableJob = $derived(
|
||||
job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'
|
||||
)
|
||||
const isHubFlowPreview = $derived(isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path))
|
||||
const runsHref = $derived(`/runs/${job?.script_path}${!isScriptJob ? '?jobKind=flow' : ''}`)
|
||||
const stem = $derived(job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`)
|
||||
// A script is addressed by the hash this run executed. Embedded, that goes in the query: the
|
||||
// host's tabs are keyed by an item's path, and a hash there would be a tab on no item.
|
||||
const viewHref = $derived(
|
||||
isScriptJob && embedded
|
||||
? `/scripts/get/${job?.script_path}?version=${job?.script_hash}`
|
||||
: `${stem}/get/${isScriptJob ? job?.script_hash : job?.script_path}`
|
||||
)
|
||||
const editHref = $derived(`${stem}/edit/${job?.script_path}?workspace=${workspaceId}`)
|
||||
const forkPreviewLabel = $derived(
|
||||
isHubFlowPreview
|
||||
? 'Fork flow into workspace'
|
||||
: `Fork ${isFlowPreview(job?.job_kind) ? 'flow' : 'code'} preview`
|
||||
)
|
||||
const viewLabel = $derived(`View ${job?.job_kind === 'script_hub' ? 'script' : job?.job_kind}`)
|
||||
const canDelete = $derived(
|
||||
!!job && 'deleted' in job && !job?.deleted && ($superadmin || (actingUser?.is_admin ?? false))
|
||||
)
|
||||
const canCancel = $derived(
|
||||
!!job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)
|
||||
)
|
||||
const canDebug = $derived(
|
||||
(job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) &&
|
||||
!!job?.['running'] &&
|
||||
job?.parent_job == undefined
|
||||
)
|
||||
const canEdit = $derived(
|
||||
(job?.job_kind === 'script' || job?.job_kind === 'flow') &&
|
||||
!actingUser?.operator &&
|
||||
editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})
|
||||
)
|
||||
const canEditInFork = $derived(
|
||||
(job?.job_kind === 'script' || job?.job_kind === 'flow') &&
|
||||
!actingUser?.operator &&
|
||||
!showEditButton &&
|
||||
!isCloudHosted() &&
|
||||
editInForkAllowed(workspaceId, $userWorkspaces)
|
||||
)
|
||||
/** Which actions the row has: they come and go as the job runs and completes, often while the
|
||||
* row is folded into the menu, where the bar cannot see them change (`actionsVersion`). */
|
||||
const actionsVersion = $derived(
|
||||
[
|
||||
canDelete,
|
||||
canCancel,
|
||||
forceCancel,
|
||||
canDebug,
|
||||
canRunNow,
|
||||
canEdit,
|
||||
canEditInFork,
|
||||
dbtResumable,
|
||||
!!job?.schedule_path,
|
||||
persistentScriptDefinition !== undefined,
|
||||
restartStep !== undefined && canRestart(restart)
|
||||
]
|
||||
.map(Number)
|
||||
.join('')
|
||||
)
|
||||
|
||||
async function runAgain() {
|
||||
// The form this lands on rebuilds the whole project. When resuming THIS run is the cheaper
|
||||
// thing to do, name it so the form can offer that in one click rather than leaving the
|
||||
// reader to know.
|
||||
const from = dbtResumable
|
||||
? `${viewHref.includes('?') ? '&' : '?'}dbt_retry_from=${job?.id}`
|
||||
: ''
|
||||
onNavigate(
|
||||
viewHref + from + `#${computeSharableHash(job?.args, await getRerunTagOverride(job?.args))}`
|
||||
)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (!job?.id) return
|
||||
cancelJob(job.id)
|
||||
// The first cancel asks the job to stop; if it has not after three seconds, the same
|
||||
// control offers to force it.
|
||||
if (!forceCancel) setTimeout(() => (forceCancel = true), 3001)
|
||||
}
|
||||
|
||||
// The same actions as the row, most used first; the restart and session controls stay beside
|
||||
// the menu, since each opens a popover of its own that a menu row cannot hold.
|
||||
const menuItems = $derived.by((): Item[] => {
|
||||
if (!job) return []
|
||||
const kind = isScriptJob ? 'script' : 'flow'
|
||||
return [
|
||||
{ displayName: viewLabel, icon: Code2, href: viewHref, hide: !isRunnableJob },
|
||||
{ displayName: 'Run again', icon: RefreshCw, action: runAgain, hide: !isRunnableJob },
|
||||
{
|
||||
displayName: 'dbt retry with same args',
|
||||
icon: RefreshCw,
|
||||
action: resumeDbtRun,
|
||||
hide: !isRunnableJob || !dbtResumable
|
||||
},
|
||||
{
|
||||
displayName: 'Run immediately with same args',
|
||||
icon: RefreshCw,
|
||||
action: () => runImmediately(),
|
||||
hide: !isRunnableJob
|
||||
},
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: Pen,
|
||||
href: editHref,
|
||||
action: () => ($initialArgsStore = job?.args),
|
||||
disabled: !showEditButton,
|
||||
hide: !canEdit
|
||||
},
|
||||
{
|
||||
displayName: editInForkLabel(workspaceId, $userWorkspaces),
|
||||
icon: Pen,
|
||||
href: buildForkEditUrl(kind, job.script_path ?? '', workspaceId),
|
||||
action: (e) => editInFork(e, kind, job?.script_path ?? ''),
|
||||
hide: !canEditInFork
|
||||
},
|
||||
{
|
||||
displayName: 'Run now',
|
||||
icon: Play,
|
||||
action: () => job?.id && runJobNow(job.id),
|
||||
hide: !canRunNow
|
||||
},
|
||||
{
|
||||
displayName: 'Edit schedule',
|
||||
icon: Calendar,
|
||||
action: () =>
|
||||
job?.schedule_path && scheduleEditor?.openEdit(job.schedule_path, job.job_kind == 'flow'),
|
||||
hide: !job.schedule_path
|
||||
},
|
||||
{
|
||||
displayName: forkPreviewLabel,
|
||||
icon: GitBranch,
|
||||
action: forkPreview,
|
||||
hide: !(isFlowPreview(job.job_kind) || isScriptPreview(job.job_kind))
|
||||
},
|
||||
{
|
||||
displayName: 'Current runs',
|
||||
icon: Activity,
|
||||
action: () => persistentScriptDrawer?.open?.(persistentScriptDefinition),
|
||||
hide: persistentScriptDefinition === undefined
|
||||
},
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: List,
|
||||
href: runsHref,
|
||||
hide: !canDelete || !(job.job_kind === 'script' || job.job_kind === 'flow')
|
||||
},
|
||||
{
|
||||
displayName: 'Copy link for members',
|
||||
icon: Users,
|
||||
action: () => job && shareReadLink(job.id),
|
||||
separatorTop: true
|
||||
},
|
||||
{
|
||||
displayName: 'Copy public link',
|
||||
icon: Globe,
|
||||
disabled: !canSharePublicly,
|
||||
action: () => job && sharePublicLink(job.id)
|
||||
},
|
||||
{
|
||||
displayName: 'Show flow debug info',
|
||||
icon: EllipsisVertical,
|
||||
action: debugInfo,
|
||||
hide: !canDebug
|
||||
},
|
||||
{
|
||||
displayName: forceCancel ? 'Force cancel' : 'Cancel',
|
||||
icon: TimerOff,
|
||||
type: 'delete',
|
||||
action: cancel,
|
||||
hide: !canCancel,
|
||||
separatorTop: true
|
||||
},
|
||||
{
|
||||
displayName: 'Delete result, logs and args (admin only)',
|
||||
icon: Trash,
|
||||
type: 'delete',
|
||||
action: () => job?.id && deleteCompletedJob(job.id),
|
||||
hide: !canDelete
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<HighlightTheme />
|
||||
@@ -890,14 +1078,47 @@
|
||||
{@render runActions()}
|
||||
</div>
|
||||
{:else}
|
||||
<PageHeaderContent section={{ label: `run/${runId}` }} actions={runActions} />
|
||||
<PageHeaderContent
|
||||
section={{ label: `run/${runId}` }}
|
||||
actions={runActions}
|
||||
actionFolds={2}
|
||||
{actionsVersion}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#snippet runActions()}
|
||||
{@const isScript = job?.job_kind === 'script'}
|
||||
{@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)}
|
||||
{@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`}
|
||||
{#if job && 'deleted' in job && !job?.deleted && ($superadmin || (actingUser?.is_admin ?? false))}
|
||||
{#if actionsInMenu}
|
||||
{#if restartStep !== undefined && canRestart(restart)}
|
||||
{@render flowRestartButton(restart, restartStep, 'button')}
|
||||
{/if}
|
||||
{#if canEdit && showEditButton}
|
||||
<OpenInSessionButton
|
||||
source={{
|
||||
target: { kind: isScriptJob ? 'script' : 'flow', path: job?.script_path ?? '' },
|
||||
workspaceId: workspaceId ?? undefined
|
||||
}}
|
||||
btnProps={{ unifiedSize: 'sm' }}
|
||||
/>
|
||||
{/if}
|
||||
<Dropdown items={menuItems} customWidth={260}>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
title="Run actions"
|
||||
startIcon={{ icon: EllipsisVertical }}
|
||||
/>
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
{:else}
|
||||
{@render runActionsRow()}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet runActionsRow()}
|
||||
{#if canDelete}
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
@@ -962,13 +1183,6 @@
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
{/if}
|
||||
{@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`}
|
||||
<!-- A script is addressed by the hash this run executed. Embedded, that goes in the query:
|
||||
the host's tabs are keyed by an item's path, and a hash there would be a tab on no item. -->
|
||||
{@const viewHref =
|
||||
isScript && embedded
|
||||
? `/scripts/get/${job?.script_path}?version=${job?.script_hash}`
|
||||
: `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`}
|
||||
{#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined}
|
||||
<div class="inline">
|
||||
<Dropdown
|
||||
@@ -1086,19 +1300,7 @@
|
||||
{/if}
|
||||
{#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'}
|
||||
<Button
|
||||
on:click={async () => {
|
||||
// The form this lands on rebuilds the whole project. When resuming
|
||||
// THIS run is the cheaper thing to do, name it so the form can
|
||||
// offer that in one click rather than leaving the reader to know.
|
||||
const from = dbtResumable
|
||||
? `${viewHref.includes('?') ? '&' : '?'}dbt_retry_from=${job?.id}`
|
||||
: ''
|
||||
onNavigate(
|
||||
viewHref +
|
||||
from +
|
||||
`#${computeSharableHash(job?.args, await getRerunTagOverride(job?.args))}`
|
||||
)
|
||||
}}
|
||||
on:click={runAgain}
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
iconOnly={compactBar}
|
||||
@@ -1122,7 +1324,7 @@
|
||||
{/if}
|
||||
{#if job?.job_kind === 'script' || job?.job_kind === 'flow'}
|
||||
{#if !actingUser?.operator}
|
||||
{#if editRights.canEditItem(isScript ? 'script' : 'flow', job?.script_path ?? '', {})}
|
||||
{#if editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})}
|
||||
<Button
|
||||
href={`${stem}/edit/${job?.script_path}?workspace=${workspaceId}`}
|
||||
on:click={() => {
|
||||
@@ -1141,7 +1343,7 @@
|
||||
Edit: where direct deployment is off, the way in is "Edit in fork". -->
|
||||
<OpenInSessionButton
|
||||
source={{
|
||||
target: { kind: isScript ? 'script' : 'flow', path: job?.script_path ?? '' },
|
||||
target: { kind: isScriptJob ? 'script' : 'flow', path: job?.script_path ?? '' },
|
||||
workspaceId: workspaceId ?? undefined
|
||||
}}
|
||||
btnProps={{ unifiedSize: 'sm' }}
|
||||
@@ -1151,11 +1353,11 @@
|
||||
{#if !showEditButton && !isCloudHosted() && editInForkAllowed(workspaceId, $userWorkspaces)}
|
||||
<Button
|
||||
href={buildForkEditUrl(
|
||||
isScript ? 'script' : 'flow',
|
||||
isScriptJob ? 'script' : 'flow',
|
||||
job?.script_path ?? '',
|
||||
workspaceId
|
||||
)}
|
||||
onClick={(e) => editInFork(e, isScript ? 'script' : 'flow', job?.script_path ?? '')}
|
||||
onClick={(e) => editInFork(e, isScriptJob ? 'script' : 'flow', job?.script_path ?? '')}
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
iconOnly={compactBar}
|
||||
|
||||
Reference in New Issue
Block a user