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:
Ruben Fiszel
2026-05-01 22:44:46 +00:00
parent b613fa9d5d
commit be9c8c5d92
7 changed files with 780 additions and 91 deletions
@@ -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}