Files
windmill/frontend/src/lib/components/ScriptBuilder.svelte
T
Guilhem 616d4fe167 fix: edit-in-dev-workspace dead-ends, wraps, and misses the tree view (#10354)
* fix: stop the homepage edit-in-fork button from wrapping

* fix: show the full edit-in-fork label anywhere on the button

* fix: thread showEditButton through the homepage tree view

* fix: match the edit-in-fork button styling to the normal edit button

* fix: edit in dev workspace dead-ends on items the dev workspace lacks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: pull the item's folder before copying it into the dev workspace

* fix: speak the compare page's update vocabulary in the dev-workspace prompt

* fix: raw app with no stylesheet was undeployable across workspaces

* fix: drop the raw-app stylesheet workaround now that the backend serves one

The frontend wrapped `getRawAppData` to report a missing `.css` as empty,
because a raw app with no stylesheet stores no css blob and the shared deploy
treats the resulting 404 as fatal. #10364 fixed that at the source: the backend
now serves an empty body for a missing stylesheet, so the wrapper guards a 404
that no longer happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop the fork icon from the edit-in-dev-workspace affordances

The row button carried both a pen and a fork, and the menu entries and detail
page buttons carried a fork alone — where the menus already used that same icon
for Duplicate/Fork, so the two entries were indistinguishable. The action is an
edit, so it takes the pen everywhere, matching the ordinary Edit button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: send edit in dev workspace to the item's editor

The affordance landed on the item's page in the dev workspace and left the user
to open the editor from there. It says "Edit", so it goes to the editor:
`/scripts/edit/...?workspace=<dev>` and the equivalent for flows and both app
kinds. `?workspace=` still does the workspace switch, which the logged layout
applies on any route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: choose the on-behalf-of user when updating the dev workspace

The prompt deployed the item with no say over the identity it would run under,
so an item that ran on behalf of someone in prod silently became the deploying
user's in the dev workspace. It now offers the same choice the compare page
does, under the same rules: shown only when the source item carries an
on_behalf_of, picking anyone but yourself gated on admin/wm_deployers in the
target, and confirming blocked until a choice is made — including while the
lookup that decides whether one is needed is still in flight.

The prompt also stops offering the compare page inline; the confirm button
still leads there when the user can't deploy into the dev workspace.

Two fixes the reused selector needed to work inside a dialog:
- ConfirmationModal takes `confirmDisabled`, which also blocks the Enter binding.
- The popover's z-index is now overridable, and the user picker is portalled.
  A ConfirmationModal renders above the popover layer, and its card is
  transformed for the open transition, which makes it the containing block for
  the picker's `fixed` positioning — so both opened behind, and the picker was
  laid out inside the card instead of the viewport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: check deploy rights per item before prompting to update the dev workspace

* fix: read the compare page link before closing the dev-workspace prompt

The link is derived from the request the prompt is answering, so closing first
left an empty string to navigate to: refusing users saw the dialog dismiss and
stay put, with no way through to the compare page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the new-tab promise and speak up when a popup is blocked

Three defects found by successive cold reviews of the click-time resolution
added earlier in this branch, each one only reachable once the previous fix
existed:

- Safari refuses `window.open` from any promise continuation however fast it
  resolves, so the tab the editor dropdown opens after its existence probe
  never appeared there. `claimTab` takes the tab inside the click's own
  transient activation and points it at the answer once it lands, releasing it
  when there is nothing to show.
- That left the two halves of the same action disagreeing: the entry promises
  never to navigate the editor away, but when the item turned out to be missing
  the prompt took over and navigated in place. The request now carries
  `openInNewTab`, and every destination the prompt can reach honours it.
- With popups blocked the fallback called `window.open` without checking, so a
  successful deploy closed the prompt and did nothing, silently. It now names
  what it could not open.

`openEditInFork` also takes the workspace explicitly. The four editor dropdowns
computed their label from `opWorkspace` but resolved the action from the
navigation store, and `prodWorkspaceId` feeds `deployItem({ workspaceFrom })` —
so a session pane would have deployed from the wrong workspace.

`checkPathWritePermission` is exported with an injectable folder probe and
covered by table-driven cases, chiefly to pin its two fail-open branches, which
otherwise read as dead code inviting deletion.

The two unrelated whitespace hunks in ScriptBuilder.svelte are the repo's
format-on-save hook fixing pre-existing violations in a file this touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: create the dev workspace's missing folder without overwriting it

`ensureFolder` delegated to the shared `deployItem`, which re-probes and
switches to `updateFolder` when the folder turns out to exist. Nobody asked for
that folder to be deployed — it is created only so the item has somewhere to
land — so a folder created between the two probes had its owners, ACL, summary
and labels silently replaced with the source workspace's. Creating is now
create-only, and losing that race counts as success: the folder exists, which is
all the caller needed.

The same delegation dropped `default_permissioned_as` and `labels`, which the
shared folder deploy does not send. A folder copied without its create-time
identity rules applies none, so an item landing inside it with no on_behalf_of
of its own resolves to whoever deployed it rather than to the principal the
source folder would have chosen — the exact substitution the rest of this branch
exists to prevent. Both are now carried across.

Also check `window.open` in the no-dev-workspace branch of `openEditInFork`. The
branch beneath it already reported a blocked popup; this one returned as if it
had opened something.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: translate copied folder identity rules into the target workspace

A `u/<username>` names a workspace-local account, so copying a folder's
`default_permissioned_as` verbatim was wrong in two directions: the same
username in the dev workspace can be a different person, who would then be
granted the item; and a username with no account there at all passes the
folder-create check, which is structural, only to fail every subsequent item
deploy on the existence check, including the retry — the folder now exists, so
`ensureFolder` short-circuits and the deploy fails identically, with no way out
of the prompt.

Rules are now resolved source username -> email -> target username, since email
is the only identifier stable across workspaces, and a rule whose principal has
no account in the target is dropped rather than carried. Dropping one makes the
copied folder less restrictive than its source, which is not something to
discover later from an item running as the wrong user, so it is reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse to overwrite a concurrent item, and translate every folder principal

Four findings from CI review, all on the implicit half of this flow — the writes
the user did not explicitly ask for.

The item write is now create-only. The shared `deployItem` re-probes and silently
switches to an update, so the caller that acts on an item being *absent* could
still overwrite whoever landed it between the two probes. Rather than
reimplementing the per-kind deploys, the frontend's own provider refuses exactly
the three writes that branch reaches for — `updateFlow`, `updateApp`/
`updateAppRaw`, and a `createScript` carrying a `parent_hash`, which is what
makes an otherwise identical create an update. A refusal reports `conflict`, and
the prompt opens their version instead of replacing it.

Folder principals are translated rather than copied. `u/<username>` is
workspace-local, so a verbatim copy either names nobody or names a different
account that happens to share the username. Users now resolve source username ->
email -> target username, and the two kinds of unresolvable principal are
separated because they fail differently: an owner or ACL entry is dropped, which
can only narrow the folder and leaves the creator owning it; an identity rule
refuses the copy outright, because dropping it runs the item as the deployer and
keeping it creates a folder the server then rejects every deploy into.

Groups resolve against `listGroups` rather than `listGroupNames`, which unions in
instance groups that folder rules do not resolve against — a same-named instance
group would otherwise let an unusable rule through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: read every page of workspace groups before judging a folder principal

`listGroups` paginates, and the `perPage: 100` it was called with is narrower
than the server's own default of 1000 — so a group past the first page read as
having no account in the target. Since an unresolvable identity rule now refuses
the whole folder copy, that turned into a refusal naming a group that does
exist, and an owner or ACL entry on a later page was dropped silently. Read
until a page comes back short, with a size check as the backstop for a server
that ignores `page`.

`list_users` is unpaginated, so the user half of the same lookup was never
affected.

Also move `makeProvider`'s doc block back onto `makeProvider`; adding
`DeployConflict` had left it documenting the type instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: reattach principalTranslator's doc block to principalTranslator

Adding `workspaceGroupNames` above it left the block documenting the helper,
the same way adding `DeployConflict` had displaced `makeProvider`'s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:21:14 +02:00

2340 lines
79 KiB
Svelte

<script lang="ts">
import { createBubbler } from 'svelte/legacy'
const bubble = createBubbler()
import {
ScriptService,
type Script,
type NewScript,
type TriggersCount,
PostgresTriggerService,
CaptureService,
type ScriptLang,
WorkerService
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import {
initialCode,
canHavePreprocessor,
getPreprocessorFullCode,
getMainFunctionPattern
} from '$lib/script_helpers'
import { isWorkflowAsCode } from './graph/wacToFlow'
import AIFormSettings from './copilot/AIFormSettings.svelte'
import {
defaultScripts,
enterpriseLicense,
usedTriggerKinds,
userStore,
userWorkspaces,
workerTags,
workspaceStore
} from '$lib/stores'
import {
emptySchema,
emptyString,
generateRandomString,
orderedJsonStringify,
readFieldsRecursively,
replaceFalseWithUndefined,
type Value
} from '$lib/utils'
import Path from './Path.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import { notifyContractWarnings } from './assets/AssetGraph/schemaContracts'
import ScriptEditor from './ScriptEditor.svelte'
import DbtEditor from './dbt/DbtEditor.svelte'
import { findModulePathClash } from './scriptModulePath'
import { Alert, Button, Drawer, SecondsInput, Tab, TabContent, Tabs } from './common'
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
import type { SupportedLanguage, Schema } from '$lib/common'
import Tooltip from './Tooltip.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ErrorHandlerToggleButton from '$lib/components/details/ErrorHandlerToggleButton.svelte'
import {
Bug,
CheckCircle,
Code,
DiffIcon,
EllipsisVertical,
Network,
Plus,
Rocket,
Settings,
Shuffle,
Tag,
X
} from 'lucide-svelte'
import { base } from '$lib/base'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { parsePipelineAnnotations } from './assets/AssetGraph/parsePipelineAnnotations'
import DropdownV2 from './DropdownV2.svelte'
import { getLocalSetting, storeLocalSetting, type Item } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { isCloudHosted } from '$lib/cloud'
import Awareness from './Awareness.svelte'
import { fade } from 'svelte/transition'
import Popover from './Popover.svelte'
import Toggle from './Toggle.svelte'
import ScriptSchema from './ScriptSchema.svelte'
import Section from './Section.svelte'
import Label from './Label.svelte'
import type Editor from './Editor.svelte'
import WorkerTagPicker from './WorkerTagPicker.svelte'
import MetadataGen from './copilot/MetadataGen.svelte'
import { writable } from 'svelte/store'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import DefaultScripts from './DefaultScripts.svelte'
import { getContext, onMount, setContext, tick, untrack } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
import Badge from './common/badge/Badge.svelte'
import Modal from './common/modal/Modal.svelte'
import AutosaveIndicator from './AutosaveIndicator.svelte'
import LabelsInput from './LabelsInput.svelte'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import TriggersEditor from './triggers/TriggersEditor.svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
import CaptureTable from './triggers/CaptureTable.svelte'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import { type Trigger, deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import type { ScriptBuilderProps } from './script_builder'
import WorkerTagSelect from './WorkerTagSelect.svelte'
import type { ButtonType } from './common/button/model'
import DebounceLimit from './flows/DebounceLimit.svelte'
import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork'
import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte'
import WacExportDrawer from './scripts/WacExportDrawer.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
let {
script = $bindable(),
fullyLoaded = true,
initialPath = $bindable(''),
userDraftPath = '',
autosaveWorkspace = undefined,
autosavePath = undefined,
template = $bindable('script'),
initialArgs = {},
lockedLanguage = false,
showMeta = false,
neverShowMeta = false,
diffDrawer = undefined,
savedScript = $bindable(undefined),
searchParams = new URLSearchParams(),
disableHistoryChange = false,
customUi = {},
savedPrimarySchedule = undefined,
functionExports = undefined,
children,
onDeploy,
onDeployError,
onSeeDetails,
onNavigate,
onTestJob,
disableAi,
testPanelCollapsed = false,
initialPathChosen = false,
onResetToDeployed,
loadedFromDraft = false,
othersDraftsCount = 0,
onOpenOthersDrafts,
condensedHeader = false
}: ScriptBuilderProps = $props()
// Top-bar button size + bar height. Condensed (session preview) uses the
// smallest well-supported unified size (`sm`) so the bar is thinner.
const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md')
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
savedValue: savedScript,
modifiedValue: {
...script,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
}
}
// used for new scripts for captures
const fakeInitialPath =
'u/' +
($userStore?.username?.includes('@')
? $userStore?.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')
: $userStore?.username) +
'/' +
generateRandomString(12)
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
let deployedBy: string | undefined = $state(undefined) // Author
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
// Top-bar responsive collapse — container width, not viewport. Collapse the
// right group (hide the ~200px tag select, icon-only Diff/Settings) before the
// full group crowds the path into heavy truncation; ~900 is where the path
// keeps a usable width given the right group's natural ~440px.
let topbarWidth = $state(0)
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 900)
// The workspace this editor operates on: deploy, save-draft, trigger loading
// and the AutosaveIndicator all target it. Falls back to the full-page
// editor's global store; the sessions preview overrides it to the session's
// (forked) workspace, so an embedded editor acts on the session's fork rather
// than the navigation workspace ($workspaceStore, which stays put). indicatorPath
// is the matching draft path (URL path full-page, session target in preview).
const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
const indicatorPath = $derived(autosavePath ?? userDraftPath)
// The shared `workerTags` store caches tags for the navigation workspace. A
// session editor deploys to `opWorkspace` (a fork), so it keeps a local list to
// gate/populate the tag picker without reading or clobbering the shared cache.
const usesLocalTags = $derived(opWorkspace != undefined && opWorkspace !== $workspaceStore)
let localWorkerTags = $state<string[] | undefined>(undefined)
const scriptWorkerTags = $derived(usesLocalTags ? localWorkerTags : $workerTags)
function getCompactMenuItems(): Item[] {
const hasTags = (scriptWorkerTags?.length ?? 0) > 0
return [
...(customUi?.topBar?.tagEdit != false && hasTags
? [
{
displayName: 'Worker tag',
icon: Tag,
action: () => {
selectedTab = 'runtime'
metadataOpen = true
}
}
]
: [])
]
}
let open: boolean = $state(false) // Is confirmation modal open
let args: Record<string, any> = $state(untrack(() => initialArgs)) // Test args input
let selectedInputTab: 'main' | 'preprocessor' | 'diagram' = $state('main')
let hasPreprocessor = $state(false)
let preserveOnBehalfOf = $state(false)
const WM_DEPLOYERS_GROUP = 'wm_deployers'
let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
let canPreserve = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer)
let originalOnBehalfOfEmail = $derived(savedScript?.on_behalf_of_email)
let originalOnBehalfOfPermissionedAs = $derived(savedScript?.on_behalf_of)
let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined)
let customOnBehalfOfEmail: string = $state('')
let myPermissionedAs = $derived($userStore?.username ? `u/${$userStore.username}` : undefined)
let metadataOpen = $state(
!untrack(() => neverShowMeta) &&
(untrack(() => showMeta) ||
untrack(() => searchParams).get('metadata_open') == 'true' ||
(initialPath == '' &&
untrack(() => searchParams).get('state') == undefined &&
untrack(() => searchParams).get('collab') == undefined))
)
let editor: Editor | undefined = $state(undefined)
// A dbt script gets its own editor: the artifact is a project bundle — a file
// tree, a descriptor, run arguments and a model graph — not a body of code.
// Both answer to the same handful of calls below, so everything around them
// is unchanged.
let scriptEditor: ScriptEditor | DbtEditor | undefined = $state(undefined)
let isDbt = $derived(script.language === 'dbt')
// dbt hides Generated UI, but the initialiser picks a tab before the language is
// known, so `ui` can be selected. Fall back only to an ENABLED tab — the other
// TabContents are not gated on their disable flags — and otherwise stay on `ui`,
// whose content IS gated for dbt, so nothing renders rather than something hidden.
$effect(() => {
if (isDbt && selectedTab === 'ui') {
const first = (
[
['metadata', customUi?.settingsPanel?.disableMetadata],
['runtime', customUi?.settingsPanel?.disableRuntime],
['triggers', customUi?.settingsPanel?.disableTriggers]
] as const
).find(([, disabled]) => disabled !== true)?.[0]
if (first) selectedTab = first
}
})
// The version whose stored graph the dbt editor draws until a refresh replaces
// it. A script never deployed has none, and `NewScript` carries no hash.
let deployedScriptHash = $derived(
savedScript && 'hash' in savedScript ? (savedScript.hash as string) : undefined
)
let captureTable: CaptureTable | undefined = $state(undefined)
let wacExportDrawer: WacExportDrawer | undefined = $state(undefined)
// Draft triggers confirmation modal
let draftTriggersModalOpen = $state(false)
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
const { selectedTriggers } = event.detail
// Continue with saving the flow
draftTriggersModalOpen = false
confirmDeploymentCallback(selectedTriggers)
}
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(
untrack(() => savedPrimarySchedule)
) // keep for legacy
const triggersCount = writable<TriggersCount | undefined>(
untrack(() => savedPrimarySchedule)
? {
schedule_count: 1,
primary_schedule: { schedule: untrack(() => savedPrimarySchedule)!.cron }
}
: undefined
)
const simplifiedPoll = writable(false)
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
loadTriggers()
}
export function setDraftTriggers(triggers: Trigger[] | undefined) {
triggersState.setTriggers([
...(triggers ?? []),
...triggersState.triggers.filter((t) => !t.draftConfig)
])
loadTriggers()
}
onMount(() => {
if (functionExports) {
console.log('functionExports set')
functionExports({
setPreviewArgs: (args: Record<string, any>) => {
scriptEditor?.setArgs(args)
},
runPreview: async () => await scriptEditor?.runTest(),
setCode: (code: string, language?: Script['language']) => {
if (language) {
script.language = language
}
editor?.setCode(code)
},
getCode: () => editor?.getCode() ?? ''
})
}
})
async function loadTriggers() {
if (!initialPath) {
return
}
$triggersCount = await ScriptService.getTriggersCountOfScript({
workspace: opWorkspace!,
path: initialPath
})
await triggersState.fetchTriggers(
triggersCount,
opWorkspace,
initialPath,
false,
$primaryScheduleStore,
$userStore
)
}
// Add triggers context store
const triggersState = $state(
new Triggers([
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(script.draft_triggers ?? [])
])
)
const captureOn = writable<boolean | undefined>(undefined)
const showCaptureHint = writable<boolean | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
triggersCount,
simplifiedPoll,
showCaptureHint: showCaptureHint,
triggersState
})
const enterpriseLangs = ['mssql', 'oracledb']
// Languages the pipeline editor treats as warehouse/dataset transforms —
// the ones where a `-- pipeline` annotation is a natural next step.
const pipelineHintLangs = ['duckdb', 'postgresql', 'bigquery', 'snowflake', 'mysql', 'mssql']
const pipelineHintDismissed = useLocalStorageValue(
'pipelineScriptHintDismissed',
false,
'boolean'
)
let showPipelineHint = $derived(
!pipelineHintDismissed.val &&
(script.kind === 'script' || script.kind === undefined) &&
pipelineHintLangs.includes(script.language ?? '') &&
!parsePipelineAnnotations(script.content ?? '').inPipeline
)
export function setCode(code: string): void {
editor?.setCode(code)
}
const scriptKindOptions: {
value: Script['kind']
title: string
Icon: any
desc?: string
documentationLink?: string
}[] = [
{
value: 'script',
title: 'Action',
Icon: Code
},
{
value: 'trigger',
title: 'Trigger',
desc: 'First module of flows to trigger them based on external changes. These kind of scripts are usually running on a schedule to periodically look for changes.',
documentationLink: 'https://www.windmill.dev/docs/flows/flow_trigger',
Icon: Rocket
},
{
value: 'approval',
title: 'Approval',
desc: 'Send notifications externally to ask for approval to continue a flow.',
documentationLink: 'https://www.windmill.dev/docs/flows/flow_approval',
Icon: CheckCircle
},
{
value: 'failure',
title: 'Error Handler',
desc: 'Handle errors in flows after all retry attempts have been exhausted.',
documentationLink: 'https://www.windmill.dev/docs/flows/flow_error_handler',
Icon: Bug
},
{
value: 'preprocessor',
title: 'Preprocessor',
desc: 'Transform incoming requests before they are passed to the main entrypoint.',
documentationLink: 'https://www.windmill.dev/docs/core_concepts/preprocessors',
Icon: Shuffle
}
]
let pathError = $state('')
let loadingSave = $state(false)
// Lifts the route's `?new_draft=true` `stopSync` suspension, but only after the
// stores-gated bind:path cascade (and, for an empty seed, `initContent` via
// `markContentReady`) settles — resuming earlier posts the seed/auto-generated
// path as the user's first "edit". `restarted` keeps re-entry idempotent.
function scheduleRestartSync(
path: string,
opts?: { waitForContent?: boolean }
): { markContentReady: () => void } {
let contentReady = !opts?.waitForContent
let storesReady = !!($userStore && $workspaceStore)
let restarted = false
async function tryRestart() {
if (restarted || !contentReady || !storesReady) return
// 500ms covers the bind:path cascade even on cold reload; two ticks
// weren't enough (bind:path fired ~100ms after restart, posting an edit).
await new Promise((r) => setTimeout(r, 500))
if (restarted) return
restarted = true
UserDraft.restartSync('script', path)
}
if (!storesReady) {
$effect(() => {
if ($userStore && $workspaceStore) {
storesReady = true
untrack(() => void tryRestart())
}
})
}
void tryRestart()
return {
markContentReady() {
contentReady = true
void tryRestart()
}
}
}
if (script.content == '') {
// Suspend autosave around the bootstrap mutations: seeding the template
// content is a programmatic write, not the user's first edit. The handle
// keys on `userDraftPath` (URL path), not the editor-displayed `initialPath`.
UserDraft.stopSync('script', userDraftPath)
if (template === 'wac_python') {
script.modules = {
'helper.py': {
content: 'def main(a: str) -> str:\n return f"hello {a}"\n',
language: 'python3'
}
}
} else if (template === 'wac_typescript') {
script.modules = {
'helper.ts': {
content: 'export function main(a: string): string {\n return `hello ${a}`\n}\n',
language: 'bun'
}
}
} else if (script.language === 'dbt') {
seedDbtProject()
}
const restarter = scheduleRestartSync(userDraftPath, { waitForContent: true })
initContent(script.language, script.kind, template).finally(() => restarter.markContentReady())
} else if (userDraftPath && untrack(() => searchParams).get('new_draft') == 'true') {
// Pre-filled new-draft seed (fork "Copy of X", hub fork, URL/YAML import): no
// template to seed, but the route still suspended autosave — lift it or the
// draft never persists (autosave stays dead for the session).
scheduleRestartSync(userDraftPath)
}
async function isTemplateScript() {
const params = new URLSearchParams(window.location.search)
const templateId = params.get('id')
if (templateId === null) {
return undefined
}
try {
const templateScript = await PostgresTriggerService.getTemplateScript({
workspace: opWorkspace!,
id: templateId
})
return templateScript
} catch (error) {
sendUserToast(
'An error occurred when trying to load your template script, please try again later',
true
)
}
}
async function initContent(
language: SupportedLanguage,
kind: Script['kind'] | undefined,
template:
| 'pgsql'
| 'mysql'
| 'script'
| 'docker'
| 'powershell'
| 'bunnative'
| 'claudesandbox'
| 'wac_python'
| 'wac_typescript'
| 'ci_test_bun'
| 'ci_test_python'
) {
scriptEditor?.disableCollaboration()
// Seed synchronously so a Deploy before the async template fetch resolves
// doesn't run `inferArgs` on empty content and toast "Could not parse code".
script.content = initialCode(language, kind, template, false)
const templateScript = await isTemplateScript()
if (templateScript) {
script.content = initialCode(language, kind, template, true)
}
if (templateScript) {
script.content += '\r\n' + templateScript
}
scriptEditor?.inferSchema(script.content, { nlang: language, resetArgs: true })
if (script.content != editor?.getCode()) {
setCode(script.content)
}
}
async function handleEditScript(stay: boolean, deployMsg?: string): Promise<void> {
scriptEditor?.flushModuleState()
// Fetch latest version and fetch entire script after if needed
let actual_parent_hash: string | undefined = undefined
try {
if (initialPath && initialPath != '') {
actual_parent_hash = (
await ScriptService.getScriptLatestVersion({
workspace: opWorkspace!,
path: initialPath
})
)?.script_hash
}
} catch (error) {
//
}
// Usually when we create new script, we put current hash as a parent_hash
// But if we specify parent_hash that is already used, than we get error
// In order to fix it we make sure that client's understanding of parent_hash
// is aligns with understanding of backend.
if (actual_parent_hash == undefined || script.parent_hash == actual_parent_hash) {
// Handle directly
await editScript(stay, script.parent_hash!, deployMsg)
} else {
// Fetch entire script, since we need it to show Diff
await syncWithDeployed()
if (
deployedValue &&
script &&
orderedJsonStringify({ ...deployedValue, hash: undefined }) ===
orderedJsonStringify(
replaceFalseWithUndefined({ ...script, hash: undefined, parent_hash: undefined })
)
) {
await editScript(stay, actual_parent_hash, deployMsg)
} else {
// Handle through confirmation modal
confirmCallback = async () => {
open = false
if (actual_parent_hash) {
await editScript(stay, actual_parent_hash, deployMsg)
} else {
sendUserToast('Could not fetch latest version of the script', true)
}
}
// Open confirmation modal
open = true
}
}
}
async function syncWithDeployed() {
const latestScript = await ScriptService.getScriptByPath({
workspace: opWorkspace!,
path: initialPath,
withStarredInfo: true
})
deployedValue = replaceFalseWithUndefined({
...latestScript,
workspace_id: undefined,
created_at: undefined,
created_by: undefined,
extra_perms: undefined,
lock: undefined,
lock_error_logs: undefined,
parent_hashes: undefined
})
deployedBy = latestScript.created_by
}
async function editScript(
stay: boolean,
parentHash: string,
deploymentMsg?: string,
triggersToDeploy?: Trigger[]
): Promise<void> {
if (!triggersToDeploy) {
// Check if there are draft triggers that need confirmation
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
if (draftTriggers.length > 0) {
draftTriggersModalOpen = true
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
await editScript(stay, parentHash, deploymentMsg, triggersToDeploy)
}
return
}
}
loadingSave = true
try {
// Legacy drafts can carry `schema: {}` (no `properties`), which trips
// `inferArgs` on `JSON.stringify(schema.properties)` and toasts "Could
// not parse code". Backfill an empty schema so it parses.
if (!script.schema || !(script.schema as any).properties) {
script.schema = emptySchema()
}
try {
const result = await inferArgs(
script.language,
script.content,
script.schema as any,
script.kind === 'preprocessor' ? 'preprocessor' : undefined
)
if (script.kind === 'preprocessor') {
script.auto_kind = undefined
script.has_preprocessor = undefined
} else {
script.auto_kind = result?.auto_kind || undefined
script.has_preprocessor = result?.has_preprocessor || undefined
}
} catch (error) {
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
}
const newHash = await ScriptService.createScript({
workspace: opWorkspace!,
requestBody: {
path: script.path,
summary: script.summary,
description: script.description ?? '',
content: script.content,
parent_hash: parentHash,
schema: script.schema,
is_template: script.is_template,
language: script.language,
kind: script.kind,
tag: script.tag,
envs: script.envs,
dedicated_worker: script.dedicated_worker,
concurrent_limit: script.concurrent_limit,
concurrency_time_window_s: script.concurrency_time_window_s,
debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key,
debounce_delay_s: script.debounce_delay_s,
debounce_args_to_accumulate:
script.debounce_args_to_accumulate && script.debounce_args_to_accumulate.length > 0
? script.debounce_args_to_accumulate
: undefined,
max_total_debouncing_time: script.max_total_debouncing_time,
max_total_debounces_amount: script.max_total_debounces_amount,
cache_ttl: script.cache_ttl,
cache_ignore_s3_path: script.cache_ignore_s3_path,
ws_error_handler_muted: script.ws_error_handler_muted,
priority: script.priority,
restart_unless_cancelled: script.restart_unless_cancelled,
timeout: script.timeout,
concurrency_key: emptyString(script.concurrency_key) ? undefined : script.concurrency_key,
visible_to_runner_only: script.visible_to_runner_only,
auto_kind: script.auto_kind,
has_preprocessor: script.has_preprocessor,
deployment_message: deploymentMsg || undefined,
on_behalf_of_email: script.on_behalf_of_email,
on_behalf_of: script.on_behalf_of,
preserve_on_behalf_of: preserveOnBehalfOf || undefined,
assets: script.assets,
modules: script.modules,
labels: script.labels
}
})
// New/updated path now exists server-side — drop the autocomplete
// cache so it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(opWorkspace!)
// Authoritative save-time schema-contract check (pipelines gap #2b):
// warn-only, post-commit so a self-produced target resolves to the
// content just deployed. Fire-and-forget — must never gate the deploy.
notifyContractWarnings(opWorkspace!, script.language, script.content)
if (!initialPath) {
await CaptureService.moveCapturesAndConfigs({
workspace: opWorkspace!,
path: fakeInitialPath,
requestBody: {
new_path: script.path
},
runnableKind: 'script'
})
}
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
opWorkspace,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
script.path,
true
)
}
const { draft_triggers: _, ...newScript } = structuredClone($state.snapshot(script))
savedScript = structuredClone($state.snapshot(newScript))
setDraftTriggers([])
if (!disableHistoryChange) {
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
}
// "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the
// editor in place rather than navigating to the deployed item.
const stayHere =
stay ||
(script.auto_kind === 'lib' &&
script.kind !== 'preprocessor' &&
!isWorkflowAsCode(script.content, script.language))
if (stayHere) {
// Re-pin parent_hash so the next deploy's conflict check is against
// the version we just wrote.
script.parent_hash = newHash
}
// Always notify on a successful deploy; the consumer decides whether to
// navigate (route) or stay + sync the preview (session). Previously the
// stay/lib branch skipped onDeploy, so session previews didn't sync after
// a "Deploy & Stay here" or lib-script deploy.
onDeploy?.({ path: script.path, hash: newHash, stay: stayHere })
} catch (error) {
onDeployError?.({ path: script.path, error })
sendUserToast(`Error while saving the script: ${error.body || error.message}`, true)
}
loadingSave = false
}
// Ctrl/Cmd+S forces an immediate flush of the pending autosave. Flush Monaco
// + `tick()` first so the last keystrokes reach the bindable before the
// syncer flushes. No toast — the AutosaveIndicator narrates the result, and
// `flush` never rejects (postSave routes errors to the failures map).
async function saveDraft(): Promise<void> {
if (!opWorkspace || !userDraftPath) return
editor?.flushPendingChanges()
await tick()
await UserDraftDbSyncer.flush({
workspace: opWorkspace,
itemKind: 'script',
path: userDraftPath
})
}
// Materialize a brand-new script's draft before the session preview loads it by
// path — an untouched new script never autosaved, so forcePersist is the only
// thing that creates the row. Gated to never-deployed: forcePersist skips the
// discardIf baseline, safe only when there is none.
async function persistDraftForSession(): Promise<void> {
await saveDraft()
if (opWorkspace && userDraftPath && savedScript?.no_deployed === true) {
await UserDraft.forcePersist('script', userDraftPath, { workspace: opWorkspace })
}
}
// Inside an AI session pane (which injects an aiChatManager via context) the
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
// fork, Exit & See details, Export — don't make sense: the session always
// stays put and is already scoped to a fork. Diff is exposed as a standalone
// top-bar button (rendered independently of the session pane), not here.
const inSessionPane = !!getContext('aiChatManager')
async function openDiffDrawer() {
if (!savedScript) {
return
}
await syncWithDeployed()
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const deployed = deployedValue ?? savedScript
// `script` comes from the full DB row (since #9351), so it carries `lock`/`extra_perms`
// while the deployed side is trimmed in `syncWithDeployed` — null them here to match
// that strip. They stay in the shared `cleanValueProperties` so version-to-version and
// folder workspace/fork diffs still surface lockfile / sharing-permission changes.
const current = {
...script,
lock: undefined,
extra_perms: undefined,
draft_triggers: currentDraftTriggers
}
if (current.assets && !current.assets.length) delete current.assets
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed,
draft: savedScript['draft'],
current
})
}
function computeDropdownItems(
initialPath: string,
savedScript: ((Script | NewScript) & { no_deployed?: boolean }) | undefined
) {
let dropdownItems: { label: string; onClick: () => void }[] =
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
? [
...(!inSessionPane
? [
{
label: 'Deploy & Stay here',
onClick: () => {
handleEditScript(true)
}
},
{
label: 'Fork',
onClick: () => {
window.open(`/scripts/add?template=${initialPath}`)
}
},
...(!isCloudHosted() && editInForkAllowed(opWorkspace, $userWorkspaces)
? [
{
label: editInForkLabel(opWorkspace, $userWorkspaces),
onClick: () => {
openEditInFork('script', initialPath, opWorkspace)
}
}
]
: [])
]
: []),
...(!inSessionPane &&
savedScript?.no_deployed !== true &&
script.kind === 'script' &&
!script.auto_kind
? [
{
label: 'Exit & See details',
onClick: () => {
onSeeDetails?.({ path: initialPath })
}
}
]
: []),
...(!inSessionPane && isWorkflowAsCode(script.content, script.language)
? [
{
label: 'Export as YAML/JSON',
onClick: () => {
wacExportDrawer?.open(script)
}
}
]
: [])
]
: []
if (
!inSessionPane &&
dropdownItems.length === 0 &&
isWorkflowAsCode(script.content, script.language)
) {
dropdownItems = [
{
label: 'Export as YAML/JSON',
onClick: () => {
wacExportDrawer?.open(script)
}
}
]
}
return dropdownItems.length > 0 ? dropdownItems : undefined
}
function onKeyDown(event: KeyboardEvent) {
// Lowercased so Caps Lock (which yields `S`) still saves. Shift excluded:
// Ctrl+Shift+S must reach the browser/OS.
switch (event.key.length === 1 ? event.key.toLowerCase() : event.key) {
case 's':
if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
saveDraft()
event.preventDefault()
}
break
}
}
// Monaco swallows the keydown, so a code editor with focus never reaches the
// window handler above; Editor/SimpleEditor/TemplateEditor re-broadcast it
// (untyped event, hence the manual listener).
$effect(() => {
window.addEventListener('wm-monaco-save-shortcut', saveDraft)
return () => window.removeEventListener('wm-monaco-save-shortcut', saveDraft)
})
let path: Path | undefined = $state(undefined)
// Seed "path is already chosen" so the summary→path auto-slug (which only
// runs for new scripts with initialPath == '') doesn't clobber a path the
// caller pre-assigned. The session preview opens AI-created scripts as new
// (empty initialPath) but with a path the AI already picked.
let dirtyPath = $state(initialPathChosen)
let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state(
(() => {
if (customUi?.settingsPanel?.disableMetadata !== true) {
// first option: either no custom UI or metadata is enabled
return 'metadata'
}
if (customUi?.settingsPanel?.disableRuntime !== true) {
return 'runtime'
}
if (customUi?.settingsPanel?.disableGeneratedUi !== true) {
return 'ui'
}
if (customUi?.settingsPanel?.disableTriggers !== true) {
return 'triggers'
}
return 'metadata'
})()
)
setContext('disableTooltips', untrack(() => customUi)?.disableTooltips === true)
function langToLanguage(lang: SupportedLanguage | 'docker' | 'bunnative'): SupportedLanguage {
if (lang == 'docker') {
return 'bash'
}
if (lang == 'bunnative') {
return 'bun'
}
return lang
}
async function applyArgs(e) {
selectedInputTab = e.detail.kind
metadataOpen = false
if (scriptEditor) {
scriptEditor.updateArgs(e.detail.args ?? {})
}
}
function openTriggers(ev) {
metadataOpen = true
selectedTab = 'triggers'
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, ev.detail.kind)
captureOn.set(true)
}
function addPreprocessor(e?: { detail?: { args: Record<string, any> } }) {
const code = editor?.getCode()
if (code) {
const preprocessorCode = getPreprocessorFullCode(script.language, false)
const mainPattern = getMainFunctionPattern(script.language)
const mainIndex = code.indexOf(mainPattern)
if (mainIndex === -1) {
editor?.setCode(code + preprocessorCode)
} else {
editor?.setCode(
code.slice(0, mainIndex) + preprocessorCode + '\n' + code.slice(mainIndex) + '\n\n'
)
}
}
selectedInputTab = 'preprocessor'
// Apply provided args to the preprocessor
if (e?.detail?.args && Object.keys(e.detail.args).length > 0) {
args = { ...args, ...e.detail.args }
}
}
function handleDeployTrigger(_trigger: Trigger) {}
// A dbt script's modules ARE its dbt project, and the runtime refuses one
// without a `dbt_project.yml`. Seeded from BOTH entry points — the empty-script
// bootstrap and the language picker — because reaching dbt by switching an
// existing draft otherwise produces a script that cannot deploy or run.
// Existing modules are left alone: switching away and back must not discard a
// project the user has already grown.
function seedDbtProject() {
// Keyed on the project file rather than on "has any modules at all": a
// draft that grew modules under another language carries none of what dbt
// needs, and the worker refuses a bundle with no `dbt_project.yml` — so
// that draft reached dbt in a state it could neither deploy nor run.
// Matched on the canonical path: a bundle pushed with `./dbt_project.yml`
// already has the project file, and seeding a second spelling of it is the
// two-keys-one-file collision the editor's add-file checks refuse.
if (findModulePathClash(script.modules, 'dbt_project.yml')) return
script.modules = {
'dbt_project.yml': {
content:
'name: my_dbt_project\nversion: "1.0"\nprofile: my_dbt_project\nmodels:\n my_dbt_project:\n +materialized: view\n',
language: 'dbt'
},
'models/example.sql': {
content: 'select 1 as id\n',
language: 'dbt'
},
// Last, so anything already written wins: the previous language's
// helper files are inert to dbt and are the user's to remove.
...(script.modules ?? {})
}
}
/// Shown once, the first time dbt is chosen: the moment of choosing is when
/// "this is alpha and its details will move" is worth knowing, rather than
/// after a project has been built on it.
const DBT_ALPHA_SEEN = 'dbt_alpha_ack'
let dbtAlphaOpen = $state(false)
function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) {
if (lang === 'dbt' && getLocalSetting(DBT_ALPHA_SEEN) !== 'true') {
dbtAlphaOpen = true
storeLocalSetting(DBT_ALPHA_SEEN, 'true')
}
if (lang == 'docker') {
template = 'docker'
} else if (lang == 'bunnative') {
template = 'bunnative'
} else {
template = 'script'
}
let language = langToLanguage(lang)
if (language === 'dbt') {
// A project bundle only ever runs as an action, and the selector that
// would set this back is hidden for dbt — so a script arriving here as a
// trigger or approval would keep a kind it cannot fill and drop out of
// the action pickers, with nothing on screen to repair it.
script.kind = 'script'
}
//
initContent(language, script.kind, template)
script.language = language
if (language === 'dbt') {
seedDbtProject()
}
}
function onSummaryChange(value: string) {
if (initialPath == '' && value?.length > 0 && !dirtyPath) {
path?.setName(
value
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}
$effect(() => {
initialPath != '' && untrack(() => loadTriggers())
})
let langs = $derived(
processLangs(script.language, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages))
.map((l) => [defaultScriptLanguages[l], l])
.filter((x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x[1]))
.filter((x) => {
if (customUi?.settingsPanel?.metadata?.languages === undefined) {
return true
}
return customUi.settingsPanel.metadata.languages.includes(x[1] as SupportedLanguage)
}) as [string, SupportedLanguage | 'docker' | 'bunnative'][]
)
$effect(() => {
;['collab', 'path'].forEach((x) => {
if (searchParams.get(x)) {
searchParams.delete(x)
}
})
})
// Mirror the draft triggers (held in a separate `triggersState` $state)
// back into `script.draft_triggers` so the UserDraft autosave — which
// deep-tracks `script` — picks them up. Pre-PR ScriptBuilder ran its own
// localStorage autosave that explicitly snapshotted triggersState; the
// switch to a unified UserDraft handle dropped that bridge.
$effect(() => {
readFieldsRecursively(triggersState.triggers)
script.draft_triggers = triggersState.getDraftTriggersSnapshot()
})
loadWorkerTags()
async function loadWorkerTags() {
if (usesLocalTags) {
if (!localWorkerTags) {
localWorkerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: opWorkspace! })
}
} else if (!$workerTags) {
$workerTags = await WorkerService.getCustomTagsForWorkspace({ workspace: opWorkspace! })
}
}
</script>
<svelte:window onkeydown={onKeyDown} />
{@render children?.()}
<DeployOverrideConfirmationModal
{deployedBy}
{confirmCallback}
bind:open
{diffDrawer}
bind:deployedValue
currentValue={script}
/>
<DraftTriggersConfirmationModal
bind:open={draftTriggersModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
on:canceled={() => {
draftTriggersModalOpen = false
}}
on:confirmed={handleDraftTriggersConfirmed}
/>
{#if !$userStore?.operator}
<Drawer
placement="right"
bind:open={metadataOpen}
size={selectedTab === 'ui' || selectedTab === 'triggers' ? '1200px' : '800px'}
>
<DrawerContent
noPadding
title="Settings"
on:close={() => (metadataOpen = false)}
aiId="script-builder-settings"
aiDescription="Script builder settings"
>
<!-- svelte-ignore a11y_autofocus -->
<div class="flex flex-col h-full">
<Tabs bind:selected={selectedTab} wrapperClass="flex-none w-full">
{#if customUi?.settingsPanel?.disableMetadata !== true}
<Tab
value="metadata"
aiId="script-builder-metadata"
aiDescription="Metadata settings"
label="Metadata"
/>
{/if}
{#if customUi?.settingsPanel?.disableRuntime !== true}
<Tab
value="runtime"
aiId="script-builder-runtime"
aiDescription="Runtime settings"
label="Runtime"
/>
{/if}
<!-- Not for dbt: its run form is derived from the descriptor server-side
(`dbt_arg_schema`), so anything refined here is overwritten by the
next deploy. -->
{#if customUi?.settingsPanel?.disableGeneratedUi !== true && !isDbt}
<Tab
value="ui"
aiId="script-builder-ui"
aiDescription="Generated UI settings"
label="Generated UI"
>
{#snippet extra()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/json_schema_and_parsing"
>
The arguments are synced with the main signature but you may refine the parts that
cannot be inferred from the type directly.
</Tooltip>
{/snippet}
</Tab>
{/if}
{#if customUi?.settingsPanel?.disableTriggers !== true}
<Tab
value="triggers"
aiId="script-builder-triggers"
aiDescription="Triggers settings"
label="Triggers"
>
{#snippet extra()}
<Tooltip documentationLink="https://www.windmill.dev/docs/getting_started/triggers">
Configure how this script will be triggered.
</Tooltip>
{/snippet}
</Tab>
{/if}
{#snippet content()}
<div class="min-h-0 grow overflow-y-auto">
<TabContent value="metadata">
<div class="flex flex-col gap-8 px-4 py-2 pb-12">
<Section label="Metadata">
{#snippet action()}
{#if customUi?.settingsPanel?.metadata?.disableMute !== true}
<div class="flex flex-row items-center gap-2">
<ErrorHandlerToggleButton
kind="script"
scriptOrFlowPath={script.path}
bind:errorHandlerMuted={script.ws_error_handler_muted}
iconOnly={false}
/>
</div>
{/if}
{/snippet}
<div class="flex flex-col gap-6">
<Label label="Summary">
<MetadataGen
aiId="create-script-summary-input"
aiDescription="Summary / Title of the new script"
bind:content={script.summary}
code={script.content}
promptConfigName="summary"
generateOnAppear
onChange={() => onSummaryChange(script.summary)}
elementProps={{
type: 'text',
placeholder: 'Short summary to be displayed when listed'
}}
/>
</Label>
<LabelsInput bind:labels={script.labels} class="-mt-4" />
<Label label="Path">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path"
>
The unique identifier of the script in the workspace that defines
permissions
</Tooltip>
{/snippet}
<Path
bind:this={path}
bind:error={pathError}
bind:path={script.path}
bind:dirty={dirtyPath}
{initialPath}
autofocus={false}
namePlaceholder="script"
kind="script"
workspaceOverride={opWorkspace}
/>
{#if initialPath && script.path && script.path !== initialPath}
<Alert
type="info"
size="xs"
title="Deploy the script to make the path change effective."
class="mt-2"
/>
{/if}
</Label>
<Label label="Description">
<MetadataGen
bind:content={script.description}
code={script.content}
promptConfigName="description"
elementType="textarea"
elementProps={{
placeholder: 'What this script does and how to use it'
}}
/>
</Label>
</div>
</Section>
{#if !customUi?.settingsPanel?.metadata?.languages || customUi?.settingsPanel?.metadata?.languages?.length > 1}
<Section label="Language">
{#snippet action()}
<DefaultScripts />
{/snippet}
{#if lockedLanguage}
<div class="text-sm text-primary italic mb-2">
As a forked script, the language '{script.language}' cannot be modified.
</div>
{/if}
<div class=" grid grid-cols-3 gap-2">
{#each langs as [label, lang] (lang)}
{@const isPicked =
(lang == script.language &&
template != 'bunnative' &&
template != 'docker' &&
template != 'claudesandbox') ||
(template == 'bunnative' && lang == 'bunnative') ||
(template == 'docker' && lang == 'docker') ||
(template == 'claudesandbox' && lang == 'bun')}
<Popover
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
>
<Button
aiId={`create-script-language-button-${lang}`}
aiDescription={`Choose ${lang} as the language of the script`}
unifiedSize="lg"
variant="default"
selected={isPicked}
btnClasses={isPicked ? '' : 'm-[1px]'}
on:click={() => onScriptLanguageTrigger(lang)}
disabled={lockedLanguage ||
(enterpriseLangs.includes(lang) && !$enterpriseLicense) ||
(script.kind == 'preprocessor' && !canHavePreprocessor(lang))}
startIcon={{
icon: LanguageIcon,
props: { lang }
} as ButtonType.Icon}
>
<span class="truncate">{label}</span>
{#if lang === 'dbt'}
<Badge color="orange" verySmall baseClass="ml-1">alpha</Badge>
{/if}
</Button>
{#snippet text()}
{label} is only available with an enterprise license
{/snippet}
</Popover>
{/each}
</div>
</Section>
{/if}
<div class="flex items-center gap-2 mt-2 flex-wrap">
<span class="text-2xs text-secondary">Template</span>
<Button
size="xs2"
variant="border"
color="light"
startIcon={{
icon: LanguageIcon,
props: { lang: 'claudesandbox', width: 16, height: 16 }
} as ButtonType.Icon}
on:click={() => {
template = 'claudesandbox'
script.language = 'bun'
initContent('bun', script.kind, template)
}}
>
Claude Sandbox
</Button>
<span title="Workflow-as-Code">
<Button
size="xs2"
variant="border"
color="light"
startIcon={{
icon: LanguageIcon,
props: { lang: 'bun', width: 16, height: 16 }
} as ButtonType.Icon}
on:click={() => {
template = 'wac_typescript'
script.language = 'bun'
script.modules = {
'helper.ts': {
content:
'export function main(a: string): string {\n return `hello ${a}`\n}\n',
language: 'bun'
}
}
initContent('bun', script.kind, template)
}}
>
WAC TypeScript
</Button>
</span>
<span title="Workflow-as-Code">
<Button
size="xs2"
variant="border"
color="light"
startIcon={{
icon: LanguageIcon,
props: { lang: 'python3', width: 16, height: 16 }
} as ButtonType.Icon}
on:click={() => {
template = 'wac_python'
script.language = 'python3'
script.modules = {
'helper.py': {
content: 'def main(a: str) -> str:\n return f"hello {a}"\n',
language: 'python3'
}
}
initContent('python3', script.kind, template)
}}
>
WAC Python
</Button>
</span>
<Button
size="xs2"
variant="border"
color="light"
startIcon={{
icon: LanguageIcon,
props: { lang: 'bun', width: 16, height: 16 }
} as ButtonType.Icon}
on:click={() => {
template = 'ci_test_bun'
script.language = 'bun'
initContent('bun', script.kind, template)
}}
>
CI Test TypeScript
</Button>
<Button
size="xs2"
variant="border"
color="light"
startIcon={{
icon: LanguageIcon,
props: { lang: 'python3', width: 16, height: 16 }
} as ButtonType.Icon}
on:click={() => {
template = 'ci_test_python'
script.language = 'python3'
initContent('python3', script.kind, template)
}}
>
CI Test Python
</Button>
</div>
<!-- Not for dbt: each kind tags a script for a role in a flow that a project
bundle cannot fill (approval, trigger, preprocessor), and the runtime only
ever runs it as an action. -->
{#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true && !isDbt}
<Section label="Script kind">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/script_editor/script_kinds"
>
Tag this script's purpose within flows such that it is available as the
corresponding action.
</Tooltip>
{/snippet}
<ToggleButtonGroup
selected={script.kind}
on:selected={({ detail }) => {
template = 'script'
script.kind = detail
initContent(script.language, detail, template)
}}
>
{#snippet children({ item })}
{#each scriptKindOptions as { value, title, desc, documentationLink, Icon }}
<ToggleButton
label={title}
{value}
tooltip={desc}
{documentationLink}
icon={Icon}
showTooltipIcon={Boolean(desc)}
{item}
/>
{/each}
{/snippet}
</ToggleButtonGroup>
</Section>
{/if}
{#if customUi?.settingsPanel?.disableRuntime}
<Section label="Worker group tag (queue)">
<WorkerTagPicker
bind:tag={script.tag}
placeholder={customUi?.tagSelectPlaceholder}
/>
</Section>
{/if}
{#if script.schema && !disableAi && !customUi?.settingsPanel?.metadata?.disableAiFilling}
<div class="mt-3">
<AIFormSettings
bind:prompt={script.schema.prompt_for_ai as string | undefined}
type="script"
/>
</div>
{/if}
</div>
</TabContent>
<TabContent value="runtime">
<div class="flex flex-col gap-8 px-4 py-2 pb-12">
<Section label="Worker group tag (queue)">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups"
>
The script will be executed on a worker configured to listen to this worker
group tag (queue). For instance, you could setup an "highmem", or "gpu" tag.
</Tooltip>
{/snippet}
<WorkerTagPicker
bind:tag={script.tag}
placeholder={customUi?.tagSelectPlaceholder}
/>
</Section>
<Section label="Concurrency limits" eeOnly>
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits"
>
Allowed concurrency within a given timeframe
</Tooltip>
{/snippet}
<Toggle
size="sm"
checked={Boolean(script.concurrent_limit)}
on:change={() => {
if (script.concurrent_limit && script.concurrent_limit != undefined) {
script.concurrent_limit = undefined
script.concurrency_time_window_s = undefined
script.concurrency_key = undefined
} else {
script.concurrent_limit = 1
}
}}
options={{
right: 'Concurrency limits'
}}
/>
{#if Boolean(script.concurrent_limit)}
<div class="flex flex-col gap-4 mt-2">
<Label label="Max number of executions within the time window">
<div class="flex flex-row gap-2 max-w-sm whitespace-nowrap">
<input
disabled={!$enterpriseLicense}
bind:value={script.concurrent_limit}
type="number"
/>
</div>
</Label>
{#if Boolean(script.concurrent_limit)}
<Label label="Time window in seconds">
<SecondsInput
disabled={!$enterpriseLicense}
bind:seconds={script.concurrency_time_window_s}
/>
</Label>
<Label label="Custom concurrency key (optional)">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits#custom-concurrency-key"
>
Concurrency keys are global, you can have them be workspace specific
using the variable `$workspace`. You can also use an argument's
value using `$args[name_of_arg]`</Tooltip
>
{/snippet}
<input
disabled={!$enterpriseLicense}
type="text"
autofocus
bind:value={script.concurrency_key}
placeholder={`$workspace/script/${script.path}-$args[foo]`}
/>
</Label>
{/if}
</div>
{/if}
</Section>
<Section label="Cache">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/caching"
>
Cache the results for each possible inputs
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
size="sm"
bind:checked={
() => !!script.cache_ttl, (v) => (script.cache_ttl = v ? 300 : undefined)
}
options={{ right: 'Cache the results for each possible inputs' }}
/>
{#if script.cache_ttl}
<div class="text-2xs text-secondary">How long to keep the cache valid</div>
<SecondsInput bind:seconds={script.cache_ttl} />
<Toggle
size="2xs"
bind:checked={
() => script.cache_ignore_s3_path,
(v) => (script.cache_ignore_s3_path = v || undefined)
}
options={{
right: 'Ignore S3 Object paths for caching purposes',
rightTooltip:
'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.'
}}
/>
{/if}
</div>
</Section>
<Section label="Timeout">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/script_editor/settings#timeout"
>
Add a custom timeout for this script
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
size="sm"
checked={Boolean(script.timeout)}
on:change={() => {
if (script.timeout && script.timeout != undefined) {
script.timeout = undefined
} else {
script.timeout = customUi?.defaultTimeout ?? 300
}
}}
options={{
right: 'Add a custom timeout for this script'
}}
/>
{#if Boolean(script.timeout)}
<span class="text-xs font-semibold text-emphasis leading-none mt-2">
Timeout duration
</span>
{#if script.timeout}
<SecondsInput bind:seconds={script.timeout} />
{:else}
<SecondsInput disabled />
{/if}
{/if}
</div>
</Section>
<Section label="Debouncing">
<DebounceLimit
size="sm"
bind:debounce_delay_s={script.debounce_delay_s}
bind:debounce_key={script.debounce_key}
bind:debounce_args_to_accumulate={script.debounce_args_to_accumulate}
bind:max_total_debouncing_time={script.max_total_debouncing_time}
bind:max_total_debounces_amount={script.max_total_debounces_amount}
schema={script.schema as Schema}
placeholder={`$workspace/script/${script.path}-$args[foo]`}
/>
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/job_debouncing"
>
Debounce Jobs
</Tooltip>
{/snippet}
</Section>
<Section label="Perpetual script">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/script_editor/perpetual_scripts"
>
Restart the script upon ending unless cancelled
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
size="sm"
checked={Boolean(script.restart_unless_cancelled)}
on:change={() => {
if (script.restart_unless_cancelled) {
script.restart_unless_cancelled = undefined
} else {
script.restart_unless_cancelled = true
}
}}
options={{
right: 'Restart upon ending unless cancelled'
}}
/>
</div>
</Section>
<Section label="Dedicated workers" eeOnly>
<Toggle
disabled={!$enterpriseLicense ||
isCloudHosted() ||
(script.language != 'bun' &&
script.language != 'bunnative' &&
script.language != 'python3' &&
script.language != 'deno')}
size="sm"
checked={Boolean(script.dedicated_worker)}
on:change={() => {
if (script.dedicated_worker) {
script.dedicated_worker = undefined
} else {
script.dedicated_worker = true
}
}}
options={{
right: 'Script is run on dedicated workers'
}}
/>
{#if script.dedicated_worker}
<div class="py-2">
<Alert type="info" title="Require dedicated workers">
A worker group needs to be configured to listen to this script. Select it
in the dedicated workers section of the worker group configuration.
</Alert>
</div>
{/if}
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/dedicated_workers"
>
In this mode, the script is meant to be run on dedicated workers that run
the script at native speed. Can reach &gt;1500rps per dedicated worker. Only
available on enterprise edition and for Python3, Deno, Bun and Bunnative.
For other languages, the efficiency is already on par with dedicated workers
since they do not spawn a full runtime</Tooltip
>
{/snippet}
</Section>
<Section label="Delete after completion">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/script_editor/settings#delete-after-use"
>
WARNING: This settings ONLY applies to synchronous webhooks or when the
script is used within a flow. If used individually, this script must be
triggered using a synchronous endpoint to have the desired effect.
<br />
<br />
The logs, arguments and results of the job will be completely deleted from Windmill
after the specified delay once it is complete and the result has been returned.
Set to 0 for immediate deletion.
<br />
<br />
The deletion is irreversible.
{#if !$enterpriseLicense}
<br />
<br />
This option is only available on Windmill Enterprise Edition.
{/if}
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
disabled={!$enterpriseLicense}
size="sm"
checked={script.delete_after_secs != null}
on:change={() => {
if (script.delete_after_secs != null) {
script.delete_after_secs = undefined
} else {
script.delete_after_secs = 0
}
}}
options={{
right: 'Delete logs, arguments and results after completion'
}}
/>
{#if script.delete_after_secs != null}
<SecondsInput
bind:seconds={script.delete_after_secs}
disabled={!$enterpriseLicense}
/>
{/if}
</div>
</Section>
{#if !isCloudHosted()}
<Section label="High priority script" eeOnly>
<Toggle
disabled={!$enterpriseLicense || isCloudHosted()}
size="sm"
checked={script.priority !== undefined && script.priority > 0}
on:change={() => {
if (script.priority) {
script.priority = undefined
} else {
script.priority = 100
}
}}
options={{
right: 'Label as high priority'
}}
>
{#snippet right()}
<input
type="number"
class="!w-16 ml-4"
disabled={script.priority === undefined}
bind:value={script.priority}
onfocus={bubble('focus')}
onchange={() => {
if (script.priority && script.priority > 100) {
script.priority = 100
} else if (script.priority && script.priority < 0) {
script.priority = 0
}
}}
/>
{/snippet}
</Toggle>
{#snippet header()}
<!-- TODO: Add EE-only badge when we have it -->
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/jobs#high-priority-jobs"
>
Jobs from script labeled as high priority take precedence over the other
jobs when in the jobs queue.
{#if !$enterpriseLicense}This is a feature only available on enterprise
edition.{/if}
</Tooltip>
{/snippet}
</Section>
{/if}
<Section label="Runs visibility">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#invisible-runs"
>
When this option is enabled, manual executions of this script are invisible
to users other than the user running it, including the owner(s). This
setting can be overridden when this script is run manually from the advanced
menu.
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
size="sm"
checked={Boolean(script.visible_to_runner_only)}
on:change={() => {
if (script.visible_to_runner_only) {
script.visible_to_runner_only = undefined
} else {
script.visible_to_runner_only = true
}
}}
options={{
right: 'Make runs invisible to others'
}}
/>
</div>
</Section>
<Section
label={canPreserve
? 'Run on behalf of a specified user'
: 'On behalf of last editor'}
>
{#snippet header()}
<Tooltip>
When this option is enabled, the script will be run with the permissions of
{canPreserve ? 'the specified user' : 'the last editor'}.
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<span class="inline-flex gap-2">
<Toggle
size="sm"
checked={Boolean(script.on_behalf_of_email)}
on:change={() => {
if (script.on_behalf_of_email) {
script.on_behalf_of_email = undefined
script.on_behalf_of = undefined
preserveOnBehalfOf = false
onBehalfOfChoice = undefined
} else {
script.on_behalf_of_email = $userStore?.email
script.on_behalf_of = myPermissionedAs
}
}}
options={{
right: `Run on behalf of ${canPreserve ? 'a specified user' : 'last editor'}`
}}
/>
{#if script.on_behalf_of_email && canPreserve}
&rarr; <OnBehalfOfSelector
targetWorkspace={opWorkspace ?? ''}
targetValue={originalOnBehalfOfEmail}
selected={onBehalfOfChoice}
onSelect={(choice, details) => {
onBehalfOfChoice = choice
if (choice === 'me') {
script.on_behalf_of_email = $userStore?.email
script.on_behalf_of = myPermissionedAs
customOnBehalfOfEmail = ''
preserveOnBehalfOf = false
} else if (choice === 'target') {
// Keep the saved pair. A script that has no recorded principal yet
// sends the email alone and the backend derives one from it.
script.on_behalf_of_email = originalOnBehalfOfEmail
script.on_behalf_of = originalOnBehalfOfPermissionedAs
customOnBehalfOfEmail = ''
preserveOnBehalfOf = true
} else if (choice === 'custom' && details) {
script.on_behalf_of_email = details.email
script.on_behalf_of = details.permissionedAs
customOnBehalfOfEmail = details.email
preserveOnBehalfOf = true
}
}}
kind="script"
{canPreserve}
customValue={customOnBehalfOfEmail}
isDeployment={false}
/>
{:else if script.on_behalf_of_email && !canPreserve}
<span class="text-xs text-tertiary">
Currently: <span class="font-medium"
>{originalOnBehalfOfEmail ?? script.on_behalf_of_email}</span
>. Will be set to <span class="font-medium">{$userStore?.email}</span> on
deploy (requires admin or wm_deployers group to override)
</span>
{/if}
</span>
</div>
</Section>
{#if !isCloudHosted()}
<Section label="Custom env variables">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/script_editor/custom_environment_variables"
>
Additional static custom env variables to pass to the script.
</Tooltip>
{/snippet}
{#if script.envs && script.envs.length > 0}
<Alert type="warning" title="Not passed in previews" size="xs">
Static envs variables are not passed in preview but solely on deployed
scripts.
</Alert>
{/if}
<div class="w-full mt-2">
<span class="text-primary text-xs pb-2">Format is: `{'<KEY>=<VALUE>'}`</span
>
{#if Array.isArray(script.envs ?? [])}
{#each script.envs ?? [] as _v, i}
<div class="flex max-w-md mt-1 w-full items-center relative">
{#if script.envs}
<input
type="text"
bind:value={script.envs[i]}
placeholder="<KEY>=<VALUE>"
/>
{/if}
<button
transition:fade|local={{ duration: 50 }}
class="rounded-full p-1 bg-surface/60 duration-200 hover:bg-gray-200 absolute right-2"
aria-label="Clear"
onclick={() => {
script.envs && script.envs.splice(i, 1)
script.envs = script.envs
}}
>
<X size={14} />
</button>
</div>
{/each}
{/if}
</div>
<div class="flex mt-2">
<Button
variant="default"
size="xs"
on:click={() => {
if (script.envs == undefined || !Array.isArray(script.envs)) {
script.envs = []
}
script.envs = script.envs.concat('')
}}
>
<div class="flex flex-row gap-1">
<Plus size="16" />
Add item
</div>
</Button>
</div>
</Section>
{/if}
</div>
</TabContent>
{#if !isDbt}
<TabContent value="ui" class="h-full p-4">
<ScriptSchema
bind:schema={script.schema}
customUi={customUi?.settingsPanel?.metadata?.editableSchemaForm}
/>
</TabContent>
{/if}
<TabContent value="triggers" class="h-full">
<TriggersEditor
on:applyArgs={applyArgs}
on:addPreprocessor={addPreprocessor}
on:exitTriggers={() => {
captureTable?.loadCaptures(true)
}}
currentPath={script.path}
{initialPath}
{fakeInitialPath}
noEditor={true}
newItem={initialPath == ''}
isFlow={false}
{hasPreprocessor}
canHavePreprocessor={canHavePreprocessor(script.language)}
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
isDeployed={savedScript && savedScript?.no_deployed !== true}
schema={script.schema}
runnableVersion={script.parent_hash}
onDeployTrigger={handleDeployTrigger}
/>
<!-- <ScriptSchedules {initialPath} schema={script.schema} schedule={scheduleStore} /> -->
</TabContent>
</div>
{/snippet}
</Tabs>
</div>
</DrawerContent>
</Drawer>
<div class="flex flex-col h-full">
<div
bind:clientWidth={topbarWidth}
class="flex items-center px-4 {condensedHeader ? 'h-9' : 'h-12'}"
>
<div class="flex gap-2 lg:gap-2 w-full items-center">
<div class="flex flex-row items-center gap-2 min-w-0 shrink">
<button
disabled={customUi?.topBar?.settings == false}
class="shrink-0"
onclick={async () => {
metadataOpen = true
}}
>
<LanguageIcon lang={script.language} size={condensedHeader ? 18 : 24} />
</button>
{#if customUi?.topBar?.path != false}
<div class="min-w-0 overflow-hidden">
<EditorHeader
bind:summary={script.summary}
bind:path={script.path}
savedPath={initialPath}
kind="script"
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
hidePath={condensedHeader}
workspaceId={autosaveWorkspace}
onNavigate={(item) => onNavigate?.(item)}
/>
</div>
{/if}
{#if opWorkspace}
<AutosaveIndicator
workspace={opWorkspace}
itemKind="script"
path={indicatorPath}
draftOnly={savedScript?.no_deployed === true}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
/>
{/if}
{#if !condensedHeader}
{@const canOpenRuntime =
customUi?.topBar?.settings != false &&
customUi?.settingsPanel?.disableRuntime !== true}
<ScriptSettingsBadges
settings={script}
onclick={canOpenRuntime
? () => {
selectedTab = 'runtime'
metadataOpen = true
}
: undefined}
/>
{/if}
</div>
<!-- Separator -->
<div class="flex-1"></div>
{#if $enterpriseLicense && initialPath != '' && !inSessionPane}
<Awareness />
{/if}
<!-- Separator -->
<div class="flex-1"></div>
{#snippet settingsButton()}
{#if customUi?.topBar?.settings != false}
<Button
aiId="script-builder-settings"
aiDescription="Script builder settings to configure metadata, runtime, triggers, and generated UI."
variant="default"
unifiedSize={headerBtnSize}
on:click={() => (metadataOpen = true)}
startIcon={{ icon: Settings }}
iconOnly={compactTopbar}
title="Settings"
>
<span> Settings </span>
</Button>
{/if}
{/snippet}
{#snippet diffButton()}
{#if customUi?.topBar?.diff != false}
{@const isDraftOnly = savedScript?.no_deployed === true}
{@const diffDisabled = !savedScript || !diffDrawer || isDraftOnly}
{@const diffTitle = isDraftOnly
? 'Deploy this script once to compare against the deployed version'
: 'Diff'}
<!-- A disabled <button> fires no pointer events, so a title/tooltip on it
never shows on hover. pointer-events-none on the button lets the hover
reach this titled wrapper instead. -->
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
<Button
variant="default"
unifiedSize={headerBtnSize}
on:click={() => openDiffDrawer()}
disabled={diffDisabled}
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
iconOnly={compactTopbar}
title={diffTitle}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
</div>
{/if}
{/snippet}
{#if compactTopbar}
<DropdownV2 items={getCompactMenuItems} placement="bottom-end">
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize={headerBtnSize}
variant="subtle"
startIcon={{ icon: EllipsisVertical }}
iconOnly
title="More"
/>
{/snippet}
</DropdownV2>
{@render diffButton()}
{@render settingsButton()}
{:else}
{@render diffButton()}
{#if customUi?.topBar?.tagEdit != false}
{#if scriptWorkerTags}
{#if scriptWorkerTags?.length ?? 0 > 0}
<div class="max-w-[200px]">
<WorkerTagSelect
nullTag={script.language}
placeholder={customUi?.tagSelectPlaceholder}
bind:tag={script.tag}
size={headerBtnSize}
workspaceId={opWorkspace}
/>
</div>
{/if}
{/if}
{/if}
{@render settingsButton()}
{/if}
<DeployButton
loading={!fullyLoaded}
{loadingSave}
unifiedSize={headerBtnSize}
dropdownItems={computeDropdownItems(initialPath, savedScript)}
on:save={({ detail }) => handleEditScript(false, detail)}
/>
</div>
</div>
{#if showPipelineHint}
<div
class="flex items-center gap-2 px-4 py-1 border-y bg-surface-secondary text-xs text-secondary"
>
<Network size={14} class="shrink-0 text-tertiary" />
<span class="truncate">
This script can become a data pipeline step: annotate it with
<code class="font-mono text-2xs bg-surface px-1 py-0.5 rounded border">-- pipeline</code>
and
<code class="font-mono text-2xs bg-surface px-1 py-0.5 rounded border">
-- materialize
</code>
to materialize its result, or build it in the
<a href="{base}/pipeline" class="text-blue-500 hover:underline">pipeline editor</a>.
<a
href="https://www.windmill.dev/docs/pipelines"
target="_blank"
rel="noreferrer"
class="text-blue-500 hover:underline"
>
Learn more
</a>
</span>
<div class="ml-auto shrink-0">
<Button
iconOnly
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: X }}
title="Dismiss pipelines hint"
onclick={() => (pipelineHintDismissed.val = true)}
/>
</div>
</div>
{/if}
{#if isDbt}
<DbtEditor
workspaceOverride={opWorkspace}
{customUi}
{onTestJob}
on:format={() => saveDraft()}
on:saveDraft={() => saveDraft()}
bind:editor
bind:this={scriptEditor}
bind:schema={script.schema}
path={script.path}
bind:code={script.content}
timeout={script.timeout}
tag={script.tag}
deployedHash={deployedScriptHash}
bind:args
bind:modules={script.modules}
/>
{:else}
<ScriptEditor
{disableAi}
workspaceOverride={opWorkspace}
sessionOpen={userDraftPath
? {
// URL draft path the editor loads/saves by, not the friendly
// `script.path` (a new script's has no row → "not found").
target: { kind: 'script', path: userDraftPath },
workspaceId: opWorkspace ?? undefined,
beforeOpen: persistDraftForSession
}
: undefined}
bind:selectedTab={selectedInputTab}
{customUi}
{onTestJob}
collabMode
edit={initialPath != ''}
on:format={() => {
saveDraft()
}}
on:saveDraft={() => {
saveDraft()
}}
on:openTriggers={openTriggers}
on:applyArgs={applyArgs}
on:addPreprocessor={addPreprocessor}
bind:editor
bind:this={scriptEditor}
bind:schema={script.schema}
path={script.path}
stablePathForCaptures={initialPath || fakeInitialPath}
bind:code={script.content}
lang={script.language}
timeout={script.timeout}
kind={script.kind}
autoKind={script.auto_kind}
{template}
tag={script.tag}
lastSavedCode={savedScript?.content}
lastDeployedCode={savedScript?.content}
bind:args
bind:hasPreprocessor
bind:captureTable
bind:assets={script.assets}
bind:modules={script.modules}
enablePreprocessorSnippet
{testPanelCollapsed}
/>
{/if}
</div>
{:else}
Script Builder not available to operators
{/if}
<Modal title="dbt on Windmill is in alpha" bind:open={dbtAlphaOpen} cancelText="Got it">
<div class="flex flex-col gap-3 text-sm text-secondary">
<p>
A dbt script is a whole dbt project: the files are the script's module bundle, the content is
a <span class="font-mono text-xs">wm_dbt.yaml</span> descriptor, and the models become
<span class="font-mono text-xs">dbt://</span> assets in the graph.
</p>
<p>
Expect rough edges, and expect details to move: the descriptor's fields, what a run returns,
and how the graph is stored are all still settling.
</p>
<p>
Deploying and running work today, and an existing project needs no changes
<span class="font-mono text-xs">cp -r</span> it into the script's folder and push. Nothing here
is load-bearing for other languages.
</p>
<p class="text-xs">
<a
href="https://www.windmill.dev/docs/getting_started/scripts_quickstart"
target="_blank"
rel="noreferrer"
class="text-blue-500 hover:underline">Docs</a
>
· report anything surprising, it is the most useful thing at this stage.
</p>
</div>
</Modal>
<WacExportDrawer bind:this={wacExportDrawer} />