mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 16:02:57 +00:00
The pen beside a name is gone. The name itself is the trigger, with a hover fill behind it where the click opens an editor rather than going anywhere — the path segment that only copies keeps no fill, which is what tells the two apart. A bar with a summary field of its own (EditorHeader's condensed pane) still gets the pen, since it has no name to hang the trigger on. Clicking either half of a name opens the editor under that half with the cursor in the matching field. The band draws the path and the page draws the summary, so one popover cannot anchor under both: a page hands the band a `pathTrigger` snippet and the band renders its path segment inside it, passing the classes its own segment wears. Each editor supplies a second PathEditPopover for it, and `focusField` names the field to focus — `#path` explicitly, since melt otherwise lands on the row's first button. Those snippets render from the band's tree, which outlives the values they read by a flush, so each is gated on the value it reads. Without that an agent page whose draft had not loaded threw inside the band and left the breadcrumb empty. The trail now carries two weights: the segment that ends it is what the page is about and takes `font-medium text-emphasis`, everything before it is a step quieter. Which segment that is follows the route — the workspace on home (the fork alone inside a fork), a list page's own name, an item's summary. And it truncates instead of switching to icons at a breakpoint. The workspace part yields first, the family name before the fork, then the path's folders, and the item's own name last. The bar no longer wraps on a phone: a wrapping row never squeezes anything, it hands the trail a line of its own and leaves it whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
294 lines
9.2 KiB
Svelte
294 lines
9.2 KiB
Svelte
<script lang="ts">
|
|
import type { Snippet } from 'svelte'
|
|
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
|
|
/** What is being renamed. An agent is a resource, so the path field is scoped to one and
|
|
* the write goes through the agent branch of the rename manager. */
|
|
kind?: 'flow' | 'script' | 'agent'
|
|
/** Header variant: no path line (the host shows it) and a lighter summary. */
|
|
compact?: boolean
|
|
/** Rendered inside the trigger in place of the summary, for a host that hangs a second copy
|
|
* of this editor off something else it draws — the band's path segment. */
|
|
label?: Snippet
|
|
/** The trigger's own classes, for a host whose row has a look of its own. */
|
|
triggerClass?: string
|
|
/** Which field takes the cursor on open: the click says which half of the name the user came
|
|
* to change. `Path` is only reachable for an owner, so 'path' falls back to the summary. */
|
|
focusField?: 'summary' | 'path'
|
|
}
|
|
|
|
let {
|
|
summary = $bindable(''),
|
|
path = $bindable(''),
|
|
labels = $bindable(),
|
|
inheritedLabels = undefined,
|
|
editable = false,
|
|
onSaved,
|
|
kind = 'flow',
|
|
compact = false,
|
|
label,
|
|
triggerClass = 'block min-w-0 max-w-full px-1 py-0.5 rounded text-left cursor-pointer hover:bg-surface-hover transition-colors',
|
|
focusField = 'summary'
|
|
}: Props = $props()
|
|
|
|
// An agent lives at a resource path, so that is what the picker validates against; the
|
|
// placeholder still says "agent", which is what the reader is naming.
|
|
const pathKind = $derived(kind === 'agent' ? 'resource' : kind)
|
|
|
|
let editSummary = $state('')
|
|
let editPath = $state('')
|
|
let dirtyPath = $state(false)
|
|
let popoverOpen = $state(false)
|
|
// Resolved before the popover opens, not on opening it: the path field only exists for an owner,
|
|
// and `openFocus` looks for it in the same flush the popover renders in.
|
|
const own = $derived(isOwner(path ?? '', 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
|
|
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.charAt(0).toUpperCase()}${kind.slice(1)} updated`)
|
|
labelsDirty = false
|
|
close()
|
|
onSaved?.(newPath)
|
|
} catch (e: any) {
|
|
sendUserToast(`Could not update ${kind}: ${e.body ?? e.message}`, true)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#snippet editor()}
|
|
<!-- `#path` is the name field inside `Path`; without naming it melt lands on the row's first
|
|
button, so a popover opened from the path would not be typing into the path. -->
|
|
<Popover
|
|
class={triggerClass}
|
|
placement="bottom-start"
|
|
contentClasses="p-4"
|
|
triggerAttrs={{ title: 'Edit summary and path', 'aria-label': 'Edit summary and path' }}
|
|
usePointerDownOutside
|
|
excludeSelectors=".drawer"
|
|
disableFocusTrap
|
|
openFocus={focusField === 'path' && own
|
|
? '#path'
|
|
: () => {
|
|
summaryInput?.focus()
|
|
return null
|
|
}}
|
|
bind:isOpen={popoverOpen}
|
|
>
|
|
{#snippet trigger()}
|
|
<!-- Both of the popover's arms show the summary and the path, and ownership decides
|
|
whether the path can be changed, not whether it is there. -->
|
|
{#if label}
|
|
{@render label()}
|
|
{:else}
|
|
<span
|
|
class="{compact
|
|
? 'text-xs font-medium'
|
|
: 'text-sm font-semibold'} block truncate {emptyString(summary)
|
|
? 'text-tertiary italic font-normal'
|
|
: 'text-emphasis'}"
|
|
>
|
|
{emptyString(summary) ? 'Add a summary...' : summary}
|
|
</span>
|
|
{/if}
|
|
{/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={pathKind}
|
|
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={pathKind}
|
|
size="sm"
|
|
drawerOffset={4000}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
</Popover>
|
|
{/snippet}
|
|
|
|
{#if label && (editable || onSaved)}
|
|
<!-- The host draws the row this hangs in — the band's path segment — so only the trigger comes
|
|
from here: no path line above it, and no second copy of the labels. -->
|
|
{@render editor()}
|
|
{:else if editable || onSaved}
|
|
<!-- The name itself opens the popover, the same way the editors do it. Only the name: the whole
|
|
block was the trigger once, which meant a path or a label you only wanted to read answered
|
|
a click. -->
|
|
<div
|
|
class="min-w-0 truncate flex {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 min-w-0">
|
|
{@render editor()}
|
|
<!-- Outside the trigger: a label is read, not clicked through to a rename. -->
|
|
{#if labels?.length}
|
|
<div class="flex items-center gap-0.5">
|
|
{#each labels as labelText}
|
|
<Badge color="blue" verySmall class="px-1" title="Label: {labelText}">{labelText}</Badge
|
|
>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
<InheritedLabels labels={inheritedLabels} />
|
|
</div>
|
|
</div>
|
|
{: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}
|