mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 16:02:11 +00:00
feat(pipeline): 2-col picker, draft path edit, save-all + leave guard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -163,7 +163,8 @@
|
||||
pathPrefix,
|
||||
defaultPathSuffix,
|
||||
producers: producersByAsset.get(`${a.kind}:${a.path}`) ?? [],
|
||||
onRunProducer
|
||||
onRunProducer,
|
||||
onSelectAsset: () => onselect?.({ kind: 'asset', asset_kind: a.kind, path: a.path })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,8 +15,11 @@
|
||||
Loader2,
|
||||
Save,
|
||||
Trash2,
|
||||
X
|
||||
X,
|
||||
Pencil
|
||||
} from 'lucide-svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { tick } from 'svelte'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { emptySchema, sendUserToast } from '$lib/utils'
|
||||
import type { AssetGraphSelection } from './types'
|
||||
@@ -73,6 +76,15 @@
|
||||
// Job id of the most recently dispatched run. Forwarded to the
|
||||
// runs panel so that clicking play auto-selects the new run.
|
||||
runsPendingJobId?: string | undefined
|
||||
// Folder-scoped non-editable prefix shown next to the suffix
|
||||
// editor when the user renames a draft (e.g. `f/<folder>/`). The
|
||||
// new path = pathPrefix + suffix.
|
||||
pathPrefix?: string
|
||||
// Called when the user renames a draft — the parent reseats the
|
||||
// path key in its drafts map and updates activeDraftPath. Returns
|
||||
// true on success; false on collision/validation failure so the
|
||||
// popover can keep itself open and surface the error inline.
|
||||
onDraftPathChange?: (oldPath: string, newPath: string) => boolean | string
|
||||
}
|
||||
let {
|
||||
selection,
|
||||
@@ -86,7 +98,9 @@
|
||||
onScriptRemoved,
|
||||
selectionProducers = [],
|
||||
runsRefreshKey,
|
||||
runsPendingJobId
|
||||
runsPendingJobId,
|
||||
pathPrefix = '',
|
||||
onDraftPathChange
|
||||
}: Props = $props()
|
||||
|
||||
// Bumped when the runs panel reports a watched job has reached a
|
||||
@@ -227,6 +241,49 @@
|
||||
let isScriptView = $derived(
|
||||
isDraft || (selection?.kind === 'runnable' && selection.runnable_kind === 'script')
|
||||
)
|
||||
|
||||
// Suffix editor for the draft-path popover. Seeded from the current
|
||||
// path each time the popover opens so the user starts with what they
|
||||
// see, not stale state from an earlier rename.
|
||||
let draftPathSuffix = $state('')
|
||||
let draftPathError = $state<string | undefined>(undefined)
|
||||
let draftPathInput: HTMLInputElement | undefined = $state(undefined)
|
||||
|
||||
function suffixOf(fullPath: string): string {
|
||||
return fullPath.startsWith(pathPrefix) ? fullPath.slice(pathPrefix.length) : fullPath
|
||||
}
|
||||
|
||||
async function openDraftPathEditor() {
|
||||
draftPathSuffix = script ? suffixOf(script.path) : ''
|
||||
draftPathError = undefined
|
||||
await tick()
|
||||
draftPathInput?.focus()
|
||||
draftPathInput?.select()
|
||||
}
|
||||
|
||||
function confirmDraftPath(close: () => void) {
|
||||
if (!script) return
|
||||
const suffix = draftPathSuffix.trim()
|
||||
if (!suffix) {
|
||||
draftPathError = 'Path cannot be empty'
|
||||
return
|
||||
}
|
||||
const newPath = pathPrefix + suffix
|
||||
if (newPath === script.path) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
const result = onDraftPathChange?.(script.path, newPath)
|
||||
if (result === true || result === undefined) {
|
||||
script.path = newPath
|
||||
draftPathError = undefined
|
||||
close()
|
||||
} else if (typeof result === 'string') {
|
||||
draftPathError = result
|
||||
} else {
|
||||
draftPathError = 'Path already in use'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full bg-surface">
|
||||
@@ -236,12 +293,87 @@
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
{#if isDraft && script}
|
||||
<Code2 size={16} class="shrink-0 text-emerald-700 dark:text-emerald-400" />
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-3xs uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
Draft pipeline script
|
||||
</span>
|
||||
<span class="text-xs font-mono truncate" title={script.path}>{script.path}</span>
|
||||
</div>
|
||||
{#if onDraftPathChange}
|
||||
<!-- Inline rename popover for drafts. The persisted-script
|
||||
branch uses SummaryPathDisplay which round-trips through
|
||||
updateItemPathAndSummary; drafts have no server row yet,
|
||||
so we just rekey the parent's drafts map locally. -->
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
contentClasses="p-3"
|
||||
usePointerDownOutside
|
||||
on:openChange={(e) => {
|
||||
if (e.detail) openDraftPathEditor()
|
||||
}}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col min-w-0 text-left px-2 py-1 rounded-md hover:bg-surface-hover transition-colors group"
|
||||
title="Edit draft path"
|
||||
>
|
||||
<span
|
||||
class="text-3xs uppercase tracking-wide text-emerald-600 dark:text-emerald-400 flex items-center gap-1"
|
||||
>
|
||||
Draft pipeline script
|
||||
<Pencil size={9} class="opacity-0 group-hover:opacity-60 transition-opacity" />
|
||||
</span>
|
||||
<span class="text-xs font-mono truncate" title={script.path}>{script.path}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<div class="flex flex-col gap-2 w-[420px]">
|
||||
<span class="text-2xs font-normal text-secondary">Path</span>
|
||||
<div
|
||||
class="flex items-stretch border rounded-md bg-surface overflow-hidden focus-within:ring-2 focus-within:ring-emerald-400"
|
||||
>
|
||||
{#if pathPrefix}
|
||||
<span
|
||||
class="flex items-center px-2 bg-surface-secondary text-tertiary text-sm font-mono border-r select-none"
|
||||
>
|
||||
{pathPrefix}
|
||||
</span>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={draftPathInput}
|
||||
bind:value={draftPathSuffix}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmDraftPath(close)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
}
|
||||
}}
|
||||
class="flex-1 min-w-0 px-2 py-1.5 text-sm font-mono bg-transparent focus:outline-none"
|
||||
placeholder="my_script"
|
||||
/>
|
||||
</div>
|
||||
{#if draftPathError}
|
||||
<span class="text-2xs text-red-500">{draftPathError}</span>
|
||||
{/if}
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!draftPathSuffix.trim()}
|
||||
onClick={() => confirmDraftPath(close)}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-3xs uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
Draft pipeline script
|
||||
</span>
|
||||
<span class="text-xs font-mono truncate" title={script.path}>{script.path}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if selection?.kind === 'asset'}
|
||||
<AssetGenericIcon
|
||||
assetKind={selection.asset_kind}
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
// cached draft content). Without this callback, the play button
|
||||
// is hidden — runs only make sense in editor contexts.
|
||||
onRunProducer?: (producer: AssetProducer) => Promise<string | undefined>
|
||||
// Forwarded from the canvas. Called when the user runs producers
|
||||
// from this node so the page can auto-select the asset and open
|
||||
// the runs panel — matches what clicking the node would do.
|
||||
onSelectAsset?: () => void
|
||||
}
|
||||
// SvelteFlow injects this on the node component when the user clicks
|
||||
// the node. Combined with our own `hovered` state to drive the
|
||||
@@ -70,6 +74,11 @@
|
||||
e.stopPropagation()
|
||||
if (!$workspaceStore || running || !data.onRunProducer) return
|
||||
if (scriptProducers.length === 0) return
|
||||
// Select the asset so the runs panel opens (or refocuses) on this
|
||||
// node — without this, dispatching a run silently goes off into the
|
||||
// void with no UI feedback when the panel was closed or pointed at
|
||||
// a different node.
|
||||
if (!selected) data.onSelectAsset?.()
|
||||
running = true
|
||||
const handler = data.onRunProducer
|
||||
try {
|
||||
|
||||
@@ -47,10 +47,10 @@
|
||||
// Default suffix seeded into the editable input when the user
|
||||
// reaches the path stage (e.g. `new_pipeline_script`).
|
||||
defaultPathSuffix?: string
|
||||
// When true, after the user picks a language we add an output-kind
|
||||
// stage between language and path. The picked kind is forwarded in
|
||||
// onPick(pick.outputKind). When false (or omitted), the menu jumps
|
||||
// directly from language → path, matching the legacy two-stage flow.
|
||||
// When true, the language stage shows a second sub-column with
|
||||
// compatible output-asset kinds; the user picks both side-by-side
|
||||
// and the chosen kind is forwarded as onPick(pick.outputKind).
|
||||
// When false (or omitted), language click goes straight to path.
|
||||
pickOutputKind?: boolean
|
||||
onPick: (pick: PipelineInsertPick) => void
|
||||
trigger: import('svelte').Snippet
|
||||
@@ -68,20 +68,42 @@
|
||||
placement = 'bottom'
|
||||
}: Props = $props()
|
||||
|
||||
// Flow stages: kind → lang → (output) → path → confirm. `stage` drives
|
||||
// Flow stages: kind → lang(+output) → path → confirm. `stage` drives
|
||||
// the right column. Only the `language` kinds reach the lang/path
|
||||
// stages. The `output` stage is gated behind `pickOutputKind` so menus
|
||||
// that don't need it (legacy two-column callers) keep their old flow.
|
||||
// stages. When `pickOutputKind` is true the lang stage renders two
|
||||
// sub-columns (languages | compatible output kinds) so picking happens
|
||||
// in one step instead of two sequential screens.
|
||||
// Default the lang column to duckdb when present — most pipeline
|
||||
// authors want SQL-on-data, and preselecting populates the output
|
||||
// column immediately so the user sees what's available without an
|
||||
// extra click.
|
||||
function defaultLangIdx(): number {
|
||||
const i = languages.findIndex((l) => l.lang === 'duckdb')
|
||||
return i >= 0 ? i : 0
|
||||
}
|
||||
|
||||
let selectedKindId = $state<string>(kinds[0]?.id ?? '')
|
||||
let selectedKind = $derived(kinds.find((k) => k.id === selectedKindId) ?? kinds[0])
|
||||
let stage = $state<'lang' | 'output' | 'path' | 'description'>(
|
||||
let stage = $state<'lang' | 'path' | 'description'>(
|
||||
kinds[0]?.pickLanguage ? 'lang' : 'description'
|
||||
)
|
||||
let selectedLanguage = $state<SupportedLanguage | undefined>(undefined)
|
||||
let selectedLanguage = $state<SupportedLanguage | undefined>(languages[defaultLangIdx()]?.lang)
|
||||
let selectedOutputKind = $state<PipelineOutputKind | undefined>(undefined)
|
||||
let pathSuffix = $state('')
|
||||
let pathInput: HTMLInputElement | undefined = $state(undefined)
|
||||
|
||||
// Keyboard navigation state. `focusCol` tracks which column the arrow
|
||||
// keys steer; per-column indexes survive column switches so the user's
|
||||
// position is preserved as they move left/right.
|
||||
let focusCol = $state<'kind' | 'lang' | 'output'>('lang')
|
||||
let kindIdx = $state(0)
|
||||
let langIdx = $state(defaultLangIdx())
|
||||
let outputIdx = $state(0)
|
||||
// Tracks popover open state so the window-level keydown handler can
|
||||
// no-op when the menu is closed (the snippet content is reused across
|
||||
// opens, so we can't rely on mount/unmount).
|
||||
let isOpen = $state(false)
|
||||
|
||||
// Output kinds that have a real template for the picked language. We
|
||||
// hide non-compatible kinds entirely rather than greying them — keeps
|
||||
// the picker scannable and the user never lands on a kind that would
|
||||
@@ -91,6 +113,10 @@
|
||||
return compatibleOutputKinds(selectedLanguage as ScriptLang)
|
||||
})
|
||||
|
||||
let visibleOutputKinds = $derived(
|
||||
PIPELINE_OUTPUT_KINDS.filter((k) => compatibleKinds.includes(k.id))
|
||||
)
|
||||
|
||||
// Popover closing doesn't unmount its content, so state persists across
|
||||
// opens. Reset everything back to initial state on close so the next
|
||||
// open starts fresh — otherwise reopening lands on the previous path
|
||||
@@ -99,9 +125,13 @@
|
||||
function resetMenuState() {
|
||||
selectedKindId = kinds[0]?.id ?? ''
|
||||
stage = kinds[0]?.pickLanguage ? 'lang' : 'description'
|
||||
selectedLanguage = undefined
|
||||
selectedLanguage = languages[defaultLangIdx()]?.lang
|
||||
selectedOutputKind = undefined
|
||||
pathSuffix = ''
|
||||
focusCol = 'lang'
|
||||
kindIdx = 0
|
||||
langIdx = defaultLangIdx()
|
||||
outputIdx = 0
|
||||
}
|
||||
|
||||
function handleKindClick(k: PipelineInsertKind, close: () => void) {
|
||||
@@ -112,9 +142,14 @@
|
||||
}
|
||||
selectedKindId = k.id
|
||||
stage = 'lang'
|
||||
selectedLanguage = undefined
|
||||
// Re-seed the language with the duckdb default rather than blanking
|
||||
// it — keeping the output column populated avoids a flash of empty
|
||||
// state when the user explores different trigger kinds.
|
||||
selectedLanguage = languages[defaultLangIdx()]?.lang
|
||||
selectedOutputKind = undefined
|
||||
pathSuffix = ''
|
||||
langIdx = defaultLangIdx()
|
||||
outputIdx = 0
|
||||
}
|
||||
|
||||
// Short random slug appended to the default suffix so that opening the
|
||||
@@ -135,15 +170,21 @@
|
||||
}
|
||||
|
||||
async function handleLanguageClick(lang: SupportedLanguage) {
|
||||
selectedLanguage = lang
|
||||
// If this menu wants an output-kind stage, route through it; the
|
||||
// path suffix only gets seeded once the user has confirmed both
|
||||
// language and output kind.
|
||||
// Output kinds are language-dependent, so changing the language
|
||||
// invalidates any previously picked kind. We stay on the lang
|
||||
// stage and let the user click an output kind in the right
|
||||
// sub-column to advance.
|
||||
const idx = languages.findIndex((l) => l.lang === lang)
|
||||
if (idx >= 0) langIdx = idx
|
||||
if (pickOutputKind) {
|
||||
selectedOutputKind = undefined
|
||||
stage = 'output'
|
||||
if (selectedLanguage !== lang) {
|
||||
selectedOutputKind = undefined
|
||||
outputIdx = 0
|
||||
}
|
||||
selectedLanguage = lang
|
||||
return
|
||||
}
|
||||
selectedLanguage = lang
|
||||
const base = defaultPathSuffix || 'pipeline_script'
|
||||
pathSuffix = `${base}_${shortSlug()}`
|
||||
stage = 'path'
|
||||
@@ -151,6 +192,8 @@
|
||||
}
|
||||
|
||||
async function handleOutputKindClick(kind: PipelineOutputKind) {
|
||||
const idx = visibleOutputKinds.findIndex((k) => k.id === kind)
|
||||
if (idx >= 0) outputIdx = idx
|
||||
selectedOutputKind = kind
|
||||
const base = defaultPathSuffix || 'pipeline_script'
|
||||
pathSuffix = `${base}_${shortSlug()}`
|
||||
@@ -158,6 +201,103 @@
|
||||
await focusPathInput()
|
||||
}
|
||||
|
||||
function clampIdx(idx: number, len: number): number {
|
||||
if (len <= 0) return 0
|
||||
if (idx < 0) return 0
|
||||
if (idx >= len) return len - 1
|
||||
return idx
|
||||
}
|
||||
|
||||
// Available column ids for keyboard nav, matching the actual rendered
|
||||
// columns: kind is hidden when there's only one option, output is only
|
||||
// rendered when pickOutputKind is true.
|
||||
function navColumns(): Array<'kind' | 'lang' | 'output'> {
|
||||
const cols: Array<'kind' | 'lang' | 'output'> = ['lang']
|
||||
if (kinds.length > 1) cols.unshift('kind')
|
||||
if (pickOutputKind) cols.push('output')
|
||||
return cols
|
||||
}
|
||||
|
||||
function handleArrowKey(e: KeyboardEvent) {
|
||||
// Only the lang stage uses arrow nav — the path stage has its own
|
||||
// input-focused handler. Window-level keydown also fires while
|
||||
// typing in the path input, so we bail when the popover is closed
|
||||
// or focus is inside an editable element.
|
||||
if (!isOpen || stage !== 'lang') return
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
||||
const cols = navColumns()
|
||||
// If somehow the focused column is no longer rendered (e.g. user
|
||||
// switched to a single-kind menu), snap back to lang before acting.
|
||||
if (!cols.includes(focusCol)) focusCol = 'lang'
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const dir = e.key === 'ArrowDown' ? 1 : -1
|
||||
if (focusCol === 'kind') {
|
||||
kindIdx = clampIdx(kindIdx + dir, kinds.length)
|
||||
} else if (focusCol === 'lang') {
|
||||
langIdx = clampIdx(langIdx + dir, languages.length)
|
||||
const lang = languages[langIdx]?.lang
|
||||
if (lang && pickOutputKind) {
|
||||
if (selectedLanguage !== lang) {
|
||||
selectedOutputKind = undefined
|
||||
outputIdx = 0
|
||||
}
|
||||
selectedLanguage = lang
|
||||
}
|
||||
} else if (focusCol === 'output') {
|
||||
outputIdx = clampIdx(outputIdx + dir, visibleOutputKinds.length)
|
||||
}
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
const i = cols.indexOf(focusCol)
|
||||
const next = e.key === 'ArrowRight' ? i + 1 : i - 1
|
||||
if (next < 0 || next >= cols.length) return
|
||||
const target = cols[next]
|
||||
// Output column needs a selected language to render anything;
|
||||
// preselect the focused lang if missing so right-arrow always
|
||||
// produces a useful destination.
|
||||
if (target === 'output' && !selectedLanguage) {
|
||||
const lang = languages[langIdx]?.lang
|
||||
if (!lang) return
|
||||
selectedLanguage = lang
|
||||
outputIdx = 0
|
||||
}
|
||||
focusCol = target
|
||||
if (target === 'output') outputIdx = clampIdx(outputIdx, visibleOutputKinds.length)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (focusCol === 'kind') {
|
||||
const k = kinds[kindIdx]
|
||||
// Only the in-place transition (pickLanguage=true) is
|
||||
// reachable from keyboard — the close-and-emit path needs
|
||||
// the popover's `close` handle which isn't available here.
|
||||
if (k?.pickLanguage) {
|
||||
selectedKindId = k.id
|
||||
selectedLanguage = languages[defaultLangIdx()]?.lang
|
||||
selectedOutputKind = undefined
|
||||
langIdx = defaultLangIdx()
|
||||
outputIdx = 0
|
||||
focusCol = 'lang'
|
||||
}
|
||||
} else if (focusCol === 'lang') {
|
||||
const l = languages[langIdx]
|
||||
if (l) {
|
||||
if (pickOutputKind) {
|
||||
focusCol = 'output'
|
||||
outputIdx = clampIdx(outputIdx, visibleOutputKinds.length)
|
||||
} else {
|
||||
handleLanguageClick(l.lang)
|
||||
}
|
||||
}
|
||||
} else if (focusCol === 'output') {
|
||||
const k = visibleOutputKinds[outputIdx]
|
||||
if (k) handleOutputKindClick(k.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function confirmPath(close: () => void) {
|
||||
const suffix = pathSuffix.trim()
|
||||
if (!suffix || !selectedLanguage) return
|
||||
@@ -177,11 +317,13 @@
|
||||
confirmPath(close)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
stage = pickOutputKind ? 'output' : 'lang'
|
||||
stage = 'lang'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleArrowKey} />
|
||||
|
||||
<Popover
|
||||
contentClasses="p-0 bg-surface overflow-hidden"
|
||||
class="inline-block"
|
||||
@@ -196,6 +338,7 @@
|
||||
overlap: false
|
||||
}}
|
||||
on:openChange={(e) => {
|
||||
isOpen = !!e.detail
|
||||
if (!e.detail) resetMenuState()
|
||||
}}
|
||||
>
|
||||
@@ -222,16 +365,22 @@
|
||||
option — single-kind menus jump straight to language/path. -->
|
||||
{#if !singleKind}
|
||||
<div class="flex flex-col gap-1 p-2 w-52 shrink-0 overflow-auto">
|
||||
{#each kinds as k}
|
||||
{#each kinds as k, i}
|
||||
{@const isSelected = selectedKindId === k.id}
|
||||
{@const isFocused = focusCol === 'kind' && kindIdx === i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleKindClick(k, close)}
|
||||
onclick={() => {
|
||||
kindIdx = i
|
||||
focusCol = 'kind'
|
||||
handleKindClick(k, close)
|
||||
}}
|
||||
class={[
|
||||
'flex items-start gap-2 px-2 py-2 rounded-md text-left transition-colors',
|
||||
isSelected
|
||||
? 'bg-surface-selected text-emphasis'
|
||||
: 'hover:bg-surface-hover text-primary'
|
||||
: 'hover:bg-surface-hover text-primary',
|
||||
isFocused ? 'ring-1 ring-emerald-400' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{#if k.icon}
|
||||
@@ -257,51 +406,71 @@
|
||||
<!-- Right column: stage-driven -->
|
||||
<div class="flex flex-col grow min-w-0 overflow-hidden">
|
||||
{#if stage === 'lang'}
|
||||
<div class="flex flex-col gap-1 p-2 grow overflow-auto">
|
||||
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Language</div>
|
||||
{#each languages as l}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
btnClasses="justify-start"
|
||||
onClick={() => handleLanguageClick(l.lang)}
|
||||
>
|
||||
<LanguageIcon lang={l.lang} width={14} height={14} />
|
||||
<span class="grow truncate text-left text-sm">{l.label}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if stage === 'output' && selectedLanguage}
|
||||
<div class="flex flex-col gap-1 p-2 grow overflow-auto">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
iconOnly
|
||||
title="Back to language"
|
||||
onClick={() => (stage = 'lang')}
|
||||
/>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<LanguageIcon lang={selectedLanguage} width={13} height={13} />
|
||||
<span class="text-xs font-medium">{selectedLanguage}</span>
|
||||
</div>
|
||||
<!-- Two sub-columns when output-kind picking is enabled:
|
||||
languages on the left, compatible output kinds on the
|
||||
right. Selecting a language updates the output column
|
||||
in place; selecting an output kind advances to path. -->
|
||||
<div
|
||||
class="flex flex-row grow min-w-0 overflow-hidden {pickOutputKind ? 'divide-x' : ''}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-1 p-2 overflow-auto {pickOutputKind
|
||||
? 'w-44 shrink-0'
|
||||
: 'grow'}"
|
||||
>
|
||||
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Language</div>
|
||||
{#each languages as l, i}
|
||||
{@const isSelected = pickOutputKind && selectedLanguage === l.lang}
|
||||
{@const isFocused = focusCol === 'lang' && langIdx === i}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
btnClasses="justify-start {isSelected ? 'bg-surface-selected' : ''} {isFocused
|
||||
? 'ring-1 ring-emerald-400'
|
||||
: ''}"
|
||||
onClick={() => {
|
||||
focusCol = 'lang'
|
||||
handleLanguageClick(l.lang)
|
||||
}}
|
||||
>
|
||||
<LanguageIcon lang={l.lang} width={14} height={14} />
|
||||
<span class="grow truncate text-left text-sm">{l.label}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Output asset</div>
|
||||
{#each PIPELINE_OUTPUT_KINDS.filter((k) => compatibleKinds.includes(k.id)) as k}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleOutputKindClick(k.id)}
|
||||
class="flex flex-col items-start gap-0.5 px-2 py-2 rounded-md text-left transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<span class="text-sm font-medium leading-tight">{k.label}</span>
|
||||
<span class="text-2xs text-tertiary font-normal leading-snug">
|
||||
{k.description}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if compatibleKinds.length === 0}
|
||||
<span class="text-2xs text-tertiary px-2">No output presets for this language.</span>
|
||||
{#if pickOutputKind}
|
||||
<div class="flex flex-col gap-1 p-2 grow min-w-0 overflow-auto">
|
||||
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Output asset</div>
|
||||
{#if !selectedLanguage}
|
||||
<span class="text-2xs text-tertiary px-2 py-1">
|
||||
Pick a language to see its output presets.
|
||||
</span>
|
||||
{:else if visibleOutputKinds.length === 0}
|
||||
<span class="text-2xs text-tertiary px-2">
|
||||
No output presets for this language.
|
||||
</span>
|
||||
{:else}
|
||||
{#each visibleOutputKinds as k, i}
|
||||
{@const isFocused = focusCol === 'output' && outputIdx === i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
focusCol = 'output'
|
||||
handleOutputKindClick(k.id)
|
||||
}}
|
||||
class={[
|
||||
'flex flex-col items-start gap-0.5 px-2 py-2 rounded-md text-left transition-colors hover:bg-surface-hover',
|
||||
isFocused ? 'ring-1 ring-emerald-400' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
<span class="text-sm font-medium leading-tight">{k.label}</span>
|
||||
<span class="text-2xs text-tertiary font-normal leading-snug">
|
||||
{k.description}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if stage === 'path' && selectedLanguage}
|
||||
@@ -315,8 +484,8 @@
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
iconOnly
|
||||
title={pickOutputKind ? 'Back to output' : 'Back to language'}
|
||||
onClick={() => (stage = pickOutputKind ? 'output' : 'lang')}
|
||||
title="Back"
|
||||
onClick={() => (stage = 'lang')}
|
||||
/>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<LanguageIcon lang={selectedLanguage} width={13} height={13} />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
|
||||
// Pipelines are dataset-shaped, so the menu surfaces the languages users
|
||||
// actually reach for first: bun for ergonomic data wrangling, duckdb for
|
||||
// in-place SQL on parquet/s3, python for ML/pandas, then the sql dialects
|
||||
// for warehouse-resident transforms. Everything else (deno/bash/go) sits
|
||||
// below — still creatable, just not the default suggestion.
|
||||
// actually reach for first: duckdb for in-place SQL on parquet/s3 (the
|
||||
// default), bun for ergonomic data wrangling, python for ML/pandas, then
|
||||
// the sql dialects for warehouse-resident transforms. Everything else
|
||||
// (deno/bash/go) sits below — still creatable, just not the default
|
||||
// suggestion.
|
||||
export const PIPELINE_LANGUAGES: Array<{ label: string; lang: ScriptLang }> = [
|
||||
{ label: 'TypeScript (Bun)', lang: 'bun' },
|
||||
{ label: 'DuckDB', lang: 'duckdb' },
|
||||
{ label: 'TypeScript (Bun)', lang: 'bun' },
|
||||
{ label: 'Python', lang: 'python3' },
|
||||
{ label: 'PostgreSQL', lang: 'postgresql' },
|
||||
{ label: 'BigQuery', lang: 'bigquery' },
|
||||
|
||||
@@ -57,7 +57,7 @@ const LANG_COMPATIBILITY: Record<ScriptLang, PipelineOutputKind[]> = {
|
||||
bun: ['datatable', 'ducklake', 's3_parquet', 's3_object', 'none'],
|
||||
deno: ['datatable', 'ducklake', 's3_parquet', 's3_object', 'none'],
|
||||
python3: ['datatable', 'ducklake', 's3_parquet', 's3_object', 'none'],
|
||||
duckdb: ['datatable', 'ducklake', 's3_parquet', 'none'],
|
||||
duckdb: ['datatable', 'ducklake', 's3_parquet', 's3_object', 'none'],
|
||||
postgresql: ['datatable', 'none'],
|
||||
mysql: ['none'],
|
||||
mssql: ['none'],
|
||||
@@ -100,7 +100,8 @@ function randomSlug(len = 7): string {
|
||||
// downstream scripts in one session.
|
||||
export function autoOutputAsset(
|
||||
kind: PipelineOutputKind,
|
||||
folder: string
|
||||
folder: string,
|
||||
language?: ScriptLang
|
||||
): { kind: AssetKind; path: string } | undefined {
|
||||
const slug = randomSlug()
|
||||
switch (kind) {
|
||||
@@ -110,8 +111,14 @@ export function autoOutputAsset(
|
||||
return { kind: 'ducklake', path: `main/${folder}_${slug}` }
|
||||
case 's3_parquet':
|
||||
return { kind: 's3object', path: `pipelines/${folder}/out_${slug}.parquet` }
|
||||
case 's3_object':
|
||||
return { kind: 's3object', path: `pipelines/${folder}/out_${slug}.json` }
|
||||
case 's3_object': {
|
||||
// duckdb's natural output for a generic blob is CSV (one COPY TO
|
||||
// statement). TS/Python templates serialize JSON, so default to
|
||||
// .json for those — keeps the body and the file extension in
|
||||
// sync without the user having to rename anything.
|
||||
const ext = language === 'duckdb' ? 'csv' : 'json'
|
||||
return { kind: 's3object', path: `pipelines/${folder}/out_${slug}.${ext}` }
|
||||
}
|
||||
case 'none':
|
||||
return undefined
|
||||
}
|
||||
@@ -188,7 +195,12 @@ function header(language: ScriptLang, triggers: DraftTriggerSource[]): string {
|
||||
return `${p} on ${t.kind} ${t.path ?? '<trigger-path>'}`
|
||||
}
|
||||
})
|
||||
return [`${p} pipeline`, ...lines, ''].join('\n')
|
||||
// Discoverability hint — the three annotations users most often miss
|
||||
// when authoring their first pipeline script. Single line, real
|
||||
// example values (not placeholders) so users see the syntax. Docs
|
||||
// link is the canonical reference once they want the details.
|
||||
const more = `${p} More: partitioned daily, freshness 1h, retry 3, tag heavy — https://www.windmill.dev/docs/pipelines/annotations`
|
||||
return [`${p} pipeline`, ...lines, more, ''].join('\n')
|
||||
}
|
||||
|
||||
// Bun / Deno bodies. These share the wmill SDK surface, so we treat them
|
||||
@@ -393,6 +405,17 @@ function bodyDuckdb(ctx: TemplateContext): string {
|
||||
)
|
||||
}
|
||||
break
|
||||
case 's3_object':
|
||||
if (output) {
|
||||
const fromExpr = inSql ?? '(SELECT 1 AS placeholder)'
|
||||
lines.push(
|
||||
`COPY (`,
|
||||
` SELECT *`,
|
||||
` FROM ${fromExpr}`,
|
||||
`) TO 's3://${output.path}' (FORMAT 'csv', HEADER);`
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'ducklake':
|
||||
if (output) {
|
||||
const tableName = output.path.split('/').slice(1).join('_') || 'table_name'
|
||||
|
||||
@@ -24,19 +24,31 @@
|
||||
import { decodeState, encodeState } from '$lib/utils'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderSearch,
|
||||
Loader2,
|
||||
NetworkIcon,
|
||||
RefreshCw
|
||||
RefreshCw,
|
||||
Save
|
||||
} from 'lucide-svelte'
|
||||
import { JobService, OpenAPI, type AssetKind, type Script, type ScriptLang } from '$lib/gen'
|
||||
import {
|
||||
JobService,
|
||||
OpenAPI,
|
||||
ScriptService,
|
||||
type AssetKind,
|
||||
type Script,
|
||||
type ScriptLang
|
||||
} from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { goto } from '$app/navigation'
|
||||
import { emptySchema, sendUserToast } from '$lib/utils'
|
||||
import { beforeNavigate, goto } from '$app/navigation'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
|
||||
// Variables and resources are declarative config, not pipeline assets —
|
||||
// they're hub-shaped (referenced by most runnables) and would swamp the
|
||||
@@ -208,7 +220,7 @@
|
||||
outputKind: PipelineOutputKind,
|
||||
input?: { kind: AssetKind; path: string }
|
||||
) {
|
||||
const out = autoOutputAsset(outputKind, folder)
|
||||
const out = autoOutputAsset(outputKind, folder, language)
|
||||
const script = buildDraft(language, scriptPath, triggers, outputKind, out, input)
|
||||
// Write the new draft into the map (structural update so Svelte
|
||||
// re-derives graphWithDraft) and focus it in the details pane. When
|
||||
@@ -221,12 +233,225 @@
|
||||
selection = undefined
|
||||
}
|
||||
|
||||
// Navigation guard state. `pendingNavigationUrl` holds the URL the user
|
||||
// tried to leave to so we can complete the navigation after they pick
|
||||
// "Save all" or "Discard all"; `bypassNavigationGuard` is the standard
|
||||
// SvelteKit pattern for "this navigation was already approved, don't
|
||||
// re-prompt".
|
||||
let pendingNavigationUrl = $state<URL | undefined>(undefined)
|
||||
let leaveModalOpen = $state(false)
|
||||
let bypassNavigationGuard = $state(false)
|
||||
let leaveSaving = $state(false)
|
||||
|
||||
beforeNavigate((nav) => {
|
||||
if (bypassNavigationGuard) {
|
||||
bypassNavigationGuard = false
|
||||
return
|
||||
}
|
||||
if (drafts.size === 0) return
|
||||
// Same-folder URL changes (search params, hash) shouldn't re-prompt;
|
||||
// the guard is for actually leaving the editor.
|
||||
if (nav.to && nav.from && nav.to.url.pathname === nav.from.url.pathname) {
|
||||
return
|
||||
}
|
||||
nav.cancel()
|
||||
pendingNavigationUrl = nav.to?.url
|
||||
leaveModalOpen = true
|
||||
})
|
||||
|
||||
// Native browser tab close / reload — we can't show a custom modal
|
||||
// here, only the browser's generic confirm. Setting returnValue to a
|
||||
// non-empty string is the cross-browser idiom that triggers the
|
||||
// "Leave site?" prompt. The text shown is browser-controlled.
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (drafts.size === 0) return
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
})
|
||||
|
||||
function leaveModalCancel() {
|
||||
leaveModalOpen = false
|
||||
pendingNavigationUrl = undefined
|
||||
}
|
||||
|
||||
function leaveModalDiscard() {
|
||||
// Wipe every draft and the active selection so saved drafts and
|
||||
// stale active path don't bleed into the next page. localStorage
|
||||
// is overwritten by the persist effect on the next tick.
|
||||
drafts = new Map()
|
||||
activeDraftPath = undefined
|
||||
saveErrors = new Map()
|
||||
const target = pendingNavigationUrl
|
||||
leaveModalOpen = false
|
||||
pendingNavigationUrl = undefined
|
||||
if (target) {
|
||||
bypassNavigationGuard = true
|
||||
goto(target)
|
||||
}
|
||||
}
|
||||
|
||||
async function leaveModalSaveAll() {
|
||||
leaveSaving = true
|
||||
await saveAllDrafts()
|
||||
leaveSaving = false
|
||||
// If anything failed, keep the modal closed and the user on the
|
||||
// page so they can deal with the failures via the bar's error
|
||||
// popover. Otherwise resume the navigation that triggered the
|
||||
// guard.
|
||||
if (drafts.size === 0) {
|
||||
const target = pendingNavigationUrl
|
||||
leaveModalOpen = false
|
||||
pendingNavigationUrl = undefined
|
||||
if (target) {
|
||||
bypassNavigationGuard = true
|
||||
goto(target)
|
||||
}
|
||||
} else {
|
||||
leaveModalOpen = false
|
||||
pendingNavigationUrl = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk-save state. Errors are keyed by draft path so the error popover
|
||||
// can show one entry per failing draft alongside its message; successes
|
||||
// are removed from the drafts map as they land.
|
||||
let savingAll = $state(false)
|
||||
let saveErrors = $state<Map<string, string>>(new Map())
|
||||
|
||||
async function saveDraft(path: string, draft: Draft, ws: string): Promise<void> {
|
||||
const script = structuredClone($state.snapshot(draft.script) as Script)
|
||||
script.schema = script.schema ?? emptySchema()
|
||||
try {
|
||||
const result = await inferArgs(script.language, script.content, script.schema)
|
||||
;(script as any).auto_kind = result?.auto_kind || undefined
|
||||
script.has_preprocessor = result?.has_preprocessor || undefined
|
||||
} catch {
|
||||
// Inference failures don't block deploys (the same fallback the
|
||||
// per-pane save uses). The createScript call is the real
|
||||
// validation gate — if the body is broken it'll reject there.
|
||||
}
|
||||
await ScriptService.createScript({
|
||||
workspace: ws,
|
||||
requestBody: {
|
||||
...script,
|
||||
language: script.language,
|
||||
description: script.description ?? '',
|
||||
// Drafts have no parent — they're brand new at this path.
|
||||
parent_hash: undefined,
|
||||
is_template: false,
|
||||
tag: script.tag,
|
||||
kind: script.kind as Script['kind'] | undefined,
|
||||
lock: undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function saveAllDrafts() {
|
||||
if (!$workspaceStore || drafts.size === 0 || savingAll) return
|
||||
savingAll = true
|
||||
const ws = $workspaceStore
|
||||
const entries = [...drafts.entries()]
|
||||
const errors = new Map<string, string>()
|
||||
const savedPaths: string[] = []
|
||||
// Parallel — every createScript is independent. The backend handles
|
||||
// its own ordering for any cross-script lock writes; we just want
|
||||
// failures isolated per script so one bad body doesn't block the
|
||||
// other deploys.
|
||||
const results = await Promise.allSettled(
|
||||
entries.map(async ([path, d]) => {
|
||||
await saveDraft(path, d, ws)
|
||||
return path
|
||||
})
|
||||
)
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i]
|
||||
const [path] = entries[i]
|
||||
if (r.status === 'fulfilled') {
|
||||
savedPaths.push(path)
|
||||
} else {
|
||||
const e: any = r.reason
|
||||
errors.set(path, e?.body ?? e?.message ?? String(e))
|
||||
}
|
||||
}
|
||||
// Drop the saved drafts from the map; failed ones stay so the user
|
||||
// can fix them and retry. Build the new map from the still-failing
|
||||
// entries to keep insertion order stable.
|
||||
if (savedPaths.length > 0) {
|
||||
const next = new Map<string, Draft>()
|
||||
for (const [k, v] of drafts) {
|
||||
if (!savedPaths.includes(k)) next.set(k, v)
|
||||
}
|
||||
drafts = next
|
||||
if (activeDraftPath && savedPaths.includes(activeDraftPath)) {
|
||||
activeDraftPath = undefined
|
||||
}
|
||||
await graphRes.refetch()
|
||||
}
|
||||
saveErrors = errors
|
||||
savingAll = false
|
||||
if (savedPaths.length > 0 && errors.size === 0) {
|
||||
sendUserToast(`Saved ${savedPaths.length} draft${savedPaths.length === 1 ? '' : 's'}`)
|
||||
} else if (savedPaths.length > 0 && errors.size > 0) {
|
||||
sendUserToast(`Saved ${savedPaths.length}, ${errors.size} failed — see details`, true)
|
||||
} else if (errors.size > 0) {
|
||||
sendUserToast(`${errors.size} draft${errors.size === 1 ? '' : 's'} failed to save`, true)
|
||||
}
|
||||
}
|
||||
|
||||
function discardDraft(path: string) {
|
||||
if (!drafts.has(path)) return
|
||||
const next = new Map(drafts)
|
||||
next.delete(path)
|
||||
drafts = next
|
||||
if (activeDraftPath === path) activeDraftPath = undefined
|
||||
clearSaveError(path)
|
||||
}
|
||||
|
||||
function clearSaveError(path: string) {
|
||||
if (!saveErrors.has(path)) return
|
||||
const next = new Map(saveErrors)
|
||||
next.delete(path)
|
||||
saveErrors = next
|
||||
}
|
||||
|
||||
// Rename a draft in place — re-key its entry in the drafts map and
|
||||
// repoint activeDraftPath. Returns false (or an error string) if the
|
||||
// new path collides with another draft so the dialog can keep itself
|
||||
// open and surface the conflict inline.
|
||||
function renameDraft(oldPath: string, newPath: string): boolean | string {
|
||||
if (oldPath === newPath) return true
|
||||
const draft = drafts.get(oldPath)
|
||||
if (!draft) return 'Draft not found'
|
||||
if (drafts.has(newPath)) return 'Another draft already uses this path'
|
||||
const next = new Map<string, Draft>()
|
||||
// Preserve insertion order: replace the entry at its original
|
||||
// position so the canvas / lists don't reshuffle on rename.
|
||||
for (const [k, v] of drafts) {
|
||||
if (k === oldPath) {
|
||||
const updatedScript = { ...v.script, path: newPath }
|
||||
next.set(newPath, { ...v, script: updatedScript })
|
||||
} else {
|
||||
next.set(k, v)
|
||||
}
|
||||
}
|
||||
drafts = next
|
||||
if (activeDraftPath === oldPath) activeDraftPath = newPath
|
||||
// Errors are keyed by path — re-key the entry so a previously
|
||||
// failed draft keeps its error visible against the new path
|
||||
// after rename.
|
||||
if (saveErrors.has(oldPath)) {
|
||||
const nextErrors = new Map(saveErrors)
|
||||
const msg = nextErrors.get(oldPath)!
|
||||
nextErrors.delete(oldPath)
|
||||
nextErrors.set(newPath, msg)
|
||||
saveErrors = nextErrors
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Currently-open draft shape (if any) — fed into the details pane.
|
||||
@@ -579,6 +804,52 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{#if saveErrors.size > 0}
|
||||
<!-- Compact errors popover anchored next to Save all so users
|
||||
can see exactly which drafts failed and why without losing
|
||||
the editor context. Drafts that succeed disappear from
|
||||
the map; the ones still listed here are the unresolved
|
||||
failures. -->
|
||||
<Popover placement="bottom-end" contentClasses="p-3 max-w-[480px]" usePointerDownOutside>
|
||||
{#snippet trigger()}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors text-xs font-medium"
|
||||
title="View save errors"
|
||||
>
|
||||
<AlertTriangle size={14} />
|
||||
<span>{saveErrors.size} failed</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">Save errors</span>
|
||||
<div class="flex flex-col gap-2 max-h-72 overflow-y-auto">
|
||||
{#each [...saveErrors.entries()] as [path, message]}
|
||||
<div class="flex flex-col gap-0.5 border-l-2 border-red-400 pl-2">
|
||||
<span class="text-2xs font-mono text-emphasis">{path}</span>
|
||||
<span class="text-2xs text-red-600 dark:text-red-400 break-words">
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if drafts.size > 0}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: savingAll ? Loader2 : Save }}
|
||||
onclick={saveAllDrafts}
|
||||
disabled={savingAll}
|
||||
title={savingAll ? 'Saving drafts…' : `Deploy all ${drafts.size} drafts`}
|
||||
>
|
||||
{savingAll ? 'Saving…' : `Save all (${drafts.size})`}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
@@ -684,6 +955,8 @@
|
||||
{runsRefreshKey}
|
||||
{runsPendingJobId}
|
||||
draftScript={activeDraft?.script}
|
||||
{pathPrefix}
|
||||
onDraftPathChange={renameDraft}
|
||||
workspace={$workspaceStore}
|
||||
onAnnotationsChange={(scriptPath, annotations) => {
|
||||
liveAnnotations = { scriptPath, annotations }
|
||||
@@ -740,4 +1013,85 @@
|
||||
</div>
|
||||
|
||||
<PipelinePickerModal bind:open={pickerModalOpen} currentFolder={folder} />
|
||||
|
||||
{#if leaveModalOpen}
|
||||
<!-- Three-button leave guard. Built inline rather than reusing
|
||||
ConfirmationModal because that one is binary (confirm/cancel) and
|
||||
we need a distinct "Save all" path that runs the same dispatch as
|
||||
the bar button. Layout mirrors the archive/delete modal in
|
||||
AssetGraphDetailsPane for consistency. -->
|
||||
<div
|
||||
transition:fade={{ duration: 100 }}
|
||||
class="fixed top-0 bottom-0 left-0 right-0 z-[9999]"
|
||||
role="dialog"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75"></div>
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
class="relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl sm:my-8 sm:w-full sm:max-w-lg sm:p-6"
|
||||
>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-800/50"
|
||||
>
|
||||
<AlertTriangle class="text-amber-500 dark:text-amber-400" />
|
||||
</div>
|
||||
<div class="ml-4 flex-1">
|
||||
<h3 class="text-lg font-medium text-primary">
|
||||
{drafts.size === 1 ? 'Unsaved draft' : `${drafts.size} unsaved drafts`}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-secondary flex flex-col gap-2">
|
||||
<p>
|
||||
You have {drafts.size === 1
|
||||
? 'a draft pipeline script'
|
||||
: `${drafts.size} draft pipeline scripts`} that {drafts.size === 1
|
||||
? 'has'
|
||||
: 'have'} not been deployed yet. What would you like to do?
|
||||
</p>
|
||||
<ul
|
||||
class="text-2xs font-mono pl-4 max-h-40 overflow-y-auto flex flex-col gap-0.5"
|
||||
>
|
||||
{#each [...drafts.keys()] as p}
|
||||
<li class="truncate text-tertiary">{p}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-row-reverse mt-4">
|
||||
<Button
|
||||
disabled={leaveSaving}
|
||||
onclick={leaveModalSaveAll}
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: leaveSaving ? Loader2 : Save }}
|
||||
>
|
||||
<span class="min-w-20">{leaveSaving ? 'Saving…' : `Save all (${drafts.size})`}</span
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={leaveSaving}
|
||||
onclick={leaveModalCancel}
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={leaveSaving}
|
||||
onclick={leaveModalDiscard}
|
||||
variant="contained"
|
||||
color="red"
|
||||
unifiedSize="sm"
|
||||
destructive
|
||||
>
|
||||
Discard all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user