From da692257da8b4b19ff27dbc545b4298679b64cba Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 11 May 2026 14:43:49 +0200 Subject: [PATCH] feat(frontend): wire script editor to UserDraft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script editor's top-level state now lives in UserDraft.use(), keyed on the route's path (page.params.path on /scripts/edit, '' on /scripts/add). Deep edits inside ScriptBuilder persist automatically; deploy and draft restore now call UserDraft.remove to clear the local autosave alongside the backend draft. Replaces the URL-hash autosave that ScriptBuilder used to write via replaceStateFn — that prop is now gone, the encodeScriptState debounce is gone, and Triggers no longer takes a saveSessionDraft callback. Viewing a specific historical hash (?hash=...) is kept draft-free by passing '' as the path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/components/ScriptBuilder.svelte | 40 +--- frontend/src/lib/components/script_builder.ts | 1 - .../(root)/(logged)/scripts/add/+page.svelte | 61 +++-- .../scripts/edit/[...path]/+page.svelte | 214 +++++++++--------- 4 files changed, 135 insertions(+), 181 deletions(-) diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index abe6be0615..78c950e3d9 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -4,7 +4,6 @@ const bubble = createBubbler() import { DraftService, - type NewScript, ScriptService, type NewScriptWithDraft, type Script, @@ -35,7 +34,6 @@ cleanValueProperties, emptySchema, emptyString, - encodeState, generateRandomString, orderedJsonStringify, readFieldsRecursively, @@ -141,7 +139,6 @@ savedScript = $bindable(undefined), searchParams = new URLSearchParams(), disableHistoryChange = false, - replaceStateFn = (url) => window.history.replaceState(null, '', url), customUi = {}, savedPrimarySchedule = undefined, functionExports = undefined, @@ -288,15 +285,11 @@ // Add triggers context store const triggersState = $state( - new Triggers( - [ - { type: 'webhook', path: '', isDraft: false }, - { type: 'default_email', path: '', isDraft: false }, - ...(script.draft_triggers ?? []) - ], - undefined, - saveSessionDraft - ) + new Triggers([ + { type: 'webhook', path: '', isDraft: false }, + { type: 'default_email', path: '', isDraft: false }, + ...(script.draft_triggers ?? []) + ]) ) const captureOn = writable(undefined) @@ -360,28 +353,6 @@ let loadingSave = $state(false) let loadingDraft = $state(false) - let timeout2: number | undefined = undefined - function encodeScriptState(script: NewScript) { - untrack(() => timeout2 && clearTimeout(timeout2)) - timeout2 = setTimeout(() => { - replaceStateFn( - '#' + - encodeState({ - ...script, - draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot()) - }) - ) - }, 500) - } - - let timeout: number | undefined = undefined - function saveSessionDraft() { - timeout && clearTimeout(timeout) - timeout = setTimeout(() => { - encodeScriptState(script) - }, 500) - } - if (script.content == '') { if (template === 'wac_python') { script.modules = { @@ -1059,7 +1030,6 @@ }) $effect(() => { readFieldsRecursively(script) - !disableHistoryChange && encodeScriptState(script) }) loadWorkerTags() diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index 24d8e69d32..6c71dbddbf 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -31,7 +31,6 @@ export interface ScriptBuilderProps { savedScript?: NewScriptWithDraftAndDraftTriggers | undefined searchParams?: URLSearchParams disableHistoryChange?: boolean - replaceStateFn?: (url: string) => void customUi?: ScriptBuilderWhitelabelCustomUi savedPrimarySchedule?: ScheduleTrigger | undefined functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte index 8f43dde796..6483becf5b 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte @@ -7,7 +7,6 @@ import type { Schema } from '$lib/common' import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils' import { goto } from '$lib/navigation' - import { replaceState } from '$app/navigation' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import { replaceScriptPlaceholderWithItsValues } from '$lib/hub' import type { Trigger } from '$lib/components/triggers/utils' @@ -16,6 +15,7 @@ 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' type Script = NewScript & { draft_triggers?: Trigger[] @@ -39,20 +39,8 @@ const path = page.url.searchParams.get('path') - const initialState = page.url.hash != '' ? page.url.hash.slice(1) : undefined - let scriptBuilder: ScriptBuilder | undefined = $state(undefined) - function decodeStateAndHandleError(state) { - try { - const decoded = decodeState(state) - return decoded - } catch (e) { - console.error('Error decoding state', e) - return defaultScript() - } - } - function defaultScript(): Script { return { hash: '', @@ -63,20 +51,22 @@ 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, + 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' } } - let script: Script | undefined = $state( - templatePath || hubPath - ? undefined - : !path && initialState != undefined - ? decodeStateAndHandleError(initialState) - : defaultScript() - ) + // New script: keyed on '' (in-memory only — empty paths bypass localStorage). + // templatePath/hubPath/import flows replace the value before render, so + // defaultValue is left undefined for those to avoid flashing a blank editor. + const scriptHandle = UserDraft.use -{#if script} +{#if scriptHandle.draft} { goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} @@ -167,9 +161,8 @@ goto(`/scripts/edit/${e.path}`) }} searchParams={page.url.searchParams} - bind:script + bind:script={scriptHandle.draft} {showMeta} - replaceStateFn={(path) => replaceState(path, page.state)} > ('script', draftPath) let initialPath: string = $state('') let scriptBuilder: ScriptBuilder | undefined = $state(undefined) - let reloadAction: () => Promise = async () => {} - let savedScript: NewScriptWithDraft | undefined = $state(undefined) let fullyLoaded = $state(false) @@ -40,28 +40,87 @@ async function loadScript(): Promise { fullyLoaded = false - if (scriptLoadedFromUrl != undefined && scriptLoadedFromUrl.path == page.params.path) { - script = scriptLoadedFromUrl - reloadAction = async () => { - scriptLoadedFromUrl = undefined - goto(`/scripts/edit/${script!.path}`) - loadScript() - } + if (hash) { + const scriptByHash = await ScriptService.getScriptByHash({ + workspace: $workspaceStore!, + hash + }) + savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft + scriptHandle.draft = { ...scriptByHash, parent_hash: hash, lock: undefined } + } else { + const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({ + workspace: $workspaceStore!, + path: page.params.path ?? '' + }) + savedScript = structuredClone($state.snapshot(scriptWithDraft)) - async function compareAutosave() { - savedScript = await ScriptService.getScriptByPathWithDraft({ - workspace: $workspaceStore!, - path: script!.path - }) + const localDraft = scriptHandle.draft + const backendDraft = scriptWithDraft.draft + ? ({ ...scriptWithDraft.draft } as EditableScript) + : undefined - const draftOrDeployed = cleanValueProperties(savedScript?.draft || savedScript) - const urlScript = cleanValueProperties(scriptLoadedFromUrl) - if (orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(urlScript)) { - reloadAction() + if (localDraft != undefined) { + // Local autosave wins; offer a diff against the latest saved + // version (backend draft if any, otherwise deployed) so the + // user can discard it. + const reference = backendDraft ?? scriptWithDraft + const referenceClean = cleanValueProperties(reference) + const localClean = cleanValueProperties(localDraft) + if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) { + // Local matches the saved version — silently drop it and use the saved one. + scriptHandle.draft = backendDraft ?? (scriptWithDraft as EditableScript) } else { - sendUserToast('Script loaded from latest autosave stored in the URL', false, [ + sendUserToast('Script loaded from local autosave', false, [ { - label: 'Discard browser stored autosave and reload', + label: 'Discard local autosave', + callback: () => { + scriptHandle.draft = backendDraft ?? (scriptWithDraft as EditableScript) + } + }, + { + label: 'Show diff', + callback: async () => { + diffDrawer?.openDrawer() + diffDrawer?.setDiff({ + mode: 'simple', + original: referenceClean, + current: localClean, + title: `${backendDraft ? 'Latest saved draft' : 'Deployed'} <> Autosave`, + button: { + text: 'Discard autosave', + onClick: () => { + scriptHandle.draft = backendDraft ?? (scriptWithDraft as EditableScript) + } + } + }) + } + } + ]) + } + } else if (backendDraft) { + scriptHandle.draft = backendDraft + if (scriptHandle.draft?.['primary_schedule']) { + savedPrimarySchedule = scriptHandle.draft['primary_schedule'] + scriptBuilder?.setPrimarySchedule(savedPrimarySchedule) + } + scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers) + + if (!scriptWithDraft.draft_only) { + const reloadAction = async () => { + await DraftService.deleteDraft({ + workspace: $workspaceStore!, + kind: 'script', + path: scriptHandle.draft!.path + }) + UserDraft.remove('script', draftPath) + goto(`/scripts/edit/${scriptHandle.draft!.path}`) + loadScript() + } + const deployed = cleanValueProperties(scriptWithDraft) + const draft = cleanValueProperties(scriptHandle.draft) + sendUserToast('Script loaded from latest saved draft', false, [ + { + label: 'Discard draft reset to deployed version', callback: reloadAction }, { @@ -70,94 +129,29 @@ diffDrawer?.openDrawer() diffDrawer?.setDiff({ mode: 'simple', - original: draftOrDeployed, - current: urlScript, - title: `${savedScript?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`, - button: { text: 'Discard autosave', onClick: reloadAction } + original: deployed, + current: draft, + title: 'Deployed <> Draft', + button: { text: 'Discard draft', onClick: reloadAction } }) } } ]) } - } - compareAutosave() - } else { - if (hash) { - const scriptByHash = await ScriptService.getScriptByHash({ - workspace: $workspaceStore!, - hash - }) - savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft - script = { ...scriptByHash, parent_hash: hash, lock: undefined } } else { - const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({ - workspace: $workspaceStore!, - path: page.params.path ?? '' - }) - savedScript = structuredClone($state.snapshot(scriptWithDraft)) - if (scriptWithDraft.draft != undefined) { - script = scriptWithDraft.draft - scriptBuilder?.setDraftTriggers(script.draft_triggers) - if (script['primary_schedule']) { - savedPrimarySchedule = script['primary_schedule'] - scriptBuilder?.setPrimarySchedule(savedPrimarySchedule) - } - - if (!scriptWithDraft.draft_only) { - reloadAction = async () => { - scriptLoadedFromUrl = undefined - await DraftService.deleteDraft({ - workspace: $workspaceStore!, - kind: 'script', - path: script!.path - }) - goto(`/scripts/edit/${script!.path}`) - loadScript() - } - const deployed = cleanValueProperties(scriptWithDraft) - const draft = cleanValueProperties(script) - sendUserToast('Script loaded from latest saved draft', false, [ - { - label: 'Discard draft reset to deployed version', - callback: reloadAction - }, - { - label: 'Show diff', - callback: async () => { - diffDrawer?.openDrawer() - diffDrawer?.setDiff({ - mode: 'simple', - original: deployed, - current: draft, - title: 'Deployed <> Draft', - button: { text: 'Discard draft', onClick: reloadAction } - }) - } - } - ]) - } - } else { - script = scriptWithDraft - } - script.parent_hash = scriptWithDraft.hash + scriptHandle.draft = scriptWithDraft as EditableScript + } + if (scriptHandle.draft) { + scriptHandle.draft.parent_hash = scriptWithDraft.hash } } - // hash - // ? await ScriptService.getScriptByHash({ - // workspace: $workspaceStore!, - // hash: page.params.hash - // }) - // : await ScriptService.getScriptByPathWithDraft({ - // workspace: $workspaceStore!, - // path: $page.params.path - // }) - if (script) { - initialPath = script.path - scriptBuilder?.setDraftTriggers(script.draft_triggers) - scriptBuilder?.setCode(script.content) + if (scriptHandle.draft) { + initialPath = scriptHandle.draft.path + scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers) + scriptBuilder?.setCode(scriptHandle.draft.content) if (topHash) { - script.parent_hash = topHash + scriptHandle.draft.parent_hash = topHash } } fullyLoaded = true @@ -177,8 +171,8 @@ return } diffDrawer?.closeDrawer() + UserDraft.remove('script', draftPath) goto(`/scripts/edit/${savedScript.draft.path}`) - scriptLoadedFromUrl = undefined loadScript() } @@ -195,18 +189,18 @@ path: savedScript.path }) } + UserDraft.remove('script', draftPath) goto(`/scripts/edit/${savedScript.path}`) - scriptLoadedFromUrl = undefined loadScript() } -{#if script} +{#if scriptHandle.draft} { + UserDraft.remove('script', draftPath) goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} onSaveInitial={(e) => { @@ -222,9 +217,6 @@ onSeeDetails={(e) => { goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`) }} - replaceStateFn={(path) => { - replaceState(path, page.state) - }} >