diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 479b7fa587..372f446ff6 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -138,6 +138,26 @@ export const UserDraft = { } }, + /** + * Whether a draft currently exists for (workspace, itemKind, path). + * For non-empty paths this checks localStorage; for empty paths it + * checks the in-memory entry. Useful for distinguishing "first visit" + * from "returning visit with unsaved local changes". + */ + has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) return entry.state.val !== undefined + if (isLocalOnly(path)) return false + try { + const raw = localStorage.getItem(localStorageKey(ws, itemKind, path)) + return raw != null && raw !== 'undefined' + } catch { + return false + } + }, + remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index 15e3354df7..cca44fef67 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -13,6 +13,7 @@ import { tick } from 'svelte' import { replaceScriptPlaceholderWithItsValues } from '$lib/hub' import type { Trigger } from '$lib/components/triggers/utils' + import { UserDraft } from '$lib/userDraft.svelte' let nodraft = page.url.searchParams.get('nodraft') @@ -57,8 +58,8 @@ // initialArgs may also be set from decoded state below (e.g. fork preview) let flowBuilder: FlowBuilder | undefined = $state(undefined) - const flowStore: StateStore = $state({ - val: { + function emptyFlow(): Flow { + return { summary: '', value: { modules: [] }, path: '', @@ -68,23 +69,26 @@ extra_perms: {}, schema: emptySchema() } - }) + } + + // New flow: empty path → in-memory only (no localStorage). + const flowHandle = UserDraft.use('flow', '', { defaultValue: emptyFlow() }) + + const flowStore: StateStore = { + 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 - let flow: Flow = { - path: '', - summary: '', - value: { modules: [] }, - edited_by: '', - edited_at: '', - archived: false, - extra_perms: {}, - schema: emptySchema() - } + let flow: Flow = emptyFlow() let state = forkState const initialStateQuery = page.url.hash != '' ? page.url.hash.slice(1) : undefined @@ -126,7 +130,6 @@ initialPath = `u/${$userStore?.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')}/${ oldPath[oldPath.length - 1] }_fork` - flow = flow goto('?', { replaceState: true }) selectedId = 'settings-metadata' } else if (hubId) { @@ -142,7 +145,6 @@ flow.value.preprocessor_module.value.content ) } - flow = flow goto('?', { replaceState: true }) selectedId = 'constants' } @@ -172,9 +174,11 @@ { + UserDraft.remove('flow', '') goto(`/flows/edit/${e.path}?selected=${e.id}`) }} onDeploy={(e) => { + UserDraft.remove('flow', '') goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} onDetails={(e) => { diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index f1fa03db4a..bcae4935e6 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -3,7 +3,13 @@ import FlowBuilder from '$lib/components/FlowBuilder.svelte' import { initialArgsStore, workspaceStore } from '$lib/stores' - import { cleanValueProperties, decodeState, emptySchema, type StateStore } from '$lib/utils' + import { + cleanValueProperties, + decodeState, + emptySchema, + orderedJsonStringify, + type StateStore + } from '$lib/utils' import { initFlow } from '$lib/components/flows/flowStore.svelte' import { goto } from '$lib/navigation' import { afterNavigate, replaceState } from '$app/navigation' @@ -16,6 +22,7 @@ import { untrack } from 'svelte' import type { stepState } from '$lib/components/stepHistoryLoader.svelte' import { page } from '$app/state' + import { UserDraft } from '$lib/userDraft.svelte' let version: undefined | number = $state(undefined) let nodraft = page.url.searchParams.get('nodraft') @@ -44,8 +51,11 @@ } }) - export const flowStore: StateStore = $state({ - val: { + const flowDraftPath = page.params.path ?? '' + const flowHandle = UserDraft.use('flow', flowDraftPath) + + function emptyFlow(): Flow { + return { summary: '', value: { modules: [] }, path: '', @@ -55,7 +65,16 @@ extra_perms: {}, schema: emptySchema() } - }) + } + + export const flowStore: StateStore = { + get val() { + return flowHandle.draft ?? emptyFlow() + }, + set val(v: Flow) { + flowHandle.draft = v + } + } const flowStateStore = $state({ val: {} }) let loading = $state(false) @@ -104,13 +123,62 @@ draft_triggers?: Trigger[] } } + + const backendFlow = + flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft + const localDraft = flowHandle.draft + + if (localDraft != undefined) { + // Returning visit: local autosave is the source of truth. If it + // matches the backend's view exactly, drop the toast — otherwise + // let the user decide between keeping local or discarding. + const localClean = cleanValueProperties(localDraft) + const backendClean = cleanValueProperties(backendFlow) + if (orderedJsonStringify(localClean) === orderedJsonStringify(backendClean)) { + flow = backendFlow + flowHandle.draft = backendFlow + } else { + flow = localDraft + sendUserToast('Flow loaded from local autosave', false, [ + { + label: 'Discard local autosave', + callback: () => { + flowHandle.draft = backendFlow + loadFlow() + } + }, + { + label: 'Show diff', + callback: async () => { + diffDrawer?.openDrawer() + diffDrawer?.setDiff({ + mode: 'simple', + original: backendClean, + current: localClean, + title: `${flowWithDraft.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`, + button: { + text: 'Discard autosave', + onClick: () => { + flowHandle.draft = backendFlow + loadFlow() + } + } + }) + } + } + ]) + } + } else { + flow = backendFlow + flowHandle.draft = backendFlow + } + if (flowWithDraft.draft != undefined && !nobackenddraft) { - flow = flowWithDraft.draft savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule'] flowBuilder?.setPrimarySchedule(savedPrimarySchedule) flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers']) - if (!flowWithDraft.draft_only) { + if (!flowWithDraft.draft_only && localDraft == undefined) { const deployed = cleanValueProperties(flowWithDraft) const draft = cleanValueProperties(flow) const reloadAction = async () => { @@ -119,6 +187,7 @@ kind: 'flow', path: flow.path }) + UserDraft.remove('flow', flowDraftPath) nobackenddraft = true loadFlow() } @@ -143,7 +212,6 @@ ]) } } else { - flow = flowWithDraft flowBuilder?.setDraftTriggers(undefined) } @@ -167,6 +235,7 @@ return } diffDrawer?.closeDrawer() + UserDraft.remove('flow', flowDraftPath) goto(`/flows/edit/${savedFlow.draft.path}`) loadFlow() } @@ -184,6 +253,7 @@ path: savedFlow.path }) } + UserDraft.remove('flow', flowDraftPath) goto(`/flows/edit/${savedFlow.path}`) loadFlow() } @@ -200,6 +270,7 @@ {:else} { + UserDraft.remove('flow', flowDraftPath) goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} onDetails={(e) => {