mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 08:03:11 +00:00
The header band now carries a single breadcrumb — workspace, fork, then the item's path — built from one component, so every segment has the same size, weight and icon size, and slashes separate them. The item's kind icon rides with its summary, after a dot, rather than with the path. The fork and drafts banners move into the band as badges plus a subtle Review button: ahead/behind counts instead of a bare change total, and the per-kind breakdown stays on the compare page. The home hero's CLI, MCP and Hub links move into an ellipsis menu, alongside the toggle that pins the session chat to the page. The flow editor and the app viewer hand their bar to the band too, and a route can drop the band's bottom border when its own content draws one — the border stays in the box with a transparent colour, so contents never shift a pixel across a navigation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
256 lines
7.1 KiB
Svelte
256 lines
7.1 KiB
Svelte
<script lang="ts">
|
|
import { emptyString, isOwner } from '$lib/utils'
|
|
import { Alert, Button } 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'
|
|
import { userStore } from '$lib/stores'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { updateItemPathAndSummary, checkFlowOnBehalfOf } from './moveRenameManager'
|
|
import Label from './Label.svelte'
|
|
import LabelsInput from './LabelsInput.svelte'
|
|
import InheritedLabels from './InheritedLabels.svelte'
|
|
import Badge from './common/badge/Badge.svelte'
|
|
import {
|
|
useOperatingUser,
|
|
useOperatingWorkspace
|
|
} from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
const operatingUser = useOperatingUser()
|
|
const actingUser = $derived(operatingUser.current)
|
|
|
|
interface Props {
|
|
summary?: string
|
|
path?: string
|
|
labels?: string[] | undefined
|
|
inheritedLabels?: string[] | undefined
|
|
editable?: boolean
|
|
onSaved?: (newPath: string) => void
|
|
kind?: 'flow' | 'script'
|
|
/** Header variant: no path line (the host shows it) and a lighter summary. */
|
|
compact?: boolean
|
|
}
|
|
|
|
let {
|
|
summary = $bindable(''),
|
|
path = $bindable(''),
|
|
labels = $bindable(),
|
|
inheritedLabels = undefined,
|
|
editable = false,
|
|
onSaved,
|
|
kind = 'flow',
|
|
compact = false
|
|
}: Props = $props()
|
|
|
|
let editSummary = $state('')
|
|
let editPath = $state('')
|
|
let dirtyPath = $state(false)
|
|
let popoverOpen = $state(false)
|
|
let ownPath = $state<string | undefined>(undefined)
|
|
// Derived: ownership answers about the operating workspace, whose user resolves asynchronously.
|
|
const own = $derived(
|
|
ownPath === undefined ? false : isOwner(ownPath, actingUser, $operatingWorkspace)
|
|
)
|
|
let onBehalfOfEmail = $state<string | undefined>(undefined)
|
|
let summaryInput: ReturnType<typeof TextInput> | undefined = $state()
|
|
let labelsDirty = $state(false)
|
|
let hasChanges = $derived(editSummary !== (summary ?? '') || (own && dirtyPath) || labelsDirty)
|
|
|
|
$effect(() => {
|
|
if (popoverOpen && onSaved) {
|
|
editSummary = summary ?? ''
|
|
editPath = path ?? ''
|
|
labelsDirty = false
|
|
ownPath = path ?? ''
|
|
onBehalfOfEmail = undefined
|
|
if (kind === 'flow' && $operatingWorkspace && path) {
|
|
checkFlowOnBehalfOf($operatingWorkspace, path).then((email) => {
|
|
onBehalfOfEmail = email
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
async function save(close: () => void) {
|
|
const initialPath = path ?? ''
|
|
const newPath = own ? editPath : initialPath
|
|
|
|
try {
|
|
await updateItemPathAndSummary({
|
|
workspace: $operatingWorkspace!,
|
|
kind,
|
|
initialPath,
|
|
newPath,
|
|
newSummary: editSummary,
|
|
labels
|
|
})
|
|
sendUserToast(`${kind === 'flow' ? 'Flow' : 'Script'} updated`)
|
|
labelsDirty = false
|
|
close()
|
|
onSaved?.(newPath)
|
|
} catch (e: any) {
|
|
sendUserToast(`Could not update ${kind}: ${e.body ?? e.message}`, true)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if editable || onSaved}
|
|
<Popover
|
|
class="min-w-0 max-w-full"
|
|
placement="bottom-start"
|
|
contentClasses="p-4"
|
|
usePointerDownOutside
|
|
excludeSelectors=".drawer"
|
|
disableFocusTrap
|
|
openFocus={() => {
|
|
summaryInput?.focus()
|
|
return null
|
|
}}
|
|
bind:isOpen={popoverOpen}
|
|
>
|
|
{#snippet trigger()}
|
|
<div
|
|
class="min-w-0 truncate flex rounded-md transition-colors cursor-pointer hover:bg-surface-hover {compact
|
|
? 'items-center px-1 py-0.5'
|
|
: 'flex-col items-start px-2 py-1'}"
|
|
>
|
|
{#if !compact}
|
|
<span
|
|
class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
|
|
>{path}</span
|
|
>
|
|
{/if}
|
|
<div class="flex items-center gap-3 max-w-full">
|
|
<span
|
|
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} truncate {emptyString(
|
|
summary
|
|
)
|
|
? 'text-tertiary italic font-normal'
|
|
: 'text-emphasis'}"
|
|
>
|
|
{emptyString(summary) ? 'Add a summary...' : summary}
|
|
</span>
|
|
{#if labels?.length}
|
|
<div class="flex items-center gap-0.5">
|
|
{#each labels as label}
|
|
<Badge color="blue" verySmall class="px-1" title="Label: {label}">{label}</Badge>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
<InheritedLabels labels={inheritedLabels} />
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
{#snippet content({ close })}
|
|
<div class="flex flex-col gap-6 w-[480px]">
|
|
{#if onSaved}
|
|
<Label label="Summary">
|
|
<TextInput
|
|
bind:this={summaryInput}
|
|
inputProps={{
|
|
type: 'text',
|
|
placeholder: 'Short summary',
|
|
onkeydown: (e) => {
|
|
if (e.key === 'Enter') {
|
|
save(close)
|
|
}
|
|
}
|
|
}}
|
|
bind:value={editSummary}
|
|
/>
|
|
</Label>
|
|
<div class="-mt-4 flex items-center gap-2">
|
|
<LabelsInput
|
|
bind:labels
|
|
onchange={() => {
|
|
labelsDirty = true
|
|
}}
|
|
/>
|
|
{#if inheritedLabels?.length}
|
|
<InheritedLabels labels={inheritedLabels} />
|
|
{/if}
|
|
</div>
|
|
{#if inheritedLabels?.length}
|
|
<p class="-mt-5 text-2xs text-tertiary">
|
|
Gray labels are inherited from the folder and can only be edited there.
|
|
</p>
|
|
{/if}
|
|
<Label label="Path">
|
|
{#if own}
|
|
<Path
|
|
autofocus={false}
|
|
bind:path={editPath}
|
|
bind:dirty={dirtyPath}
|
|
initialPath={path ?? ''}
|
|
namePlaceholder={kind}
|
|
{kind}
|
|
size="sm"
|
|
drawerOffset={4000}
|
|
/>
|
|
{:else}
|
|
<span class="text-xs font-mono text-secondary">{path}</span>
|
|
<p class="text-2xs text-tertiary mt-1">Only the owner can change the path</p>
|
|
{/if}
|
|
</Label>
|
|
{#if onBehalfOfEmail}
|
|
<Alert type="info" title="Run on behalf of" size="xs">
|
|
This flow will be redeployed on behalf of you ({$userStore?.email}) instead of {onBehalfOfEmail}
|
|
</Alert>
|
|
{/if}
|
|
<Button
|
|
size="xs"
|
|
variant="accent"
|
|
disabled={!hasChanges}
|
|
title="Save summary and path"
|
|
onclick={() => save(close)}
|
|
>
|
|
Save
|
|
</Button>
|
|
{:else}
|
|
<label class="block text-primary">
|
|
<div class="pb-1 text-xs font-semibold text-emphasis">Summary</div>
|
|
<TextInput
|
|
bind:this={summaryInput}
|
|
inputProps={{
|
|
type: 'text',
|
|
placeholder: 'Short summary',
|
|
onkeydown: (e) => {
|
|
if (e.key === 'Enter') {
|
|
close()
|
|
}
|
|
}
|
|
}}
|
|
bind:value={summary}
|
|
/>
|
|
</label>
|
|
<div class="block text-primary">
|
|
<div class="pb-1 text-xs font-semibold text-emphasis">Path</div>
|
|
<Path
|
|
autofocus={false}
|
|
bind:path
|
|
bind:dirty={dirtyPath}
|
|
initialPath={path ?? ''}
|
|
namePlaceholder={kind}
|
|
{kind}
|
|
size="sm"
|
|
drawerOffset={4000}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
</Popover>
|
|
{:else}
|
|
<div class="min-w-0 truncate flex items-center {compact ? '' : 'flex-col px-2'}">
|
|
{#if !emptyString(summary) && !compact}
|
|
<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"
|
|
>
|
|
{emptyString(summary) ? (path ?? '') : summary}
|
|
</span>
|
|
</div>
|
|
{/if}
|