This commit is contained in:
Ruben Fiszel
2026-04-27 12:37:45 +00:00
parent 29f75fcccf
commit a5183f40a3
10 changed files with 1802 additions and 0 deletions
@@ -0,0 +1,105 @@
<script lang="ts">
import { base } from '$lib/base'
import { goto } from '$app/navigation'
import Modal from '$lib/components/common/modal/Modal.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Select from '$lib/components/select/Select.svelte'
import type { ScriptLang } from '$lib/gen'
import { Code2 } from 'lucide-svelte'
interface Props {
open: boolean
folder: string
// When present, an `// on <asset>` trigger line is prefilled below the
// `// materialize` marker. Used by the per-asset + on an asset node so
// the new script is wired to refresh when that asset changes.
triggerAsset?: { prefix: string; path: string } | undefined
onOpenChange: (v: boolean) => void
}
let { open = $bindable(), folder, triggerAsset = undefined, onOpenChange }: Props = $props()
const LANGUAGES: Array<{ label: string; value: ScriptLang }> = [
{ label: 'TypeScript (Bun)', value: 'bun' },
{ label: 'TypeScript (Deno)', value: 'deno' },
{ label: 'Python', value: 'python3' },
{ label: 'PostgreSQL', value: 'postgresql' },
{ label: 'DuckDB', value: 'duckdb' },
{ label: 'BigQuery', value: 'bigquery' },
{ label: 'Snowflake', value: 'snowflake' },
{ label: 'MySQL', value: 'mysql' },
{ label: 'MS SQL', value: 'mssql' },
{ label: 'Bash', value: 'bash' },
{ label: 'Go', value: 'go' }
]
let scriptPath = $state('')
let language = $state<ScriptLang>('bun')
$effect(() => {
if (open) {
scriptPath = `f/${folder}/new_materializer`
language = 'bun'
}
})
let canConfirm = $derived(
scriptPath.trim().startsWith(`f/${folder}/`) && scriptPath.trim().length > `f/${folder}/`.length
)
function confirm() {
if (!canConfirm) return
const params = new URLSearchParams({
path: scriptPath.trim(),
lang: language,
materialize: '1'
})
if (triggerAsset) {
params.set('on_asset', `${triggerAsset.prefix}${triggerAsset.path}`)
}
onOpenChange(false)
goto(`${base}/scripts/add?${params}`)
}
</script>
<Modal bind:open title={triggerAsset ? 'Add script triggered by asset' : 'Add materializer script'}>
<div class="flex flex-col gap-4 w-full min-w-0">
<label class="flex flex-col gap-1">
<span class="text-xs font-medium text-secondary">Path</span>
<TextInput bind:value={scriptPath} placeholder="f/{folder}/new_materializer" />
<span class="text-2xs text-tertiary">
Must live in <code>f/{folder}/</code>.
</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-medium text-secondary">Language</span>
<Select items={LANGUAGES} bind:value={language} />
</label>
{#if triggerAsset}
<div class="rounded-md bg-surface-secondary border px-3 py-2 text-xs text-secondary">
The new script will include <code>// on {triggerAsset.prefix}{triggerAsset.path}</code> so it
refreshes when this asset changes.
</div>
{:else}
<div class="rounded-md bg-surface-secondary border px-3 py-2 text-xs text-secondary">
The new script will include a bare <code>// materialize</code> marker so it's counted as a pipeline
member. Any writes the parser detects become its outputs.
</div>
{/if}
</div>
{#snippet actions()}
<Button variant="subtle" unifiedSize="sm" onclick={() => onOpenChange(false)}>Cancel</Button>
<Button
variant="accent"
unifiedSize="sm"
disabled={!canConfirm}
onclick={confirm}
startIcon={{ icon: Code2 }}
>
Create script
</Button>
{/snippet}
</Modal>
@@ -0,0 +1,155 @@
<script lang="ts">
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
import {
Plus,
Clock,
Webhook,
Mail,
Zap,
Radio,
MessageSquare,
Database,
Send,
CloudCog
} from 'lucide-svelte'
import type { ScriptLang } from '$lib/gen'
import type { NativeTriggerKind } from './types'
// Each left-column kind is just "materializer triggered by <trigger
// source>". id === the SCRIPT_TRIGGER_KIND value, so the handler can
// dispatch on it uniformly. Asset-triggered materializers are not in
// this menu; those live under the per-asset + inside the graph.
type KindId = 'schedule' | NativeTriggerKind
interface Props {
data: {
onAddMaterializer: (
language: ScriptLang,
path: string,
source:
| { kind: 'schedule'; cron: string }
| { kind: NativeTriggerKind; path: string | undefined }
) => void
pathPrefix: string
defaultPathSuffix: string
defaultScheduleCron: string
}
}
let { data }: Props = $props()
const LANGUAGES: Array<{ label: string; lang: ScriptLang }> = [
{ label: 'TypeScript (Bun)', lang: 'bun' },
{ label: 'TypeScript (Deno)', lang: 'deno' },
{ label: 'Python', lang: 'python3' },
{ label: 'PostgreSQL', lang: 'postgresql' },
{ label: 'DuckDB', lang: 'duckdb' },
{ label: 'BigQuery', lang: 'bigquery' },
{ label: 'Snowflake', lang: 'snowflake' },
{ label: 'MySQL', lang: 'mysql' },
{ label: 'MS SQL', lang: 'mssql' },
{ label: 'Bash', lang: 'bash' },
{ label: 'Go', lang: 'go' }
]
function handlePick(pick: PipelineInsertPick) {
if (!pick.language || !pick.path) return
const kindId = pick.kindId as KindId
if (kindId === 'schedule') {
data.onAddMaterializer(pick.language as ScriptLang, pick.path, {
kind: 'schedule',
cron: data.defaultScheduleCron
})
} else {
// Native trigger reference: user is expected to fill in the
// trigger path themselves in the editor (or configure it in the
// trigger's own UI). We seed the annotation with an empty ref
// the user replaces.
data.onAddMaterializer(pick.language as ScriptLang, pick.path, {
kind: kindId,
path: undefined
})
}
}
</script>
<PipelineInsertMenu
kinds={[
{
id: 'schedule',
label: 'On schedule',
description: 'Cron-driven materializer',
icon: Clock,
pickLanguage: true
},
{
id: 'webhook',
label: 'On webhook',
description: 'Triggered by an HTTP webhook',
icon: Webhook,
pickLanguage: true
},
{
id: 'email',
label: 'On email',
description: 'Triggered by incoming email',
icon: Mail,
pickLanguage: true
},
{
id: 'kafka',
label: 'On Kafka',
description: 'Triggered by a Kafka message',
icon: Zap,
pickLanguage: true
},
{
id: 'mqtt',
label: 'On MQTT',
description: 'Triggered by an MQTT message',
icon: Radio,
pickLanguage: true
},
{
id: 'nats',
label: 'On NATS',
description: 'Triggered by a NATS message',
icon: MessageSquare,
pickLanguage: true
},
{
id: 'postgres',
label: 'On Postgres',
description: 'Triggered by a Postgres event',
icon: Database,
pickLanguage: true
},
{
id: 'sqs',
label: 'On SQS',
description: 'Triggered by an SQS message',
icon: Send,
pickLanguage: true
},
{
id: 'gcp',
label: 'On GCP Pub/Sub',
description: 'Triggered by a Pub/Sub message',
icon: CloudCog,
pickLanguage: true
}
]}
languages={LANGUAGES as any}
pathPrefix={data.pathPrefix}
defaultPathSuffix={data.defaultPathSuffix}
onPick={handlePick}
>
{#snippet trigger()}
<button
type="button"
class="w-10 h-10 rounded-full flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white shadow-md transition-colors cursor-pointer"
title="Add to pipeline"
>
<Plus size={20} />
</button>
{/snippet}
</PipelineInsertMenu>
@@ -0,0 +1,279 @@
<script lang="ts" module>
import type { ComponentType } from 'svelte'
import type { SupportedLanguage } from '$lib/common'
export type PipelineInsertKind = {
// Machine-readable id; drives which right-column panel renders.
id: string
label: string
description?: string
icon?: ComponentType
// When pickLanguage is true, the right panel shows the language
// picker (and after a language is chosen, a path-entry stage).
// Otherwise onSelect is called directly with no language / path.
pickLanguage?: boolean
}
export type PipelineInsertPick = {
kindId: string
language?: SupportedLanguage
path?: string
}
</script>
<script lang="ts">
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import { ArrowLeft, ChevronRight } from 'lucide-svelte'
import { tick } from 'svelte'
interface Props {
kinds: PipelineInsertKind[]
languages?: Array<{ label: string; lang: SupportedLanguage }>
// Non-editable path prefix shown as a read-only chip before the
// user-editable suffix (e.g. `f/<folder>/`). The final path passed
// in onPick is `pathPrefix + suffix`.
pathPrefix?: string
// Default suffix seeded into the editable input when the user
// reaches the path stage (e.g. `new_materializer`).
defaultPathSuffix?: string
onPick: (pick: PipelineInsertPick) => void
trigger: import('svelte').Snippet
placement?: 'bottom' | 'top' | 'left' | 'right'
}
let {
kinds,
languages = [],
pathPrefix = '',
defaultPathSuffix = '',
onPick,
trigger: triggerSnippet,
placement = 'bottom'
}: Props = $props()
// Flow stages: kind → lang → path → confirm. `stage` drives the right
// column. Only the `language` kinds reach the lang/path stages.
// Default-select the first kind, and if it needs a language jump
// straight into the lang stage so the user doesn't have to click the
// left column to see the language picker.
let selectedKindId = $state<string>(kinds[0]?.id ?? '')
let selectedKind = $derived(kinds.find((k) => k.id === selectedKindId) ?? kinds[0])
let stage = $state<'lang' | 'path' | 'description'>(
kinds[0]?.pickLanguage ? 'lang' : 'description'
)
let selectedLanguage = $state<SupportedLanguage | undefined>(undefined)
let pathSuffix = $state('')
let pathInput: HTMLInputElement | undefined = $state(undefined)
// 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
// stage with the previous suffix, and a subsequent Enter creates a
// duplicate of the first draft.
function resetMenuState() {
selectedKindId = kinds[0]?.id ?? ''
stage = kinds[0]?.pickLanguage ? 'lang' : 'description'
selectedLanguage = undefined
pathSuffix = ''
}
function handleKindClick(k: PipelineInsertKind, close: () => void) {
if (!k.pickLanguage) {
onPick({ kindId: k.id })
close()
return
}
selectedKindId = k.id
stage = 'lang'
selectedLanguage = undefined
pathSuffix = ''
}
// Short random slug appended to the default suffix so that opening the
// menu twice in a row seeds two distinct paths — otherwise creating
// multiple materializers in the same folder collides on the same
// `f/<folder>/<suffix>` and they all become revisions of one script.
function shortSlug(len = 4): string {
const a = 'abcdefghijklmnopqrstuvwxyz0123456789'
let out = ''
for (let i = 0; i < len; i++) out += a[Math.floor(Math.random() * a.length)]
return out
}
async function handleLanguageClick(lang: SupportedLanguage) {
selectedLanguage = lang
const base = defaultPathSuffix || 'materializer'
pathSuffix = `${base}_${shortSlug()}`
stage = 'path'
// Focus the suffix input so the user can just start typing a name.
await tick()
pathInput?.focus()
pathInput?.select()
}
function confirmPath(close: () => void) {
const suffix = pathSuffix.trim()
if (!suffix || !selectedLanguage) return
onPick({
kindId: selectedKindId,
language: selectedLanguage,
path: pathPrefix + suffix
})
close()
}
function handlePathKeydown(e: KeyboardEvent, close: () => void) {
if (e.key === 'Enter') {
e.preventDefault()
confirmPath(close)
} else if (e.key === 'Escape') {
e.preventDefault()
stage = 'lang'
}
}
</script>
<Popover
contentClasses="p-0 bg-surface overflow-hidden"
class="inline-block"
usePointerDownOutside
floatingConfig={{
placement,
strategy: 'absolute',
gutter: 8,
overflowPadding: 16,
flip: true,
fitViewport: true,
overlap: false
}}
on:openChange={(e) => {
if (!e.detail) resetMenuState()
}}
>
{#snippet trigger()}
{@render triggerSnippet?.()}
{/snippet}
{#snippet content({ close })}
{@const singleKind = kinds.length === 1}
<div
class={singleKind
? 'flex flex-row bg-surface-tertiary w-[360px] h-[280px]'
: 'flex flex-row divide-x bg-surface-tertiary w-[560px] h-[280px]'}
>
<!-- Left column: kind picker. Only shown when there's more than one
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}
{@const isSelected = selectedKindId === k.id}
<button
type="button"
onclick={() => 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'
].join(' ')}
>
{#if k.icon}
{@const Icon = k.icon}
<Icon size={14} class="shrink-0 mt-0.5 text-secondary" />
{/if}
<span class="flex flex-col items-start flex-1 min-w-0">
<span class="text-sm font-medium leading-tight">{k.label}</span>
{#if k.description}
<span class="text-2xs text-tertiary font-normal leading-snug mt-0.5">
{k.description}
</span>
{/if}
</span>
{#if k.pickLanguage}
<ChevronRight size={12} class="shrink-0 mt-1 text-secondary" />
{/if}
</button>
{/each}
</div>
{/if}
<!-- 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 === 'path' && selectedLanguage}
<div class="flex flex-col gap-3 p-3 grow overflow-auto">
<div class="flex items-center gap-2">
<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>
</div>
<div class="flex flex-col gap-1">
<span class="text-2xs font-normal text-secondary">Path</span>
<!-- Prefix is a separate non-editable <span> sitting next to
the <input>, so users physically can't delete it with
backspace. The final path is pathPrefix + pathSuffix. -->
<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"
title="Folder-scoped prefix (fixed)"
>
{pathPrefix}
</span>
{/if}
<input
bind:this={pathInput}
bind:value={pathSuffix}
onkeydown={(e) => handlePathKeydown(e, close)}
class="flex-1 min-w-0 px-2 py-1.5 text-sm font-mono bg-transparent focus:outline-none"
placeholder="my_materializer"
/>
</div>
<span class="text-2xs text-tertiary">Press Enter to create</span>
</div>
<div class="flex justify-end mt-auto">
<Button
variant="accent"
unifiedSize="sm"
disabled={!pathSuffix.trim()}
onClick={() => confirmPath(close)}
>
Create
</Button>
</div>
</div>
{:else if selectedKind?.description}
<div class="flex-1 flex items-center justify-center p-4 text-center">
<span class="text-xs text-secondary">{selectedKind.description}</span>
</div>
{/if}
</div>
</div>
{/snippet}
</Popover>
@@ -0,0 +1,176 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { base } from '$lib/base'
import { goto } from '$app/navigation'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Select from '$lib/components/select/Select.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import { FolderService, OpenAPI } from '$lib/gen'
import { resource } from 'runed'
import { sendUserToast } from '$lib/utils'
import { ArrowRight, FolderPlus, Loader2 } from 'lucide-svelte'
interface PipelineFolder {
folder: string
script_count: number
}
interface Props {
open: boolean
// When provided, the current folder is dropped from the existing list
// so users don't "switch" to the folder they're already on.
currentFolder?: string | undefined
}
let { open = $bindable(), currentFolder = undefined }: Props = $props()
let pipelines = resource(
() => $workspaceStore,
async (ws, _prev, { signal }) => {
if (!ws) return [] as PipelineFolder[]
const base_url = OpenAPI.BASE ?? ''
const res = await fetch(`${base_url}/w/${ws}/assets/pipelines`, {
credentials: 'include',
signal
})
if (!res.ok) throw new Error(`GET /assets/pipelines → ${res.status}`)
return (await res.json()) as PipelineFolder[]
}
)
let allFolders = resource(
() => $workspaceStore,
async (ws) => {
if (!ws) return [] as string[]
return await FolderService.listFolderNames({ workspace: ws })
}
)
let selectedExistingFolder = $state<string | undefined>(undefined)
let newFolderName = $state('')
let creatingFolder = $state(false)
let visiblePipelines = $derived(
(pipelines.current ?? []).filter((p) => p.folder !== currentFolder)
)
let foldersWithoutPipeline = $derived.by(() => {
const existing = new Set((pipelines.current ?? []).map((p) => p.folder))
return (allFolders.current ?? []).filter((f) => !existing.has(f) && f !== currentFolder)
})
async function openExistingPipeline(folder: string) {
open = false
await goto(`${base}/pipeline/${encodeURIComponent(folder)}`)
}
async function startInExistingFolder() {
if (!selectedExistingFolder) return
await openExistingPipeline(selectedExistingFolder)
}
async function createFolderAndStart() {
const name = newFolderName.trim()
if (!name || !$workspaceStore) return
creatingFolder = true
try {
await FolderService.createFolder({
workspace: $workspaceStore,
requestBody: { name }
})
sendUserToast(`Created folder f/${name}`)
await openExistingPipeline(name)
} catch (e: any) {
sendUserToast(`Failed to create folder: ${e?.body ?? e?.message ?? e}`, true)
} finally {
creatingFolder = false
}
}
</script>
<Modal bind:open title="Open a pipeline">
<div class="flex flex-col gap-6 w-full min-w-0">
{#if visiblePipelines.length > 0 || pipelines.loading}
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
Existing pipelines
</h3>
{#if pipelines.loading && !pipelines.current}
<div class="text-tertiary text-sm flex items-center gap-2">
<Loader2 size={14} class="animate-spin" />
Loading…
</div>
{:else if pipelines.error}
<div class="text-red-500 text-sm">Failed: {pipelines.error.message}</div>
{:else}
<div class="flex flex-col border rounded-md overflow-hidden max-h-64 overflow-y-auto">
{#each visiblePipelines as p}
<button
type="button"
class="flex items-center justify-between px-3 py-2 border-b last:border-b-0 bg-surface hover:bg-surface-hover transition-colors text-left"
onclick={() => openExistingPipeline(p.folder)}
>
<span class="font-mono text-sm">f/{p.folder}</span>
<span class="text-2xs text-tertiary">
{p.script_count}
{p.script_count === 1 ? 'materializer' : 'materializers'}
</span>
</button>
{/each}
</div>
{/if}
</section>
{/if}
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
Start in an existing folder
</h3>
<div class="flex items-center gap-2">
<div class="flex-1">
<Select
items={foldersWithoutPipeline.map((f) => ({ label: `f/${f}`, value: f }))}
bind:value={selectedExistingFolder}
placeholder={foldersWithoutPipeline.length === 0
? 'No folders available'
: 'Pick a folder…'}
clearable
/>
</div>
<Button
variant="accent"
unifiedSize="sm"
disabled={!selectedExistingFolder}
onclick={startInExistingFolder}
startIcon={{ icon: ArrowRight }}
>
Open
</Button>
</div>
</section>
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
Or create a new folder
</h3>
<div class="flex items-center gap-2">
<div class="flex-1">
<TextInput
bind:value={newFolderName}
placeholder="new-folder-name"
disabled={creatingFolder}
/>
</div>
<Button
variant="accent"
unifiedSize="sm"
disabled={!newFolderName.trim() || creatingFolder}
onclick={createFolderAndStart}
startIcon={{ icon: FolderPlus }}
>
{creatingFolder ? 'Creating…' : 'Create & open'}
</Button>
</div>
</section>
</div>
</Modal>
@@ -0,0 +1,145 @@
<script lang="ts" module>
import type { ComponentType } from 'svelte'
import type { NativeTriggerKind } from './types'
// Trigger kinds the pipeline graph can render as a source node. Union of
// 'schedule' (inline cron) and the eight native-trigger keywords.
export type TriggerNodeKind = 'schedule' | NativeTriggerKind
// Per-kind presentation. Icons are kept loose — pick the lucide glyph
// whose shape most-obviously signals the trigger type at a glance.
import {
Clock,
Database,
Mail,
MessageSquare,
Radio,
Send,
Webhook,
Zap,
CloudCog
} from 'lucide-svelte'
type Presentation = {
icon: ComponentType
label: string
// Tailwind class fragments for bg + border + text accent.
bg: string
border: string
borderUnsaved: string
iconText: string
}
export const TRIGGER_NODE_STYLE: Record<TriggerNodeKind, Presentation> = {
schedule: {
icon: Clock,
label: 'schedule',
bg: 'bg-amber-50 dark:bg-amber-900/30',
border: 'outline-amber-300 dark:outline-amber-600/60',
borderUnsaved: 'outline-dashed outline-amber-400',
iconText: 'text-amber-700 dark:text-amber-400'
},
webhook: {
icon: Webhook,
label: 'webhook',
bg: 'bg-sky-50 dark:bg-sky-900/30',
border: 'outline-sky-300 dark:outline-sky-600/60',
borderUnsaved: 'outline-dashed outline-sky-400',
iconText: 'text-sky-700 dark:text-sky-400'
},
email: {
icon: Mail,
label: 'email',
bg: 'bg-violet-50 dark:bg-violet-900/30',
border: 'outline-violet-300 dark:outline-violet-600/60',
borderUnsaved: 'outline-dashed outline-violet-400',
iconText: 'text-violet-700 dark:text-violet-400'
},
kafka: {
icon: Zap,
label: 'kafka',
bg: 'bg-rose-50 dark:bg-rose-900/30',
border: 'outline-rose-300 dark:outline-rose-600/60',
borderUnsaved: 'outline-dashed outline-rose-400',
iconText: 'text-rose-700 dark:text-rose-400'
},
mqtt: {
icon: Radio,
label: 'mqtt',
bg: 'bg-teal-50 dark:bg-teal-900/30',
border: 'outline-teal-300 dark:outline-teal-600/60',
borderUnsaved: 'outline-dashed outline-teal-400',
iconText: 'text-teal-700 dark:text-teal-400'
},
nats: {
icon: MessageSquare,
label: 'nats',
bg: 'bg-cyan-50 dark:bg-cyan-900/30',
border: 'outline-cyan-300 dark:outline-cyan-600/60',
borderUnsaved: 'outline-dashed outline-cyan-400',
iconText: 'text-cyan-700 dark:text-cyan-400'
},
postgres: {
icon: Database,
label: 'postgres',
bg: 'bg-indigo-50 dark:bg-indigo-900/30',
border: 'outline-indigo-300 dark:outline-indigo-600/60',
borderUnsaved: 'outline-dashed outline-indigo-400',
iconText: 'text-indigo-700 dark:text-indigo-400'
},
sqs: {
icon: Send,
label: 'sqs',
bg: 'bg-orange-50 dark:bg-orange-900/30',
border: 'outline-orange-300 dark:outline-orange-600/60',
borderUnsaved: 'outline-dashed outline-orange-400',
iconText: 'text-orange-700 dark:text-orange-400'
},
gcp: {
icon: CloudCog,
label: 'gcp',
bg: 'bg-emerald-50 dark:bg-emerald-900/30',
border: 'outline-emerald-300 dark:outline-emerald-600/60',
borderUnsaved: 'outline-dashed outline-emerald-400',
iconText: 'text-emerald-700 dark:text-emerald-400'
}
}
</script>
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { NODE } from '$lib/components/graph/util'
import { twMerge } from 'tailwind-merge'
interface Props {
// `ref` is the cron expression for schedules, the trigger-path for
// every other kind. Rendered verbatim — no formatting per kind.
data: { kind: TriggerNodeKind; ref: string; unsaved?: boolean }
}
let { data }: Props = $props()
let style = $derived(TRIGGER_NODE_STYLE[data.kind])
let Icon = $derived(style.icon)
</script>
<div class="relative">
<div
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1',
style.bg,
data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={data.unsaved ? `Unsaved ${style.label}: ${data.ref}` : `${style.label}: ${data.ref}`}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${style.iconText}`} />
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span class="text-3xs uppercase tracking-wide text-tertiary truncate">
{style.label}{data.unsaved ? ' · unsaved' : ''}
</span>
<span class="text-2xs font-mono text-emphasis truncate">{data.ref}</span>
</div>
</div>
</div>
<Handle type="source" position={Position.Bottom} isConnectable={false} />
@@ -0,0 +1,137 @@
import type { AssetKind } from '$lib/gen'
import type { NativeTriggerKind } from './types'
// Mirror of backend/parsers/windmill-parser/src/asset_parser.rs
// `parse_pipeline_annotations` + `parse_trigger_spec`, ported to TS so the
// pipeline editor can reflect `// materialize` / `// on <spec>` edits live
// before deploy. Keep the two implementations behaviorally identical — any
// divergence means the graph preview lies.
const COMMENT_PREFIXES = ['//', '--', '#'] as const
const ASSET_PREFIXES: Array<[string, AssetKind]> = [
['s3://', 's3object'],
['res://', 'resource'],
['$res:', 'resource'],
['ducklake://', 'ducklake'],
['datatable://', 'datatable'],
['volume://', 'volume']
]
// Non-native, non-schedule trigger keywords — each carries a workspace
// trigger-path reference (`on <kind> <path>`).
const NATIVE_TRIGGER_KEYWORDS: NativeTriggerKind[] = [
'webhook',
'email',
'kafka',
'mqtt',
'nats',
'postgres',
'sqs',
'gcp'
]
export type PipelineTriggerAsset = { kind: AssetKind; path: string }
export type PipelineNativeTrigger = { kind: NativeTriggerKind; path: string }
export type PipelineAnnotations = {
isMaterializer: boolean
triggerAssets: PipelineTriggerAsset[]
schedules: string[] // raw cron expressions, in insertion order
nativeTriggers: PipelineNativeTrigger[]
}
function unquote(s: string): string | undefined {
if (s.length >= 2) {
const q = s[0]
if ((q === '"' || q === "'") && s.endsWith(q)) {
return s.slice(1, -1)
}
}
return undefined
}
function parseAssetSyntax(s: string): PipelineTriggerAsset | undefined {
for (const [prefix, kind] of ASSET_PREFIXES) {
if (s.startsWith(prefix)) {
return { kind, path: s.slice(prefix.length) }
}
}
return undefined
}
type ParsedSpec =
| { kind: 'asset'; value: PipelineTriggerAsset }
| { kind: 'schedule'; value: string }
| { kind: 'native'; value: PipelineNativeTrigger }
function parseTriggerSpec(s: string): ParsedSpec | undefined {
if (s.startsWith('schedule')) {
const rest = s.slice('schedule'.length).trimStart()
const cron = unquote(rest)
if (cron && cron.trim() !== '') return { kind: 'schedule', value: cron }
return undefined
}
for (const kw of NATIVE_TRIGGER_KEYWORDS) {
if (s.startsWith(kw)) {
const after = s.slice(kw.length)
// Require whitespace so `kafkalike` doesn't match `kafka`.
if (after.length === 0 || !/\s/.test(after[0])) continue
const path = after.trim()
if (!path) return undefined
return { kind: 'native', value: { kind: kw, path } }
}
}
const asset = parseAssetSyntax(s)
if (asset) return { kind: 'asset', value: asset }
return undefined
}
function stripCommentPrefix(line: string): string | undefined {
const trimmed = line.trimStart()
for (const p of COMMENT_PREFIXES) {
if (trimmed.startsWith(p)) return trimmed.slice(p.length)
}
return undefined
}
export function parsePipelineAnnotations(code: string): PipelineAnnotations {
let isMaterializer = false
const triggerAssets: PipelineTriggerAsset[] = []
const schedules: string[] = []
const nativeTriggers: PipelineNativeTrigger[] = []
for (const rawLine of code.split('\n')) {
const rest = stripCommentPrefix(rawLine)
if (rest === undefined) continue
const inner = rest.trimStart()
if (inner.startsWith('materialize')) {
const after = inner.slice('materialize'.length)
if (after === '' || /\s/.test(after[0])) isMaterializer = true
continue
}
if (inner.startsWith('on')) {
const after = inner.slice('on'.length)
if (after === '' || !/\s/.test(after[0])) continue
const specText = after.trim()
if (!specText) continue
const spec = parseTriggerSpec(specText)
if (!spec) continue
if (spec.kind === 'asset') {
if (!triggerAssets.some((a) => a.kind === spec.value.kind && a.path === spec.value.path)) {
triggerAssets.push(spec.value)
}
} else if (spec.kind === 'schedule') {
if (!schedules.includes(spec.value)) schedules.push(spec.value)
} else {
if (!nativeTriggers.some((n) => n.kind === spec.value.kind && n.path === spec.value.path)) {
nativeTriggers.push(spec.value)
}
}
}
}
return { isMaterializer, triggerAssets, schedules, nativeTriggers }
}
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Pipelines' }
}
}
@@ -0,0 +1,47 @@
<script lang="ts">
import { userStore } from '$lib/stores'
import Button from '$lib/components/common/button/Button.svelte'
import PipelinePickerModal from '$lib/components/assets/AssetGraph/PipelinePickerModal.svelte'
import { ArrowRight, NetworkIcon } from 'lucide-svelte'
// Modal is open by default on landing; the editor shell stays empty
// behind it until the user picks or creates a folder.
let pickerOpen = $state(true)
</script>
<svelte:head>
<title>Pipeline editor — Windmill</title>
</svelte:head>
{#if $userStore?.operator}
<div class="p-8 text-tertiary">Page not available for operators.</div>
{:else}
<div class="flex flex-col h-full">
<div
class="border-b flex flex-row justify-between gap-2 px-2 py-1 items-center min-h-10 shrink-0 whitespace-nowrap"
>
<div class="flex flex-row items-center gap-2">
<NetworkIcon size={16} class="text-tertiary shrink-0" />
<h1 class="text-sm font-semibold">Pipeline editor</h1>
<span class="text-xs text-tertiary">· no folder selected</span>
</div>
</div>
<div class="flex-1 min-h-0 relative bg-surface-secondary">
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-tertiary">
<NetworkIcon size={32} class="opacity-50" />
<span class="text-sm">Pick a folder to open its pipeline.</span>
<Button
variant="accent"
unifiedSize="sm"
onclick={() => (pickerOpen = true)}
startIcon={{ icon: ArrowRight }}
>
Choose a folder
</Button>
</div>
</div>
</div>
<PipelinePickerModal bind:open={pickerOpen} />
{/if}
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Pipeline editor' }
}
}
@@ -0,0 +1,748 @@
<script lang="ts">
import { workspaceStore, userStore } from '$lib/stores'
import { base } from '$lib/base'
import { page } from '$app/state'
import Button from '$lib/components/common/button/Button.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte'
import AssetGraphDetailsPane from '$lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte'
import PipelinePickerModal from '$lib/components/assets/AssetGraph/PipelinePickerModal.svelte'
import type {
AssetGraphResponse,
AssetGraphSelection
} from '$lib/components/assets/AssetGraph/types'
import {
parsePipelineAnnotations,
type PipelineAnnotations
} from '$lib/components/assets/AssetGraph/parsePipelineAnnotations'
import { decodeState, encodeState } from '$lib/utils'
import { onMount, untrack } from 'svelte'
import {
ArrowLeft,
ChevronDown,
Folder,
FolderSearch,
Loader2,
NetworkIcon,
RefreshCw
} from 'lucide-svelte'
import { OpenAPI, type AssetKind, type Script, type ScriptLang } from '$lib/gen'
import { initialCode } from '$lib/script_helpers'
import { resource } from 'runed'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { emptySchema } from '$lib/utils'
import { goto } from '$app/navigation'
// Variables and resources are declarative config, not pipeline assets —
// they're hub-shaped (referenced by most runnables) and would swamp the
// layout without adding lineage information.
const DATA_KINDS = ['s3object', 'ducklake', 'datatable', 'volume']
let folder = $derived(page.params.folder as string)
let selection = $state<AssetGraphSelection | undefined>(undefined)
// Asset-kind → syntax-prefix for `// on <ref>` reconstruction. Mirrors
// ASSET_KINDS in backend/parsers/windmill-parser/src/asset_parser.rs.
const ASSET_PREFIX: Record<AssetKind, string> = {
s3object: 's3://',
resource: '$res:',
ducklake: 'ducklake://',
datatable: 'datatable://',
volume: 'volume://'
}
// Path-input split for the insert menu: a read-only `f/<folder>/` chip
// on the left the user can't delete, plus an editable suffix seeded
// with a placeholder name.
let pathPrefix = $derived(`f/${folder}/`)
const DEFAULT_PATH_SUFFIX = 'new_materializer'
// Default cron for top + materializers (pipeline roots). Every hour is a
// sane middle ground between batch and real-time; users edit the
// `// on schedule "..."` line in the editor before saving if they want
// something different. Per-asset materializers don't get a schedule by
// default — they inherit their trigger from the upstream asset.
const DEFAULT_SCHEDULE_CRON = '0 * * * *'
// In-flight drafts keyed by script path. Multiple can coexist — clicking
// + repeatedly creates additional drafts, each with its own random
// output asset, and they all render on the graph simultaneously.
// Saving removes a draft from the map; closing the pane keeps it so the
// user can come back to it.
type Draft = {
script: Script
outputAsset: { kind: AssetKind; path: string }
}
let drafts = $state<Map<string, Draft>>(new Map())
// Which draft (if any) is currently open in the details pane. When
// undefined and `selection` is set, the pane shows the persisted
// selection's script. Never both at once.
let activeDraftPath = $state<string | undefined>(undefined)
// Per-folder localStorage key. Matches the flow-builder pattern so
// reloading /pipeline/<folder> restores in-flight drafts. We serialize
// the drafts map as an entry array (Map doesn't survive JSON.stringify).
let storageKey = $derived(`pipeline-${folder}`)
onMount(() => {
if (typeof localStorage === 'undefined') return
const raw = localStorage.getItem(`pipeline-${folder}`)
if (!raw) return
try {
const state = decodeState(raw)
if (Array.isArray(state?.drafts)) {
const loaded = new Map<string, Draft>()
for (const entry of state.drafts) {
if (entry && typeof entry[0] === 'string' && entry[1]?.script) {
loaded.set(entry[0], entry[1] as Draft)
}
}
if (loaded.size > 0) drafts = loaded
}
if (typeof state?.activeDraftPath === 'string') {
activeDraftPath = state.activeDraftPath
}
} catch (e) {
console.warn('failed to restore pipeline state', e)
}
})
// Debounced persist: reruns whenever drafts / activeDraftPath change.
// 500 ms matches FlowBuilder.saveSessionDraft; balances typing churn
// against losing state to a crash.
let persistTimer: number | undefined = undefined
$effect(() => {
// Track deps explicitly so Svelte 5 re-runs on mutation.
const serialized = Array.from(drafts.entries())
const activePath = activeDraftPath
const key = storageKey
untrack(() => {
if (typeof localStorage === 'undefined') return
if (persistTimer != undefined) clearTimeout(persistTimer)
persistTimer = window.setTimeout(() => {
try {
if (serialized.length === 0 && !activePath) {
localStorage.removeItem(key)
} else {
localStorage.setItem(
key,
encodeState({ drafts: serialized, activeDraftPath: activePath })
)
}
} catch (e) {
console.warn('failed to persist pipeline state', e)
}
}, 500)
})
})
// Live-parsed annotations from whatever script is currently open in the
// details pane (draft or existing). Refreshed on every keystroke via
// `onAnnotationsChange`. Used to overlay unsaved schedule / trigger-asset
// edges onto the graph so the editor buffer and the graph stay in sync.
let liveAnnotations = $state<{
scriptPath: string | undefined
annotations: PipelineAnnotations
}>({
scriptPath: undefined,
annotations: {
isMaterializer: false,
triggerAssets: [],
schedules: [],
nativeTriggers: []
}
})
// Crockford-ish random slug for asset paths. 7 chars of [a-z0-9] gives
// ~36^7 ≈ 7.8e10 combinations — collision-free in practice for the
// handful of scripts a user creates in a session.
function randomSlug(len = 7): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
let out = ''
for (let i = 0; i < len; i++) {
out += alphabet[Math.floor(Math.random() * alphabet.length)]
}
return out
}
function randomOutputAssetPath(): { kind: AssetKind; path: string } {
// `s3://` is the most universal kind; works as a literal across every
// supported language without extra imports. Inside `pipelines/<folder>/`
// so outputs of a single pipeline cluster under one prefix.
return { kind: 's3object', path: `pipelines/${folder}/out_${randomSlug()}.parquet` }
}
// Language → comment prefix recognized by parse_pipeline_annotations.
function commentPrefix(lang: ScriptLang): string {
switch (lang) {
case 'python3':
case 'bash':
case 'powershell':
case 'nu':
case 'ansible':
return '#'
case 'postgresql':
case 'mysql':
case 'bigquery':
case 'snowflake':
case 'mssql':
case 'oracledb':
case 'duckdb':
return '--'
default:
return '//'
}
}
// Each entry becomes a `// on <…>` line in the seeded template. Multiple
// trigger sources are valid (a script can be fired by both a schedule
// and a webhook, for instance), but the menu only seeds one at a time.
type DraftTriggerSource =
| { kind: 'schedule'; cron: string }
| { kind: 'asset'; ref: string } // already-prefixed (e.g. s3://…)
| {
kind: 'webhook' | 'email' | 'kafka' | 'mqtt' | 'nats' | 'postgres' | 'sqs' | 'gcp'
path: string | undefined
}
// Short "how to author a materializer" doc block prepended to every new
// draft. Kept terse because the editor mounts it on-screen — a wall of
// comments up top discourages the user more than it helps.
function materializerHeader(language: ScriptLang, sources: DraftTriggerSource[]): string {
const p = commentPrefix(language)
const onLines = sources.map((s) => {
switch (s.kind) {
case 'schedule':
return `${p} on schedule "${s.cron}"`
case 'asset':
return `${p} on ${s.ref}`
default:
// Empty placeholder path makes it visible the user needs
// to fill in the trigger reference.
return `${p} on ${s.kind} ${s.path ?? '<trigger-path>'}`
}
})
const lines = [
`${p} materialize`,
...onLines,
`${p}`,
`${p} This script is a pipeline materializer.`,
`${p} - Reads and writes detected in the code become the lineage edges`,
`${p} shown in the pipeline graph (no extra declaration needed).`,
`${p} - The \`${p} materialize\` marker opts it into the pipeline.`,
`${p} - \`${p} on <asset|schedule "cron"|<kind> <path>>\` declares trigger edges.`,
`${p} Supported trigger kinds: schedule, webhook, email, kafka, mqtt,`,
`${p} nats, postgres, sqs, gcp.`,
`${p}`,
`${p} Put your logic inside \`main\`. Whatever you return is the script's`,
`${p} output; writes to assets (e.g. s3://, datatable://, ducklake://,`,
`${p} volume://) go through the usual Windmill helpers.`,
''
]
return lines.join('\n')
}
// Per-language boilerplate that declares the output asset URI at module
// scope. Two reasons:
// 1. The string literal is where the backend parser picks up the write
// on deploy — reproducing the same asset → runnable edge the
// frontend draft overlay shows while unsaved.
// 2. Gives the user a named variable to reference when filling in
// `main`, instead of a magic string deep in the body.
function outputAssetSnippet(language: ScriptLang, uri: string): string {
const p = commentPrefix(language)
const note = `${p} Output of this materializer — write to this path inside \`main\`.`
switch (language) {
case 'python3':
return `${note}\nOUTPUT = "${uri}"\n\n`
case 'postgresql':
case 'mysql':
case 'bigquery':
case 'snowflake':
case 'mssql':
case 'oracledb':
case 'duckdb':
// SQL has no variable scope we can reliably reuse; drop a
// referenceable comment the parser still finds inside strings.
return `${note}\n${p} ${uri}\n\n`
case 'bash':
case 'powershell':
case 'nu':
case 'ansible':
return `${note}\nOUTPUT="${uri}"\n\n`
default:
return `${note}\nconst OUTPUT = "${uri}"\n\n`
}
}
function buildDraft(
language: ScriptLang,
scriptPath: string,
sources: DraftTriggerSource[],
outputAssetUri?: string
): Script {
// Reuse the language-specific main() boilerplate Windmill already
// ships with so every language produces a usable function signature.
const body = initialCode(language as any, 'script', 'script')
const header = materializerHeader(language, sources)
const output = outputAssetUri ? outputAssetSnippet(language, outputAssetUri) : ''
const content = header + output + body
// Cast through unknown: the Script generated type has many readonly
// deployment fields (hash, created_*) that we don't care about for a
// local draft. The details pane only reads path/language/content/schema.
return {
hash: '',
path: scriptPath,
summary: '',
description: '',
content,
schema: emptySchema(),
is_template: false,
extra_perms: {},
language,
kind: 'script',
created_by: '',
created_at: new Date().toISOString(),
archived: false,
deleted: false,
starred: false
} as unknown as Script
}
function openMaterializerDraft(
language: ScriptLang,
scriptPath: string,
sources: DraftTriggerSource[]
) {
const out = randomOutputAssetPath()
const outputUri = `${ASSET_PREFIX[out.kind]}${out.path}`
const script = buildDraft(language, scriptPath, sources, outputUri)
// Write the new draft into the map (structural update so Svelte
// re-derives graphWithDraft) and focus it in the details pane.
const next = new Map(drafts)
next.set(scriptPath, { script, outputAsset: out })
drafts = next
activeDraftPath = scriptPath
selection = undefined
}
function discardDraft(path: string) {
if (!drafts.has(path)) return
const next = new Map(drafts)
next.delete(path)
drafts = next
if (activeDraftPath === path) activeDraftPath = undefined
}
// Currently-open draft shape (if any) — fed into the details pane.
let activeDraft = $derived(activeDraftPath ? drafts.get(activeDraftPath) : undefined)
// Overlay the draft runnable + live-parsed trigger edges onto the fetched
// graph. Live edges come from the editor buffer's `// on <spec>` lines
// and are marked `unsaved: true` unless they already match a persisted
// script_trigger row. This keeps the canvas in sync with the editor
// keystroke-by-keystroke; saving replaces the live edges with real ones
// via graphRes.refetch().
let graphWithDraft = $derived.by<AssetGraphResponse>(() => {
const base = graphRes.current ?? EMPTY_GRAPH
// Every draft contributes: a runnable, an output asset, a write edge,
// plus its own seeded schedule trigger (template includes `// on
// schedule "0 * * * *"` by default, picked up through live parse).
// We iterate the whole `drafts` map so multiple concurrent drafts
// all render as their own subgraph at once.
const runnables = [...base.runnables]
const assets = [...base.assets]
const edges = [...base.edges]
const extraTriggers: AssetGraphResponse['triggers'] = []
for (const [path, d] of drafts) {
runnables.push({ path, usage_kind: 'script', is_materializer: true })
const out = d.outputAsset
const hasAsset = assets.some((a) => a.kind === out.kind && a.path === out.path)
if (!hasAsset) assets.push({ kind: out.kind, path: out.path })
edges.push({
runnable_path: path,
runnable_kind: 'script',
asset_kind: out.kind,
asset_path: out.path,
access_type: 'w',
unsaved: true
})
// Seed trigger edges (schedule + asset) from the draft's template
// so the graph stays stable when the user clicks off this draft.
// Live annotations (below) take over for the currently-open draft
// so keystroke edits still update in real time.
const parsed = parsePipelineAnnotations(d.script.content)
for (const cron of parsed.schedules) {
extraTriggers.push({
trigger_kind: 'schedule',
cron,
runnable_kind: 'script',
runnable_path: path,
unsaved: true
})
}
for (const a of parsed.triggerAssets) {
extraTriggers.push({
trigger_kind: 'asset',
asset_kind: a.kind,
asset_path: a.path,
runnable_kind: 'script',
runnable_path: path,
unsaved: true
})
// Also synthesize the asset node so the trigger edge has a
// target even if the upstream asset isn't in base (e.g. the
// producer script is in another folder we haven't fetched
// or also a draft).
const hasTriggerAsset = assets.some((x) => x.kind === a.kind && x.path === a.path)
if (!hasTriggerAsset) assets.push({ kind: a.kind, path: a.path })
}
for (const n of parsed.nativeTriggers) {
extraTriggers.push({
trigger_kind: n.kind,
path: n.path,
runnable_kind: 'script',
runnable_path: path,
unsaved: true
})
}
}
// Live-parsed overlay for the currently-open script — takes precedence
// over the seeded-template triggers for the same path by swapping
// them out. Scoped to one path (only one pane is open at a time).
const livePath = liveAnnotations.scriptPath
if (livePath) {
const persistedAssetKeys = new Set(
base.triggers
.filter(
(t) =>
t.trigger_kind === 'asset' &&
t.runnable_kind === 'script' &&
t.runnable_path === livePath
)
.map((t) => (t.trigger_kind === 'asset' ? `${t.asset_kind}:${t.asset_path}` : ''))
)
const persistedScheduleCrons = new Set(
base.triggers
.filter(
(t) =>
t.trigger_kind === 'schedule' &&
t.runnable_kind === 'script' &&
t.runnable_path === livePath
)
.map((t) => (t.trigger_kind === 'schedule' ? t.cron : ''))
)
// Strip seeded triggers we computed above for the active draft;
// live annotations are authoritative for the open buffer.
for (let i = extraTriggers.length - 1; i >= 0; i--) {
if (extraTriggers[i].runnable_path === livePath) extraTriggers.splice(i, 1)
}
for (const a of liveAnnotations.annotations.triggerAssets) {
const key = `${a.kind}:${a.path}`
if (persistedAssetKeys.has(key)) continue
extraTriggers.push({
trigger_kind: 'asset',
asset_kind: a.kind,
asset_path: a.path,
runnable_kind: 'script',
runnable_path: livePath,
unsaved: true
})
}
for (const cron of liveAnnotations.annotations.schedules) {
if (persistedScheduleCrons.has(cron)) continue
extraTriggers.push({
trigger_kind: 'schedule',
cron,
runnable_kind: 'script',
runnable_path: livePath,
unsaved: true
})
}
// Persisted native triggers keyed by `<kind>:<path>`, used to
// suppress duplicate overlay for already-saved `// on <kind>`
// annotations. trigger_kind is narrower than the union so we
// cast through string.
const persistedNativeKeys = new Set(
base.triggers
.filter(
(t) =>
t.trigger_kind !== 'asset' &&
t.trigger_kind !== 'schedule' &&
t.runnable_kind === 'script' &&
t.runnable_path === livePath
)
.map((t) => `${t.trigger_kind}:${(t as { path: string }).path}`)
)
for (const n of liveAnnotations.annotations.nativeTriggers) {
const key = `${n.kind}:${n.path}`
if (persistedNativeKeys.has(key)) continue
extraTriggers.push({
trigger_kind: n.kind,
path: n.path,
runnable_kind: 'script',
runnable_path: livePath,
unsaved: true
})
}
}
return { ...base, assets, runnables, edges, triggers: [...base.triggers, ...extraTriggers] }
})
// Selection highlights the active draft (if any) or the user's picked
// node. Non-active drafts render without selection highlight but are
// still clickable to re-enter their edit pane.
let effectiveSelection = $derived<AssetGraphSelection | undefined>(
activeDraftPath
? { kind: 'runnable', runnable_kind: 'script', path: activeDraftPath }
: selection
)
// Folder-picker modal state. Opens from the folder selector button when
// there are no other pipelines to switch to, or from the "Choose another
// folder…" entry in the dropdown otherwise.
let pickerModalOpen = $state(false)
// Reuse the empty AssetGraphResponse so we can still render the canvas
// (layout, controls, mini-map) on a fresh pipeline.
const EMPTY_GRAPH: AssetGraphResponse = {
assets: [],
runnables: [],
edges: [],
triggers: []
}
// Powers the folder switcher in the header. Same endpoint the landing
// page uses, so switches are free after the first fetch.
let pipelineFoldersRes = resource(
() => $workspaceStore,
async (ws, _prev, { signal }) => {
if (!ws) return [] as Array<{ folder: string; script_count: number }>
const base_url = OpenAPI.BASE ?? ''
const res = await fetch(`${base_url}/w/${ws}/assets/pipelines`, {
credentials: 'include',
signal
})
if (!res.ok) return []
return (await res.json()) as Array<{ folder: string; script_count: number }>
}
)
// Only pipelines the user can actually switch to — the current folder
// is excluded. If this is empty, the folder selector button opens the
// picker modal directly instead of a single-item dropdown.
let otherPipelineFolders = $derived(
(pipelineFoldersRes.current ?? []).filter((p) => p.folder !== folder)
)
let folderSwitcherItems = $derived.by(() => {
const items = otherPipelineFolders.map((p) => ({
displayName: `f/${p.folder}`,
icon: Folder,
disabled: false,
action: () => goto(`${base}/pipeline/${encodeURIComponent(p.folder)}`)
}))
items.push({
displayName: 'Choose another folder…',
icon: FolderSearch,
disabled: false,
action: async () => {
pickerModalOpen = true
}
})
return items
})
let graphRes = resource(
[() => $workspaceStore, () => folder],
async ([ws, f], _prev, { signal }) => {
if (!ws || !f) return undefined
const base_url = OpenAPI.BASE ?? ''
const params = new URLSearchParams({
folder: f,
asset_kinds: DATA_KINDS.join(',')
})
const res = await fetch(`${base_url}/w/${ws}/assets/graph?${params}`, {
credentials: 'include',
signal
})
if (!res.ok) throw new Error(`GET /assets/graph → ${res.status}`)
return (await res.json()) as AssetGraphResponse
}
)
function pluralize(n: number, singular: string): string {
return `${n} ${singular}${n === 1 ? '' : 's'}`
}
let summary = $derived.by<string[]>(() => {
const g = graphRes.current
if (!g) return []
const parts: string[] = []
const scripts = g.runnables.filter((r) => r.usage_kind === 'script').length
const flows = g.runnables.filter((r) => r.usage_kind === 'flow').length
if (scripts) parts.push(pluralize(scripts, 'script'))
if (flows) parts.push(pluralize(flows, 'flow'))
const byKind = new Map<string, number>()
for (const a of g.assets) byKind.set(a.kind, (byKind.get(a.kind) ?? 0) + 1)
for (const [kind, n] of byKind) parts.push(pluralize(n, kind))
return parts
})
</script>
<svelte:head>
<title>Pipeline · {folder} — Windmill</title>
</svelte:head>
{#if $userStore?.operator}
<div class="p-8 text-tertiary">Page not available for operators.</div>
{:else}
<div class="flex flex-col h-full">
<div
class="border-b flex flex-row justify-between gap-2 px-2 py-1 items-center overflow-y-visible overflow-x-auto min-h-10 shrink-0 whitespace-nowrap"
>
<div class="flex flex-row items-center gap-2">
<Button
variant="subtle"
unifiedSize="sm"
href="{base}/pipeline"
startIcon={{ icon: ArrowLeft }}
iconOnly
title="Back to pipelines"
/>
<NetworkIcon size={16} class="text-tertiary shrink-0" />
<h1 class="text-sm font-semibold">Pipeline editor</h1>
<span class="text-tertiary text-sm">·</span>
{#if otherPipelineFolders.length === 0}
<button
type="button"
onclick={() => (pickerModalOpen = true)}
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 bg-surface hover:bg-surface-hover transition-colors"
title="Switch pipeline folder"
>
<Folder size={14} class="text-emerald-600 dark:text-emerald-400 shrink-0" />
<span class="text-sm font-mono font-medium text-emphasis">f/{folder}</span>
<ChevronDown size={12} class="text-tertiary" />
</button>
{:else}
<DropdownV2 size="sm" items={folderSwitcherItems}>
{#snippet buttonReplacement()}
<span
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 bg-surface hover:bg-surface-hover transition-colors"
title="Switch pipeline folder"
>
<Folder size={14} class="text-emerald-600 dark:text-emerald-400 shrink-0" />
<span class="text-sm font-mono font-medium text-emphasis">f/{folder}</span>
<ChevronDown size={12} class="text-tertiary" />
</span>
{/snippet}
</DropdownV2>
{/if}
{#if summary.length > 0}
<span class="text-xs text-tertiary">· {summary.join(' · ')}</span>
{/if}
</div>
<div class="flex flex-row items-center gap-2">
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RefreshCw }}
onclick={() => graphRes.refetch()}
disabled={graphRes.loading}
iconOnly
title="Refresh"
/>
</div>
</div>
<div class="flex-1 min-h-0">
{#if graphRes.loading && !graphRes.current}
<div class="h-full flex items-center justify-center gap-2 text-tertiary">
<Loader2 size={18} class="animate-spin" />
<span>Loading pipeline…</span>
</div>
{:else if graphRes.error}
<div class="h-full flex items-center justify-center text-red-500 text-sm">
Failed to load pipeline: {graphRes.error.message}
</div>
{:else}
<Splitpanes class="!h-full">
<Pane size={selection ? 60 : 100}>
<AssetGraphCanvas
graph={graphWithDraft}
selection={effectiveSelection}
{pathPrefix}
defaultPathSuffix={DEFAULT_PATH_SUFFIX}
defaultScheduleCron={DEFAULT_SCHEDULE_CRON}
onselect={(s) => {
// Clicking a draft runnable node re-opens it in the pane;
// clicking anything else selects it normally and detaches
// from any active draft (drafts stay overlaid until saved
// or discarded).
if (
s &&
s.kind === 'runnable' &&
s.runnable_kind === 'script' &&
drafts.has(s.path)
) {
activeDraftPath = s.path
selection = undefined
} else {
activeDraftPath = undefined
selection = s
}
}}
onAddScriptForAsset={(asset, language, scriptPath) => {
const ref = `${ASSET_PREFIX[asset.kind]}${asset.path}`
openMaterializerDraft(language, scriptPath, [{ kind: 'asset', ref }])
}}
onAddMaterializer={(language, scriptPath, source) =>
openMaterializerDraft(language, scriptPath, [source])}
/>
</Pane>
{#if (selection || activeDraft) && $workspaceStore}
<Pane size={40} minSize={25}>
<AssetGraphDetailsPane
selection={activeDraft ? undefined : selection}
draftScript={activeDraft?.script}
workspace={$workspaceStore}
onAnnotationsChange={(scriptPath, annotations) => {
liveAnnotations = { scriptPath, annotations }
}}
onclose={() => {
// Close dismisses the pane but preserves drafts so
// the user can come back to them. Discarding is
// via the explicit "Discard" button in the pane.
selection = undefined
activeDraftPath = undefined
liveAnnotations = {
scriptPath: undefined,
annotations: {
isMaterializer: false,
triggerAssets: [],
schedules: [],
nativeTriggers: []
}
}
}}
onDiscard={() => {
if (activeDraftPath) discardDraft(activeDraftPath)
}}
onDraftSaved={async (savedPath) => {
discardDraft(savedPath)
await graphRes.refetch()
}}
/>
</Pane>
{/if}
</Splitpanes>
{/if}
</div>
</div>
<PipelinePickerModal bind:open={pickerModalOpen} currentFolder={folder} />
{/if}