feat: redirect /add pages to /edit/draft_uuid with new_draft flag

This commit is contained in:
Diego Imbert
2026-06-02 12:29:47 +02:00
parent 00e195c1ba
commit 32a78be6dc
22 changed files with 350 additions and 1410 deletions
+35 -12
View File
@@ -20,7 +20,9 @@ use windmill_api_auth::{
};
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
use windmill_common::{
user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery},
user_drafts::{
fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
},
utils::{WithStarredInfoQuery, HTTP_CLIENT},
webhook::{WebhookMessage, WebhookShared},
DB,
@@ -1464,17 +1466,38 @@ async fn get_flow_by_path(
tx.commit().await?;
let flow = not_found_if_none(flow_o, "Flow", path)?;
let overlay = maybe_overlay_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::Flow,
path,
query.draft.get_draft,
flow,
)
.await?;
// Editors that have only ever drafted (never deployed) a flow at this
// path will land here with no deployed row. When `get_draft` is set,
// fall back to the draft table so /flows/edit/draft_<uuid> works the
// same way as a deployed-flow reload.
let overlay = match flow_o {
Some(flow) => {
maybe_overlay_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::Flow,
path,
query.draft.get_draft,
flow,
)
.await?
}
None if query.draft.get_draft => {
fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Flow, path)
.await?
.ok_or_else(|| {
windmill_common::error::Error::NotFound(format!(
"Flow not found at path {path}"
))
})?
}
None => {
return Err(windmill_common::error::Error::NotFound(format!(
"Flow not found at path {path}"
)));
}
};
Ok(Json(overlay))
}
+38 -17
View File
@@ -12,7 +12,9 @@ use windmill_api_auth::{
check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed,
};
use windmill_common::{
user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery},
user_drafts::{
fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
},
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT},
webhook::{WebhookMessage, WebhookShared},
workspaces::{check_deploy_rules, RuleCheckResult},
@@ -1690,22 +1692,41 @@ async fn get_script_by_path(
};
tx.commit().await?;
let script = windmill_common::scripts::prefetch_cached_script_with_starred(
not_found_if_none(script_o, "Script", path)?,
&db,
)
.await?;
let overlay = maybe_overlay_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::Script,
path,
query.draft.get_draft,
script,
)
.await?;
// Editors that have only ever drafted (never deployed) a script at this
// path will land here with no deployed row. When `get_draft` is set, fall
// back to the draft table so /scripts/edit/draft_<uuid> works the same
// way as a deployed-script reload.
let overlay = match script_o {
Some(script_o) => {
let script =
windmill_common::scripts::prefetch_cached_script_with_starred(script_o, &db)
.await?;
maybe_overlay_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::Script,
path,
query.draft.get_draft,
script,
)
.await?
}
None if query.draft.get_draft => {
fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Script, path)
.await?
.ok_or_else(|| {
windmill_common::error::Error::NotFound(format!(
"Script not found at path {path}"
))
})?
}
None => {
return Err(windmill_common::error::Error::NotFound(format!(
"Script not found at path {path}"
)))
}
};
Ok(Json(overlay))
}
+8
View File
@@ -10482,6 +10482,14 @@ paths:
schema:
type: boolean
- $ref: "#/components/parameters/GetDraft"
- name: raw_app
in: query
description: |
When no deployed app exists at this path and `get_draft` is set,
disambiguates which draft kind (`raw_app` or `app`) to look up.
Ignored when a deployed row exists.
schema:
type: boolean
responses:
"200":
description: app details
+51 -18
View File
@@ -58,7 +58,9 @@ use windmill_common::{
get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion,
JobPayload, RawCode,
},
user_drafts::{maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery},
user_drafts::{
fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
},
users::username_to_permissioned_as,
utils::{
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
@@ -552,6 +554,13 @@ struct GetAppQuery {
starred: WithStarredInfoQuery,
#[serde(flatten)]
draft: WithDraftQuery,
/// When no deployed app exists at this path and `get_draft` is set,
/// `raw_app` picks which draft kind to look up (`raw_app` or `app`).
/// Ignored when a deployed row exists — the row's own `raw_app`
/// column wins. Frontend sets this from the route the editor is on
/// (`/apps_raw/...` → true, `/apps/...` → false).
#[serde(default)]
raw_app: Option<bool>,
}
async fn get_app(
@@ -600,24 +609,48 @@ async fn get_app(
};
tx.commit().await?;
let app = not_found_if_none(app_o, "App", path)?;
// The same `app` table backs both regular apps and raw apps; the
// `raw_app` flag on the row picks which draft kind to look up.
let kind = if app.app.raw_app {
UserDraftItemKind::RawApp
} else {
UserDraftItemKind::App
// Editors that have only ever drafted (never deployed) an app at this
// path will land here with no deployed row. When `get_draft` is set,
// fall back to the draft table so /apps/edit/draft_<uuid> and
// /apps_raw/edit/draft_<uuid> work the same way as a deployed reload.
// For draft-only there's no `raw_app` row column to consult — the
// caller's `raw_app` query param picks the draft kind.
let overlay = match app_o {
Some(app) => {
let kind = if app.app.raw_app {
UserDraftItemKind::RawApp
} else {
UserDraftItemKind::App
};
maybe_overlay_draft(
&db,
&w_id,
&authed.email,
kind,
path,
query.draft.get_draft,
app,
)
.await?
}
None if query.draft.get_draft => {
let kind = if query.raw_app.unwrap_or(false) {
UserDraftItemKind::RawApp
} else {
UserDraftItemKind::App
};
fetch_draft_only(&db, &w_id, &authed.email, kind, path)
.await?
.ok_or_else(|| {
windmill_common::error::Error::NotFound(format!("App not found at path {path}"))
})?
}
None => {
return Err(windmill_common::error::Error::NotFound(format!(
"App not found at path {path}"
)));
}
};
let overlay = maybe_overlay_draft(
&db,
&w_id,
&authed.email,
kind,
path,
query.draft.get_draft,
app,
)
.await?;
Ok(Json(overlay))
}
@@ -160,3 +160,51 @@ fn deep_merge(target: &mut serde_json::Value, source: serde_json::Value) {
(t, s) => *t = s,
}
}
/// Fetch the authed user's draft as a standalone payload, used by
/// "get by path" routes when no deployed row exists at the path but a
/// draft might. Returns the draft JSON wrapped as `WithDraftOverlay`
/// with `is_draft = true`, so the response shape matches the overlay
/// path the handler uses when a deployed row IS present.
///
/// Callers must already have established that no deployed row exists.
/// Returns `Ok(None)` when there's also no draft — caller should 404.
///
/// The draft JSON is expected to be a JSON object (every editor writes
/// drafts as object-shaped editable state, so `serde(flatten)` works on
/// the inner value). A non-object draft would render with no fields
/// flattened — defensive but degraded.
pub async fn fetch_draft_only(
db: &DB,
w_id: &str,
email: &str,
kind: UserDraftItemKind,
path: &str,
) -> Result<Option<WithDraftOverlay>> {
let row = sqlx::query!(
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
created_at
FROM draft
WHERE workspace_id = $1
AND email = $2
AND path = $3
AND typ = $4"#,
w_id,
email,
path,
kind as UserDraftItemKind,
)
.fetch_optional(db)
.await?;
let Some(row) = row else {
return Ok(None);
};
let inner: serde_json::Value = serde_json::from_str(row.value.0.get())?;
Ok(Some(WithDraftOverlay {
inner,
is_draft: true,
draft_saved_at: Some(row.created_at),
}))
}
@@ -98,8 +98,8 @@
// /apps/add reload: the route always initializes `app` to an empty
// template, but the user's last session is sitting in LS under the
// empty-path entry). The route is responsible for wiping the entry
// (`UserDraft.remove`) when it wants to force a fresh start
// `?nodraft=true`, template/hub loads, etc.
// (`UserDraft.remove`) when it wants to force a fresh start
// (template/hub loads, etc.).
const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app))
const appStore = writable<App>(stateApp)
// Captured once on mount: the load-time revs are only used as the
@@ -26,7 +26,7 @@
targetTutorial = undefined
}}
on:confirmed={async () => {
window.open(`/apps/add?tutorial=${targetTutorial}&nodraft=true`, '_blank')
window.open(`/apps/add?tutorial=${targetTutorial}`, '_blank')
}}
>
<div class="flex flex-col w-full space-y-4">
@@ -26,10 +26,10 @@
// Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation),
// so the in-memory importStore would be lost. Use sessionStorage instead.
sessionStorage.setItem('rawAppImport', JSON.stringify(parsed))
await goto('/apps_raw/add?nodraft=true')
await goto('/apps_raw/add')
} else {
$importStore = parsed
await goto('/apps/add?nodraft=true')
await goto('/apps/add')
}
drawer?.closeDrawer?.()
}
@@ -40,12 +40,12 @@
function selectLowCode() {
appTypeModalOpen = false
goto(`${base}/apps/add?nodraft=true`)
goto(`${base}/apps/add`)
}
function selectFullCode() {
appTypeModalOpen = false
goto(`${base}/apps_raw/add?nodraft=true`)
goto(`${base}/apps_raw/add`)
}
</script>
@@ -33,7 +33,7 @@
async function importRaw() {
$importFlowStore =
importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '')
await goto('/flows/add?nodraft=true')
await goto('/flows/add')
drawer?.closeDrawer?.()
}
@@ -41,13 +41,13 @@
const parsed =
wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '')
$importScriptStore = parsed
await goto(`${base}/scripts/add?import=true&nodraft=true`)
await goto(`${base}/scripts/add?import=true`)
wacDrawer?.closeDrawer?.()
}
function handleFlowClick() {
if (skipModal) {
goto(`${base}/flows/add?nodraft=true`)
goto(`${base}/flows/add`)
} else {
flowModalOpen = true
}
@@ -55,17 +55,17 @@
function selectFlowEditor() {
flowModalOpen = false
goto(`${base}/flows/add?nodraft=true`)
goto(`${base}/flows/add`)
}
function selectWacPython() {
flowModalOpen = false
goto(`${base}/scripts/add?nodraft=true&wac=python`)
goto(`${base}/scripts/add?wac=python`)
}
function selectWacTypescript() {
flowModalOpen = false
goto(`${base}/scripts/add?nodraft=true&wac=typescript`)
goto(`${base}/scripts/add?wac=typescript`)
}
function toggleSkipModal() {
@@ -14,7 +14,7 @@
unifiedSize="lg"
variant="accent"
startIcon={{ icon: Plus }}
href="{base}/scripts/add?nodraft=true"
href="{base}/scripts/add"
endIcon={{ icon: Code2 }}
>
Script
+4 -4
View File
@@ -67,7 +67,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
title: 'Build a flow',
description: 'Learn how to build workflows in Windmill with our interactive tutorial.',
onClick: () => {
window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial&nodraft=true`
window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial`
},
index: 2,
active: true,
@@ -81,7 +81,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
title: 'Fix a broken flow',
description: 'Learn how to monitor and debug your script and flow executions.',
onClick: () => {
window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow&nodraft=true`
window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow`
},
index: 3,
active: true,
@@ -131,7 +131,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
title: 'Background runnables',
description: 'Learn how to create and use background runnables in your apps.',
onClick: () => {
window.location.href = `${base}/apps/add?tutorial=backgroundrunnables&nodraft=true`
window.location.href = `${base}/apps/add?tutorial=backgroundrunnables`
},
index: 4,
active: true,
@@ -145,7 +145,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
title: 'Connection',
description: 'Learn how to connect component inputs to outputs in your apps.',
onClick: () => {
window.location.href = `${base}/apps/add?tutorial=connection&nodraft=true`
window.location.href = `${base}/apps/add?tutorial=connection`
},
index: 5,
active: true,
@@ -1,142 +1,15 @@
<script lang="ts">
import { importStore } from '$lib/components/apps/store'
import AppEditor from '$lib/components/apps/editor/AppEditor.svelte'
import { AppService, type Policy } from '$lib/gen'
import { page } from '$app/state'
import { userStore, workspaceStore } from '$lib/stores'
import type { App } from '$lib/components/apps/types'
import { replaceState } from '$app/navigation'
// `/apps/add` is a thin redirect onto the canonical editor at
// `/apps/edit/draft_{uuid}?new_draft=true`. See /scripts/add for the
// design rationale — all editor logic lives in /apps/edit/[...path].
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import { DEFAULT_THEME } from '$lib/components/apps/editor/componentsPanel/themeUtils'
import { emptyApp } from '$lib/components/apps/editor/appUtils'
import { tick } from 'svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { page } from '$app/state'
import { onMount } from 'svelte'
// "+ App" buttons navigate with ?nodraft=true to signal "start fresh".
// Wipe the persisted empty-path autosave and strip the flag from the URL
// synchronously so a reload doesn't wipe the freshly-started draft. A
// plain reload of /apps/add (no nodraft) instead restores the previous
// session via the child AppEditor's `UserDraft.use`.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('app', '')
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
let appEditor: AppEditor | undefined = $state(undefined)
const hubId = page.url.searchParams.get('hub')
const templatePath = page.url.searchParams.get('template')
const templateId = page.url.searchParams.get('template_id')
const importRaw = $importStore
if ($importStore) {
$importStore = undefined
}
let summary = $state('')
let value: App = $state({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: [],
theme: {
type: 'path',
path: DEFAULT_THEME
}
onMount(() => {
const uuid = crypto.randomUUID()
const params = new URLSearchParams(page.url.searchParams)
params.set('new_draft', 'true')
goto(`/apps/edit/draft_${uuid}?${params.toString()}`, { replaceState: true })
})
let policy: Policy = $state({
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
: `u/${$userStore?.username}`,
on_behalf_of_email: $userStore?.email,
execution_mode: 'publisher'
})
loadApp()
async function loadApp() {
if (importRaw) {
// Import/template/hub loads are an explicit "start fresh from this
// content" — drop any previous empty-path autosave so it doesn't
// shadow the imported value on AppEditor mount.
UserDraft.remove('app', '')
sendUserToast('Loaded from YAML/JSON')
if ('value' in importRaw) {
summary = importRaw.summary
value = importRaw.value
policy = importRaw.policy
} else {
value = importRaw
}
} else if (templatePath) {
UserDraft.remove('app', '')
const template = await AppService.getAppByPath({
workspace: $workspaceStore!,
path: templatePath
})
value = template.value as any
sendUserToast('App loaded from template')
goto('?', { replaceState: true })
} else if (templateId) {
UserDraft.remove('app', '')
const template = await AppService.getAppByVersion({
workspace: $workspaceStore!,
id: parseInt(templateId)
})
value = template.value as any
sendUserToast('App loaded from template')
goto('?', { replaceState: true })
} else if (hubId) {
UserDraft.remove('app', '')
const hub = await AppService.getHubAppById({ id: Number(hubId) })
value = {
hiddenInlineScripts: [],
unusedInlineScripts: [],
fullscreen: false,
...((hub.app.value ?? {}) as any)
}
summary = hub.app.summary
sendUserToast('App loaded from Hub')
goto('?', { replaceState: true })
} else {
value = emptyApp()
}
// Trigger tutorial after everything is initialized
const tutorialParam = page.url.searchParams.get('tutorial')
if (tutorialParam) {
// Wait for critical elements to be ready before triggering tutorial
await tick()
let attempts = 0
while (attempts < 20 && !document.querySelector('#app-editor-runnable-panel')) {
await new Promise((resolve) => setTimeout(resolve, 100))
attempts++
}
appEditor?.triggerTutorial()
}
}
</script>
{#if value}
<div class="h-screen">
{#key value}
<AppEditor
bind:this={appEditor}
onSavedNewAppPath={(path) => {
goto(`/apps/edit/${path}`)
}}
{summary}
app={value}
path={''}
{policy}
fromHub={hubId != null}
newApp={true}
replaceStateFn={(path) => replaceState(path, page.state)}
gotoFn={(path, opt) => goto(path, opt)}
/>
{/key}
</div>
{/if}
@@ -11,6 +11,7 @@
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import OtherUsersDraftsModal from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { emptyApp } from '$lib/components/apps/editor/appUtils'
import { untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
@@ -71,23 +72,42 @@
staleModalOpen = false
}
// `?nodraft=true` is the callers' way of saying "skip the local autosave
// on this load." Wipe the UserDraft entry and strip the flag from the
// URL synchronously, before any descendant reads it. A plain reload
// (no nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('app', path)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
/** Increments per `loadApp` call. Stale loads (e.g. when picker
* navigation races a draft-discard reload) bail at the next checkpoint
* after their captured token no longer matches. */
let loadAppToken = 0
async function loadApp(): Promise<void> {
const tok = ++loadAppToken
// `?new_draft=true` (set by `/apps/add`'s redirect) means we landed
// on a fresh `draft_{uuid}` path that's never been saved. Skip the
// backend fetch (it would 404), seed an empty app, strip the flag.
if (page.url.searchParams.get('new_draft') === 'true') {
const url = new URL(window.location.href)
url.searchParams.delete('new_draft')
window.history.replaceState(window.history.state, '', url.toString())
const emptyValue = emptyApp()
app = {
summary: '',
value: emptyValue as any,
path: page.params.path ?? '',
policy: {} as any,
custom_path: undefined,
versions: [] as any,
id: 0 as any,
extra_perms: {},
created_at: new Date().toISOString(),
created_by: '',
raw_app: false
} as unknown as AppWithLastVersion & { value: any }
savedApp = {
summary: '',
value: emptyValue as any,
path: page.params.path ?? '',
policy: {} as any
}
currentRevs = {}
return
}
const backendApp = await AppService.getAppByPath({
path: page.params.path ?? '',
workspace: $workspaceStore!,
@@ -1,688 +1,15 @@
<script lang="ts">
import { importStore } from '$lib/components/apps/store'
import { AppService, type Policy } from '$lib/gen'
import { page } from '$app/state'
import { userStore, workspaceStore } from '$lib/stores'
// `/apps_raw/add` is a thin redirect onto the canonical editor at
// `/apps_raw/edit/draft_{uuid}?new_draft=true`. See /scripts/add for the
// design rationale — all editor logic lives in /apps_raw/edit/[...path].
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import { page } from '$app/state'
import { onMount } from 'svelte'
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import FileEditorIcon from '$lib/components/raw_apps/FileEditorIcon.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { readFieldsRecursively } from '$lib/utils'
import { untrack } from 'svelte'
import {
react18Template,
react19Template,
svelte5Template
} from '$lib/components/raw_apps/templates'
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import {
createDatatablesResource,
createSchemasResource,
toDatatableItems,
toSchemaItems
} from '$lib/components/raw_apps/datatableUtils.svelte'
import Select from '$lib/components/select/Select.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { Sparkles, Plus, List, Ban, ExternalLinkIcon } from 'lucide-svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import RawAppDataTableList from '$lib/components/raw_apps/RawAppDataTableList.svelte'
import RawAppDataTableDrawer from '$lib/components/raw_apps/RawAppDataTableDrawer.svelte'
import { type DataTableRef, formatDataTableRef } from '$lib/components/raw_apps/dataTableRefUtils'
import { copilotInfo } from '$lib/aiStore'
import { aiChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { Alert } from '$lib/components/common'
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
// `nodraft` is captured into a local because we strip it from the URL
// below — downstream readers like `templatePicker` must see the original
// signal.
const nodraft = page.url.searchParams.get('nodraft')
const templatePath = page.url.searchParams.get('template')
const templateId = page.url.searchParams.get('template_id')
const hubId = page.url.searchParams.get('hub')
// "+ Raw App" / "+ App > Full code" buttons navigate with ?nodraft=true to
// signal "start fresh". Wipe the persisted empty-path autosave and strip
// the flag from the URL synchronously so a reload doesn't wipe the
// freshly-started draft. A plain reload of /apps_raw/add (no nodraft)
// instead restores the previous session.
if (nodraft && typeof window !== 'undefined') {
UserDraft.discard('raw_app', '', undefined)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
// Check in-memory store first, then sessionStorage (used when full page reload occurs)
let importRaw = $importStore
if ($importStore) {
$importStore = undefined
}
if (!importRaw) {
const sessionData = sessionStorage.getItem('rawAppImport')
if (sessionData) {
sessionStorage.removeItem('rawAppImport')
importRaw = JSON.parse(sessionData)
}
}
const draftHandle = UserDraft.use<{
files: Record<string, string>
runnables: Record<string, Runnable>
data: RawAppData
summary: string
policy?: Policy
custom_path?: string
}>('raw_app', '')
// Restore the persisted autosave so a plain reload of /apps_raw/add
// resumes the last session. Captured once; the $effect below mirrors
// later edits back. Import/template/hub flows in loadApp() wipe the
// entry first (`UserDraft.remove`) for "start fresh" semantics.
const restoredDraft = untrack(() => draftHandle.draft)
const defaultRunnables: Record<string, Runnable> = {
a: {
name: 'a',
fields: {},
type: 'inline',
inlineScript: {
content:
'// import * as wmill from "windmill-client"\n\nexport async function main(x: string) {\n return x\n}\n',
language: 'bun',
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties: {
x: {
default: null,
description: '',
originalType: 'string',
type: 'string'
}
},
required: ['x'],
type: 'object'
}
}
}
}
let summary = $state(restoredDraft?.summary ?? '')
let files: Record<string, string> = $state(restoredDraft?.files ?? react19Template)
let policy: Policy = $state(
restoredDraft?.policy ?? {
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
: `u/${$userStore?.username}`,
on_behalf_of_email: $userStore?.email,
execution_mode: 'publisher'
}
)
let runnables: Record<string, Runnable> = $state(restoredDraft?.runnables ?? defaultRunnables)
/** Data configuration including tables and creation policy */
let data: RawAppData = $state(restoredDraft?.data ?? { ...DEFAULT_DATA })
// First mirror consumes the handle's first-write skip up-front (wipe
// then restore) so the user's first real edit isn't the one dropped.
let firstMirror = true
$effect(() => {
readFieldsRecursively(files)
readFieldsRecursively(runnables)
readFieldsRecursively(data)
readFieldsRecursively(policy)
void summary
untrack(() => {
if (firstMirror) {
firstMirror = false
draftHandle.setDraftAndMeta(undefined, {})
}
draftHandle.draft = { files, runnables, data, summary, policy }
})
onMount(() => {
const uuid = crypto.randomUUID()
const params = new URLSearchParams(page.url.searchParams)
params.set('new_draft', 'true')
goto(`/apps_raw/edit/draft_${uuid}?${params.toString()}`, { replaceState: true })
})
// Reflect an external UserDraft.save into the form. Idempotent + the
// d == null guard keeps it from looping with the mirror above or
// clobbering "start fresh" loads (which discard the in-memory draft).
$effect(() => {
const d = draftHandle.draft
if (d == null) return
untrack(() => {
if (localDraftDiffers(d, { files, runnables, data, summary, policy })) {
files = d.files
runnables = d.runnables
data = d.data
summary = d.summary
if (d.policy !== undefined) policy = d.policy
}
})
})
loadApp()
function extractValue(value: any) {
files = value.files
runnables = value.runnables
// Support old formats and new format
if (value.data) {
const d = value.data
// Handle old nested creation format
if (d.creation) {
data = {
tables: d.tables ?? [],
datatable: d.creation.datatable,
schema: d.creation.schema
}
} else {
data = d
}
} else if (value.dataTableRefs) {
data = { ...DEFAULT_DATA, tables: value.dataTableRefs }
}
}
async function loadApp() {
if (importRaw) {
// Import/template/hub loads are an explicit "start fresh from this
// content" — drop the restored empty-path autosave so it doesn't
// linger as the next plain reload's baseline.
UserDraft.discard('raw_app', '', undefined)
sendUserToast('Loaded from YAML/JSON')
if ('value' in importRaw) {
summary = importRaw.summary
extractValue(importRaw.value)
policy = importRaw.policy
} else {
extractValue(importRaw)
}
console.log('importRaw', importRaw)
} else if (templatePath) {
UserDraft.discard('raw_app', '', undefined)
const template = await AppService.getAppByPath({
workspace: $workspaceStore!,
path: templatePath
})
extractValue(template.value)
console.log('App loaded from template')
sendUserToast('App loaded from template path')
goto('?', { replaceState: true })
} else if (templateId) {
UserDraft.discard('raw_app', '', undefined)
const template = await AppService.getAppByVersion({
workspace: $workspaceStore!,
id: parseInt(templateId)
})
extractValue(template.value)
console.log('App loaded from template id')
sendUserToast('App loaded from template')
goto('?', { replaceState: true })
} else if (hubId) {
UserDraft.discard('raw_app', '', undefined)
const hub = await AppService.getHubRawAppById({ id: Number(hubId) })
if (hub.app?.value) {
extractValue(hub.app.value)
}
if (hub.app?.summary) {
summary = hub.app.summary
}
console.log('App loaded from Hub')
sendUserToast('App loaded from Hub')
goto('?', { replaceState: true })
}
}
const templates = [
{
name: 'React 19',
icon: 'tsx',
files: undefined,
selected: true
},
{
name: 'React 18',
icon: 'tsx',
files: react18Template
},
{
name: 'Svelte 5',
icon: 'svelte',
files: svelte5Template
}
]
let templatePicker = $state(nodraft != null && !importRaw)
let reloadCounter = $state(0)
// Modal state
let selectedTemplateIndex = $state(0)
let tableCreationEnabled = $state(true)
let selectedDatatable = $state<string | undefined>(undefined)
let schemaMode = $state<'none' | 'new' | 'existing'>('new')
let selectedSchema = $state<string | undefined>(undefined)
let newSchemaName = $state('')
let appSummary = $state('')
let initialPrompt = $state('')
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
// Pre-whitelisted tables for the app
let preWhitelistedTables = $state<DataTableRef[]>([])
// Load available datatables and schemas using shared utilities
const datatables = createDatatablesResource(() => $workspaceStore)
const schemas = createSchemasResource(() => selectedDatatable)
// Derived value to force reactivity on datatables.current
const availableDatatables = $derived(datatables.current)
const availableSchemas = $derived(schemas.current)
// Auto-select datatable: prefer "main" if available, otherwise first one
// Only runs once when datatables first load (selectedDatatable is undefined)
let hasAutoSelected = false
$effect(() => {
if (availableDatatables?.length > 0 && !hasAutoSelected) {
hasAutoSelected = true
if (availableDatatables.includes('main')) {
selectedDatatable = 'main'
} else {
selectedDatatable = availableDatatables[0]
}
}
})
// Generate unique schema name (appX where X is first unused number)
function generateUniqueSchemaName(existingSchemas: string[]): string {
let num = 1
while (existingSchemas.includes(`app${num}`)) {
num++
}
return `app${num}`
}
// Check if new schema name already exists
const newSchemaAlreadyExists = $derived(
schemaMode === 'new' &&
newSchemaName.trim() !== '' &&
(availableSchemas ?? []).includes(newSchemaName.trim())
)
// Track if the user has manually edited the schema name
let userEditedSchemaName = $state(false)
// Set default new schema name when schemas load or when switching to new mode
// Also auto-fix if the current name exists and was auto-generated (not user-edited)
$effect(() => {
const schemas = availableSchemas ?? []
if (schemaMode === 'new') {
if (!newSchemaName) {
// Initial load: set default name
newSchemaName = generateUniqueSchemaName(schemas)
userEditedSchemaName = false
} else if (!userEditedSchemaName && schemas.includes(newSchemaName)) {
// Auto-generated name now exists (schemas reloaded), regenerate
newSchemaName = generateUniqueSchemaName(schemas)
}
}
})
// Reset schema when datatable changes
let previousDatatable = $state<string | undefined>(undefined)
$effect(() => {
if (previousDatatable !== undefined && selectedDatatable !== previousDatatable) {
selectedSchema = undefined
newSchemaName = ''
userEditedSchemaName = false
}
previousDatatable = selectedDatatable
})
// Update AI prompt when summary changes
$effect(() => {
if (appSummary.trim() && isAiEnabled) {
initialPrompt = `Build ${appSummary.trim()}`
}
})
const datatableItems = $derived(toDatatableItems(availableDatatables))
const schemaItems = $derived(toSchemaItems(availableSchemas))
// The effective schema to use (either selected existing, new schema name, or undefined for none)
const effectiveSchema = $derived(
schemaMode === 'new' ? newSchemaName : schemaMode === 'existing' ? selectedSchema : undefined
)
const hasNoDatatables = $derived(availableDatatables?.length === 0)
const isAiEnabled = $derived($copilotInfo.enabled)
async function startApp(withPrompt: boolean) {
const template = templates[selectedTemplateIndex]
if (template.files) {
files = template.files
reloadCounter += 1
}
// Set summary
summary = appSummary.trim()
// Create new schema if needed
if (schemaMode === 'new' && newSchemaName && selectedDatatable && $workspaceStore) {
try {
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
const dbOps = dbSchemaOpsWithPreviewScripts({
workspace: $workspaceStore,
input: {
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${selectedDatatable}`
}
})
await dbOps.onCreateSchema({ schema: newSchemaName })
} catch (e) {
console.error('Failed to create schema:', e)
sendUserToast(`Failed to create schema: ${e}`, true)
}
}
// Set the data configuration including pre-whitelisted tables
const formattedTables = preWhitelistedTables.map(formatDataTableRef)
if (tableCreationEnabled && selectedDatatable) {
data = {
tables: formattedTables,
datatable: selectedDatatable,
schema: effectiveSchema
}
} else {
data = {
tables: formattedTables,
datatable: undefined,
schema: undefined
}
}
// Sync to aiChatManager
aiChatManager.datatableCreationPolicy = {
enabled: tableCreationEnabled && !!selectedDatatable,
datatable: tableCreationEnabled ? selectedDatatable : undefined,
schema: tableCreationEnabled ? effectiveSchema : undefined
}
templatePicker = false
// Remove nodraft from URL
const url = new URL(window.location.href)
if (url.searchParams.has('nodraft')) {
url.searchParams.delete('nodraft')
window.history.replaceState({}, '', url.toString())
}
// If starting with a prompt, trigger AI after a short delay for the editor to initialize
if (withPrompt && initialPrompt.trim() && isAiEnabled) {
setTimeout(() => {
aiChatManager.changeMode(AIMode.APP)
if (!aiChatManager.open) {
aiChatManager.toggleOpen()
}
aiChatManager.instructions = initialPrompt.trim()
aiChatManager.sendRequest()
}, 500)
}
}
</script>
{#if templatePicker}
<Modal kind="X" open title="New App setup">
<div class="flex flex-col gap-6 min-w-sm">
<!-- Summary -->
<div>
<h2 class="text-xs font-semibold text-emphasis mb-1">Summary</h2>
<TextInput
bind:value={appSummary}
inputProps={{
placeholder: "Brief description of the app (e.g., 'Todo list with authentication')"
}}
/>
</div>
<!-- Template Selection -->
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Framework</h2>
<div class="flex flex-wrap gap-3">
{#each templates as t, i}
<button
onclick={() => (selectedTemplateIndex = i)}
class="w-24 h-24 flex justify-between py-5 flex-col {selectedTemplateIndex === i
? 'bg-surface-accent-selected border border-accent'
: ''} hover:bg-surface-hover border rounded-lg transition-all"
>
<div class="w-full flex items-center justify-center">
<FileEditorIcon file={'.' + t.icon} size={32} />
</div>
<div class="center-center w-full text-sm text-secondary">{t.name}</div>
</button>
{/each}
</div>
</div>
<!-- Data Configuration -->
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Data configuration</h2>
{#if hasNoDatatables}
<Alert type="warning" title="No datatables configured.">
You can still create an app, but for data storage you won't be able to use data tables
which are <b>highly recommended</b>.
<br />
{#if $userStore?.is_admin}
Configure datatables in
<a
href="/workspace_settings?tab=windmill_data_tables"
target="_blank"
class="inline-flex items-center gap-1"
>workspace settings <ExternalLinkIcon size={16} />
</a> to enable this feature.
{:else}
Ask your workspace admin to configure datatables in workspace settings to enable this
feature.
{/if}
</Alert>
{:else}
<div class="flex flex-col gap-4">
<!-- Default Datatable & Schema -->
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary mb-1 block">Default settings for new tables</span>
<div class="flex flex-col gap-4 rounded-md p-4 border">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-xs text-emphasis font-semibold" for="datatable"
>Datatable</label
>
<Select
id="datatable"
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
size="sm"
class="w-40"
/>
</div>
<div>
<span class="text-xs text-emphasis font-semibold">Schema</span>
<div class="flex flex-row gap-1 w-full items-center">
<div>
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
{#snippet children({ item })}
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
<ToggleButton value="new" label="New" icon={Plus} {item} size="sm" />
<ToggleButton
value="existing"
label="Existing"
icon={List}
{item}
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{#if schemaMode === 'new'}
<TextInput
bind:value={newSchemaName}
inputProps={{
placeholder: 'Schema name',
oninput: () => (userEditedSchemaName = true)
}}
class="flex-1"
error={newSchemaAlreadyExists}
size="sm"
/>
{:else if schemaMode === 'existing'}
<div class="flex-1">
<Select
disablePortal
items={schemaItems}
bind:value={selectedSchema}
placeholder="Schema"
size="sm"
/>
</div>
{/if}
</div>
{#if newSchemaAlreadyExists}
<span class="text-xs text-red-500"
>Schema "{newSchemaName}" already exists</span
>
{/if}
</div>
</div>
</div>
</div>
<!-- Table Creation Toggle -->
<div class="flex items-center">
<Toggle
size="sm"
bind:checked={tableCreationEnabled}
options={{ right: 'Allow AI to create new tables' }}
/>
</div>
<!-- Pre-whitelisted Tables -->
<div class="pt-6">
<RawAppDataTableList
dataTableRefs={preWhitelistedTables}
defaultDatatable={selectedDatatable}
defaultSchema={effectiveSchema}
standalone
hideDefaultSelector
onAdd={() => dataTableDrawer?.openDrawer()}
onRemove={(index) => {
preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index)
}}
/>
</div>
</div>
{/if}
</div>
<!-- AI Prompt (Optional) -->
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1 flex items-center gap-2">
<Sparkles size={16} class="text-ai" />
Start with AI
<span class="text-xs font-normal text-tertiary">(optional)</span>
</h2>
{#if !isAiEnabled}
<Alert type="info" title="AI is not configured for this workspace.">
You can still create an app manually but using AI is highly recommended.
<br />
{#if $userStore?.is_admin}
Configure AI in
<a
href="/workspace_settings?tab=ai"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>workspace settings <ExternalLinkIcon size={16} />
</a>
to enable this feature.
{:else}
Ask your workspace admin to configure AI in workspace settings to enable this feature.
{/if}
</Alert>
{:else}
<div class="flex flex-col gap-2">
<TextInput
underlyingInputEl="textarea"
bind:value={initialPrompt}
inputProps={{
rows: 3,
placeholder:
"Describe what you want to build... (e.g., 'Create a todo list app with user authentication')"
}}
/>
<p class="text-xs text-tertiary">
Leave empty to start with a blank template, or describe your app to get AI assistance
right away.
</p>
</div>
{/if}
</div>
<!-- Actions -->
<div class="pt-6 flex justify-end gap-3">
<Button
variant="default"
size="sm"
on:click={() => startApp(false)}
disabled={!templates[selectedTemplateIndex] || newSchemaAlreadyExists}
>
Start without AI
</Button>
{#if isAiEnabled}
<Button
variant="accent"
on:click={() => startApp(true)}
disabled={!templates[selectedTemplateIndex] ||
!initialPrompt.trim() ||
newSchemaAlreadyExists}
startIcon={{ icon: Sparkles }}
btnClasses={AIBtnClasses('accent')}
>
Start with AI
</Button>
{/if}
</div>
</div>
</Modal>
{/if}
{#key reloadCounter}
<RawAppEditor
on:savedNewAppPath={(event) => {
goto(`/apps_raw/edit/${event.detail}`)
}}
bind:files
bind:runnables
bind:data
{policy}
path={''}
liveEditorDraftStoragePath=""
bind:summary
newApp
/>
{/key}
<RawAppDataTableDrawer
bind:this={dataTableDrawer}
offset={10000}
existingRefs={preWhitelistedTables}
onAdd={(ref) => {
preWhitelistedTables = [...preWhitelistedTables, ref]
}}
/>
@@ -56,17 +56,6 @@
let redraw = $state(0)
let path = page.params.path ?? ''
// `?nodraft=true` is the callers' way of saying "skip the local autosave
// on this load." Wipe the UserDraft entry and strip the flag from the
// URL synchronously, before the handle is created. A plain reload (no
// nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('raw_app', path)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
const draftHandle = UserDraft.use<RawAppDraft>('raw_app', path)
// Local-draft staleness modal: opened when the remote has moved on since
@@ -175,10 +164,29 @@
let loadAppToken = 0
async function loadApp(): Promise<void> {
const tok = ++loadAppToken
// `?new_draft=true` (set by `/apps_raw/add`'s redirect) means we
// landed on a fresh `draft_{uuid}` path that's never been saved.
// Skip the backend fetch (it would 404), seed an empty raw app,
// strip the flag. `rawApp: true` on subsequent reloads tells the
// backend to look up the `raw_app` kind in the draft table.
if (page.url.searchParams.get('new_draft') === 'true') {
const url = new URL(window.location.href)
url.searchParams.delete('new_draft')
window.history.replaceState(window.history.state, '', url.toString())
savedApp = {
summary: '',
value: { files: {}, runnables: {} },
path: page.params.path ?? '',
policy: {},
custom_path: undefined
}
return
}
const backendApp = await AppService.getAppByPath({
path: page.params.path ?? '',
workspace: $workspaceStore!,
getDraft: true
getDraft: true,
rawApp: true
})
if (tok !== loadAppToken) return
if (backendApp.is_draft) {
@@ -1,208 +1,15 @@
<script lang="ts">
// `/flows/add` is a thin redirect onto the canonical editor at
// `/flows/edit/draft_{uuid}?new_draft=true`. See /scripts/add for the
// design rationale — all editor logic lives in /flows/edit/[...path].
import { goto } from '$lib/navigation'
import { page } from '$app/state'
import { onMount } from 'svelte'
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import { importFlowStore, initFlow } from '$lib/components/flows/flowStore.svelte'
import { FlowService, type Flow } from '$lib/gen'
import { initialArgsStore, userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { decodeState, emptySchema, type StateStore } from '$lib/utils'
import { tick } from 'svelte'
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
import type { Trigger } from '$lib/components/triggers/utils'
import { UserDraft } from '$lib/userDraft.svelte'
// "+ Flow" buttons navigate with ?nodraft=true to signal "start fresh".
// Wipe the persisted empty-path autosave and strip the flag from the URL
// synchronously so a reload doesn't wipe the freshly-started draft. A
// plain reload of /flows/add (no nodraft) instead restores whatever the
// user was last working on.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('flow', '')
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
const hubId = page.url.searchParams.get('hub')
const templatePath = page.url.searchParams.get('template')
const templateId = page.url.searchParams.get('template_id')
const isFork = page.url.searchParams.get('fork')
let forkState: any = undefined
if (isFork) {
const forkJson = localStorage.getItem('fork_flow')
if (forkJson) {
try {
forkState = JSON.parse(forkJson)
} catch {}
localStorage.removeItem('fork_flow')
} else if ((window.opener as any)?.__forkPreviewData) {
forkState = (window.opener as any).__forkPreviewData
delete (window.opener as any).__forkPreviewData
}
}
let selectedId: string = $state('settings-metadata')
let loading = $state(false)
let initialPath: string | undefined = $state(undefined)
let pathStoreInit: string | undefined = $state(undefined)
let initialArgs = $state({})
if ($initialArgsStore) {
initialArgs = $initialArgsStore
$initialArgsStore = undefined
}
// initialArgs may also be set from decoded state below (e.g. fork preview)
let flowBuilder: FlowBuilder | undefined = $state(undefined)
function emptyFlow(): Flow {
return {
summary: '',
value: { modules: [] },
path: '',
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {},
schema: emptySchema()
}
}
const flowHandle = UserDraft.use<Flow>('flow', '', { defaultValue: emptyFlow() })
const flowStore: StateStore<Flow> = {
get val() {
return flowHandle.draft ?? emptyFlow()
},
set val(v: Flow) {
flowHandle.draft = v
}
}
const flowStateStore = $state({ val: {} })
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
async function loadFlow() {
loading = true
// Start from the persisted autosave, not a fresh `emptyFlow()`. The
// branches below override `flow` when the user explicitly asked for a
// different starting point (import/fork/URL state/template/hub); a
// plain reload of /flows/add (no query params) falls through with
// the LS value intact so the user's last session is restored.
let flow: Flow = flowHandle.draft ?? emptyFlow()
let state = forkState
const initialStateQuery = page.url.hash != '' ? page.url.hash.slice(1) : undefined
if (initialStateQuery) {
state = decodeState(initialStateQuery)
}
if ($importFlowStore) {
flow = $importFlowStore
$importFlowStore = undefined
sendUserToast('Flow loaded from YAML/JSON')
} else if (!templatePath && !hubId && state) {
flow = state.flow
pathStoreInit = state.path
if (state.initialArgs) {
initialArgs = state.initialArgs
}
draftTriggersFromUrl = state.draft_triggers
selectedTriggerIndexFromUrl = state.selected_trigger
flowBuilder?.setDraftTriggers(draftTriggersFromUrl)
flowBuilder?.setSelectedTriggerIndex(selectedTriggerIndexFromUrl)
state?.selectedId && (selectedId = state?.selectedId)
} else {
if (templatePath) {
let template: Flow
if (templateId) {
template = await FlowService.getFlowVersion({
workspace: $workspaceStore!,
version: parseInt(templateId)
})
} else {
template = await FlowService.getFlowByPath({
workspace: $workspaceStore!,
path: templatePath
})
}
// Template/hub flows are an explicit "start fresh from this
// content" — drop any previous empty-path autosave and use
// the freshly built flow as the baseline.
flow = emptyFlow()
Object.assign(flow, template)
const oldPath = templatePath.split('/')
initialPath = `u/${$userStore?.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')}/${
oldPath[oldPath.length - 1]
}_fork`
goto('?', { replaceState: true })
selectedId = 'settings-metadata'
} else if (hubId) {
const hub = await FlowService.getHubFlowById({ id: Number(hubId) })
delete hub['comments']
initialPath = `u/${$userStore?.username
.split('@')[0]
.replace(/[^a-zA-Z0-9_]/g, '')}/flow_${hubId}`
flow = emptyFlow()
Object.assign(flow, hub.flow)
if (flow.value.preprocessor_module?.value.type === 'rawscript') {
flow.value.preprocessor_module.value.content = replaceScriptPlaceholderWithItsValues(
hubId,
flow.value.preprocessor_module.value.content
)
}
goto('?', { replaceState: true })
selectedId = 'constants'
}
}
await initFlow(flow, flowStore, flowStateStore)
flowBuilder?.loadFlowState()
loading = false
// Trigger tutorial after everything is initialized
const tutorialParam = page.url.searchParams.get('tutorial')
if (tutorialParam) {
// Wait for critical elements to be ready before triggering tutorial
await tick()
let attempts = 0
while (attempts < 20 && !document.querySelector('#flow-editor-virtual-Input')) {
await new Promise((resolve) => setTimeout(resolve, 100))
attempts++
}
flowBuilder?.triggerTutorial()
}
}
loadFlow()
onMount(() => {
const uuid = crypto.randomUUID()
const params = new URLSearchParams(page.url.searchParams)
params.set('new_draft', 'true')
goto(`/flows/edit/draft_${uuid}?${params.toString()}`, { replaceState: true })
})
</script>
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<FlowBuilder
onDeploy={(e) => {
UserDraft.remove('flow', '')
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onDetails={(e) => {
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onNavigate={(item) => goto(editPathFor(item))}
{initialPath}
{pathStoreInit}
liveEditorDraftStoragePath=""
bind:this={flowBuilder}
newFlow
{initialArgs}
{flowStore}
{flowStateStore}
{selectedId}
{loading}
{draftTriggersFromUrl}
{selectedTriggerIndexFromUrl}
noInitial
/>
@@ -52,17 +52,6 @@
// Derived so client-side nav (breadcrumb) re-keys the handle to the new path.
let flowDraftPath = $derived(page.params.path ?? '')
// `?nodraft=true` is the callers' way of saying "skip the local autosave
// on this load." Wipe the UserDraft entry and strip the flag from the
// URL synchronously, before the handle is created — same pattern as
// /flows/add. A plain reload (no nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('flow', flowDraftPath)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
// `useMany` keyed off the reactive `flowDraftPath` re-keys the handle on nav;
// `flowHandle` proxies the current handle so `flowStore` keeps a fixed ref.
const flowHandles = UserDraft.useMany<Flow>(() => [{ itemKind: 'flow', path: flowDraftPath }])
@@ -167,6 +156,34 @@
// so flowBuilder is unmounted and direct calls would no-op.
let draftTriggersToApply: Trigger[] | undefined = undefined
let applyPrimarySchedule = false
// `?new_draft=true` (set by `/flows/add`'s redirect) means we
// landed on a fresh `draft_{uuid}` path that's never been saved.
// Skip both the latest-version and the get-by-path fetches (they
// would 404), seed an empty Flow, strip the single-use flag.
if (page.url.searchParams.get('new_draft') === 'true') {
const url = new URL(window.location.href)
url.searchParams.delete('new_draft')
window.history.replaceState(window.history.state, '', url.toString())
const empty: Flow = {
path: page.params.path ?? '',
summary: '',
description: '',
value: { modules: [] },
schema: {},
extra_perms: {},
edited_at: new Date().toISOString(),
edited_by: ''
} as unknown as Flow
savedFlow = structuredClone(empty)
flowHandle.setDraftAndMeta(empty, {})
flow = empty
await initFlow(flow, flowStore, flowStateStore)
if (tok !== loadFlowToken) return
loading = false
selectedId = page.url.searchParams.get('selected') ?? 'settings-metadata'
renderEditor = true
return
}
// Currently there is no way to get version of flow with flow.
// So we have to request it here
const v = (
@@ -329,7 +329,7 @@
onClick: async () => {
const app = createAppFromFlow(flow.path, flow.schema)
$importStore = JSON.parse(JSON.stringify(app))
await goto('/apps/add?nodraft=true')
await goto('/apps/add')
},
unifiedSize: 'md',
variant: 'subtle',
@@ -1,290 +1,23 @@
<script lang="ts">
import { type NewScript, ScriptService, type ScriptLang } from '$lib/gen'
import { page } from '$app/state'
import { defaultScripts, initialArgsStore, workspaceStore } from '$lib/stores'
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import type { Schema } from '$lib/common'
import {
cleanValueProperties,
decodeState,
emptySchema,
emptyString,
encodeState,
orderedJsonStringify,
readFieldsRecursively,
sendUserToast
} from '$lib/utils'
// `/scripts/add` is a thin redirect onto the canonical editor at
// `/scripts/edit/draft_{uuid}?new_draft=true`. All editor logic — fetch,
// seed handling, autosave wiring — lives in `/scripts/edit/[...path]`;
// keeping it in one place means a single source of truth for the
// editor's load lifecycle.
//
// `new_draft=true` tells the edit page "first mount, don't fetch the
// non-existent item; seed empty." The flag is consumed and stripped
// from the URL by the edit page on first render. Any other query
// params (`template`, `hub`, `tutorial`, ...) ride along untouched so
// existing entry-points keep working.
import { goto } from '$lib/navigation'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
import type { Trigger } from '$lib/components/triggers/utils'
import { get } from 'svelte/store'
import { untrack } from 'svelte'
import ScriptEditorSkeleton from '$lib/components/ScriptEditorSkeleton.svelte'
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow'
import { UserDraft } from '$lib/userDraft.svelte'
import { page } from '$app/state'
import { onMount } from 'svelte'
type Script = NewScript & {
draft_triggers?: Trigger[]
hash?: string
extra_perms?: Record<string, any>
}
// Default
let schema: Schema = emptySchema()
const templatePath = page.url.searchParams.get('template')
const hubPath = page.url.searchParams.get('hub')
const showMeta = /true|1/i.test(page.url.searchParams.get('show_meta') ?? '0')
const urlArgs = page.url.searchParams.get('initial_args')
const collabLang = page.url.searchParams.get('lang') as ScriptLang | null
const wacParam = page.url.searchParams.get('wac')
const importParam = page.url.searchParams.get('import')
/** Some pages (run/[...run]'s "Fork" action, workspace_settings'
* error/success-handler template buttons) base64-JSON-encode a NewScript
* payload into the URL hash. That value is an explicit "open this script"
* intent and wins over local autosave, templates, hubs, and YAML imports.
*
* We can't use `decodeState` from utils.ts directly — it fires its own
* "Impossible to parse state" toast on failure, which would noise up the
* UI when the hash isn't a script payload at all (e.g. a route anchor).
*/
function decodeUrlScript(): Partial<Script> | undefined {
const fragment = page.url.hash.startsWith('#') ? page.url.hash.slice(1) : ''
if (!fragment) return undefined
try {
const decoded = JSON.parse(decodeURIComponent(atob(fragment)))
if (decoded && typeof decoded === 'object') return decoded as Partial<Script>
} catch {
// Hash isn't a valid encoded script — ignore.
}
return undefined
}
const urlScript = decodeUrlScript()
// "+ Script" buttons navigate with ?nodraft=true to signal "start fresh".
// Wipe the persisted empty-path autosave and strip the flag from the URL
// synchronously so a reload doesn't wipe the freshly-started draft. A
// plain reload of /scripts/add (no nodraft) instead restores the
// previous session.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('script', '')
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
let initialArgs = urlArgs ? decodeState(urlArgs) : (get(initialArgsStore) ?? {})
if (get(initialArgsStore)) $initialArgsStore = undefined
const path = page.url.searchParams.get('path')
function defaultScript(): Script {
return {
hash: '',
path: path ?? '',
summary: '',
content: '',
description: '',
schema: schema,
is_template: false,
extra_perms: {},
language:
(wacParam === 'python' ? 'python3' : wacParam === 'typescript' ? 'bun' : null) ??
collabLang ??
(($defaultScripts?.order?.filter(
(x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x)
)?.[0] ?? 'bun') as ScriptLang),
kind: 'script'
}
}
// templatePath/hubPath/import/url-hash flows replace the value before
// render, so defaultValue is left undefined for those to avoid flashing a
// blank editor.
const scriptHandle = UserDraft.use<Script>('script', '', {
defaultValue: templatePath || hubPath || urlScript ? undefined : defaultScript()
})
$effect(() => {
if (!$workspaceStore) return
const workspace = $workspaceStore
UserDraft.setLiveEditorDraft({
workspace,
itemKind: 'script',
storagePath: '',
effectivePath: scriptHandle.draft?.path
})
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: '' })
})
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
// Legacy behavior: the URL hash both seeds the editor on load AND stays in
// sync with edits (encoded back into the hash, debounced). Asks the user
// via modal when the URL payload would clobber an existing local autosave.
let urlConflictModalOpen = $state(false)
let pendingUrlPayload: Script | undefined = undefined
if (urlScript) {
const seeded = { ...defaultScript(), ...urlScript } as Script
const existing = scriptHandle.draft
if (existing) {
const localClean = orderedJsonStringify(cleanValueProperties(existing))
const seededClean = orderedJsonStringify(cleanValueProperties(seeded))
if (localClean !== seededClean) {
pendingUrlPayload = seeded
urlConflictModalOpen = true
}
} else {
scriptHandle.draft = seeded
sendUserToast('Loaded from URL')
}
}
function onUrlConflictUseUrl() {
if (pendingUrlPayload) {
scriptHandle.draft = pendingUrlPayload
sendUserToast('Loaded from URL')
}
pendingUrlPayload = undefined
urlConflictModalOpen = false
}
function onUrlConflictKeepLocal() {
pendingUrlPayload = undefined
urlConflictModalOpen = false
}
let _urlHashSyncTimeout: number | undefined
$effect(() => {
const draft = scriptHandle.draft
if (!draft) return
// Gate while the conflict modal is open so we don't overwrite the URL
// payload before the user has decided.
if (urlConflictModalOpen) return
readFieldsRecursively(draft)
if (typeof window === 'undefined') return
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
_urlHashSyncTimeout = setTimeout(() => {
const snapshot = $state.snapshot(scriptHandle.draft)
if (!snapshot) return
const url = new URL(window.location.href)
url.hash = encodeState(snapshot)
window.history.replaceState(window.history.state, '', url.toString())
}, 500)
})
// === END TEMP URL-HASH SYNC ===
async function loadTemplate(): Promise<void> {
if (urlScript) return
if (templatePath) {
try {
const template = await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
path: templatePath
})
scriptHandle.draft = {
...defaultScript(),
summary: !emptyString(template.summary) ? `Copy of ${template.summary}` : '',
description: template.description,
content: template.content,
schema: template.schema,
language: template.language,
path: template.path + '_fork'
}
} catch (err) {
scriptHandle.draft = defaultScript()
console.error('Error loading template', err)
sendUserToast('Error loading template: ' + err.message, true)
}
}
}
async function loadHub(): Promise<void> {
if (urlScript) return
if (hubPath) {
try {
const { content, language, summary } = await ScriptService.getHubScriptByPath({
path: hubPath
})
scriptHandle.draft = {
...defaultScript(),
description: `Fork of ${hubPath}`,
content: replaceScriptPlaceholderWithItsValues(hubPath, content),
summary: summary ?? '',
language: language as Script['language'],
path: hubPath + '_fork'
}
} catch (err) {
scriptHandle.draft = defaultScript()
console.error('Error loading script from hub', err)
sendUserToast('Error loading script from hub: ' + err.message, true)
}
}
}
loadHub()
let importedWacTemplate: 'wac_python' | 'wac_typescript' | undefined = undefined
if (!urlScript && importParam && $importScriptStore) {
const imported = $importScriptStore
$importScriptStore = undefined
const isWac = isWorkflowAsCode(imported.content ?? '', imported.language ?? '')
scriptHandle.draft = {
...defaultScript(),
...imported,
path: path ?? '',
hash: '',
extra_perms: {}
}
if (isWac) {
importedWacTemplate = imported.language === 'python3' ? 'wac_python' : 'wac_typescript'
sendUserToast('WAC script loaded from YAML/JSON')
} else {
sendUserToast('Script loaded from YAML/JSON')
}
}
$effect(() => {
if ($workspaceStore) {
untrack(() => loadTemplate())
}
onMount(() => {
const uuid = crypto.randomUUID()
const params = new URLSearchParams(page.url.searchParams)
params.set('new_draft', 'true')
goto(`/scripts/edit/draft_${uuid}?${params.toString()}`, { replaceState: true })
})
</script>
<!-- TEMP URL-HASH SYNC: conflict modal (remove with future PR) -->
<LocalDraftStaleModal
open={urlConflictModalOpen}
cause="url"
onLoadLatest={onUrlConflictUseUrl}
onKeepDraft={onUrlConflictKeepLocal}
/>
{#if scriptHandle.draft}
<ScriptBuilder
{initialArgs}
lockedLanguage={templatePath != null || hubPath != null}
template={importedWacTemplate ??
(wacParam === 'python'
? 'wac_python'
: wacParam === 'typescript'
? 'wac_typescript'
: 'script')}
onDeploy={(e) => {
// "Deploy & Stay here" / lib: stay on the editor (just confirm).
if (e.stay) {
sendUserToast('Deployed')
return
}
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
onNavigate={(item) => goto(editPathFor(item))}
searchParams={page.url.searchParams}
bind:script={scriptHandle.draft}
{showMeta}
/>
{:else}
<ScriptEditorSkeleton />
{/if}
@@ -197,6 +197,28 @@
async function loadScript(): Promise<void> {
const tok = ++loadScriptToken
fullyLoaded = false
// `?new_draft=true` (set by `/scripts/add`'s redirect) means we
// landed on a fresh `draft_{uuid}` path that's never been saved
// anywhere. Skip the backend fetch (it would 404), seed an empty
// `NewScript`, and strip the single-use flag from the URL.
if (page.url.searchParams.get('new_draft') === 'true') {
const url = new URL(window.location.href)
url.searchParams.delete('new_draft')
window.history.replaceState(window.history.state, '', url.toString())
const empty: EditableScript = {
path: page.params.path ?? '',
summary: '',
description: '',
content: '',
language: 'bun',
schema: {}
} as unknown as EditableScript
savedScript = structuredClone(empty)
scriptHandle.setDraftAndMeta(empty, {})
fullyLoaded = true
renderEditor = true
return
}
if (hash) {
const scriptByHash = await ScriptService.getScriptByHash({
workspace: $workspaceStore!,
@@ -422,7 +422,7 @@
onClick: async () => {
const app = createAppFromScript(script.path, script.schema)
$importStore = JSON.parse(JSON.stringify(app))
await goto('/apps/add?nodraft=true')
await goto('/apps/add')
},
disabled: !showEditButtons,
unifiedSize: 'md',
@@ -118,7 +118,7 @@
<Button size="xs" variant="default" target="_blank" href="/scripts/add"
>Create new script</Button
>
<Button size="xs" variant="default" target="_blank" href="/flows/add?nodraft=true"
<Button size="xs" variant="default" target="_blank" href="/flows/add"
>Create new flow</Button
>
</div>