From 9ca787a18b7a9172e3732f6f346a98df2ee43f38 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 12 May 2026 22:47:27 +0200 Subject: [PATCH] feat(frontend): staleness modal for flow, app, and raw-app editors Migrates the flow, app, and raw_app editor routes to the same `LocalDraftStaleModal` flow already used by scripts: compare the recorded meta against the current `version` / `versions[last]` and `draft_created_at`; on mismatch, surface the choice in a modal. Adds `UserDraft.saveMeta` for routes that don't hold a live handle (the app editor reads via `UserDraft.get` and the handle lives in the child `AppEditor` component). It writes meta directly to localStorage and tolerates the no-entry case. --- frontend/src/lib/userDraft.svelte.ts | 28 ++++++ .../(logged)/apps/edit/[...path]/+page.svelte | 81 +++++++++++------ .../apps_raw/edit/[...path]/+page.svelte | 85 ++++++++++++------ .../flows/edit/[...path]/+page.svelte | 90 ++++++++++++------- 4 files changed, 197 insertions(+), 87 deletions(-) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 0dca13ce52..cf63db2b17 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -276,6 +276,34 @@ export const UserDraft = { return unwrap(readPersisted(localStorageKey(ws, itemKind, path))) }, + /** + * Update the rev metadata for an entry without touching the value, and + * persist immediately. Used by editor routes that don't hold a live + * handle (apps, raw apps) — they read the local draft via `UserDraft.get` + * and the handle is created later inside the child editor. + * + * No-op when the entry has no draft to attach meta to. + */ + saveMeta( + itemKind: UserDraftItemKind, + path: string, + meta: UserDraftMeta, + opts?: UserDraftOptions + ): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + const current = entry.state.val as StoredDraft | undefined + if (current === undefined) return + entry.state.val = wrap(current.value, meta) + } + if (isLocalOnly(path)) return + const existing = readPersisted(localStorageKey(ws, itemKind, path)) + if (existing === undefined) return + persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta) + }, + /** * Read the rev metadata for the entry. Returns an empty object if there * is no entry. Useful for staleness checks before reading the draft. diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index d3d7ab5613..b6472bf051 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -14,10 +14,11 @@ import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { App } from '$lib/components/apps/types' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' + import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte' import { stateSnapshot } from '$lib/svelte5Utils.svelte' import { untrack } from 'svelte' import { page } from '$app/state' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte' let app = $state( undefined as (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined @@ -36,6 +37,35 @@ let redraw = $state(0) let path = page.params.path ?? '' + // Local-draft staleness modal: opened when the remote has moved on since + // the local autosave was written. + let staleModalOpen = $state(false) + let staleModalCause = $state<'draft' | 'version'>('version') + let pendingBaseline: + | { baseline: AppWithLastVersion & { draft_only?: boolean; value: any }; revs: UserDraftMeta } + | undefined = undefined + + function onStaleLoadLatest(): void { + if (!pendingBaseline) { + staleModalOpen = false + return + } + UserDraft.remove('app', path) + UserDraft.saveMeta('app', path, pendingBaseline.revs) + app = pendingBaseline.baseline + pendingBaseline = undefined + staleModalOpen = false + redraw++ + } + + function onStaleKeepDraft(): void { + if (pendingBaseline) { + UserDraft.saveMeta('app', path, pendingBaseline.revs) + } + pendingBaseline = undefined + staleModalOpen = false + } + let nodraft = page.url.searchParams.get('nodraft') afterNavigate(() => { @@ -94,37 +124,30 @@ : app_w_draft const localDraftValue = UserDraft.get('app', path) + const previousMeta = UserDraft.getMeta('app', path) + const newRevs: UserDraftMeta = { + remoteRev: app_w_draft.versions + ? app_w_draft.versions[app_w_draft.versions.length - 1] + : undefined, + remoteDraftRev: app_w_draft.draft_created_at + } if ( localDraftValue != undefined && orderedJsonStringify(cleanValueProperties(localDraftValue)) !== orderedJsonStringify(cleanValueProperties(backendApp.value)) ) { - const reloadAction = async () => { - UserDraft.remove('app', path) - await loadApp() - redraw++ + const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev) + if (cause) { + pendingBaseline = { baseline: backendApp, revs: newRevs } + staleModalCause = cause + staleModalOpen = true + } else if ( + previousMeta.remoteRev === undefined && + previousMeta.remoteDraftRev === undefined + ) { + // Legacy entry — backfill meta so the next load can detect staleness. + UserDraft.saveMeta('app', path, newRevs) } - const deployed = cleanValueProperties(savedApp?.draft || savedApp) - const local = { ...deployed, value: localDraftValue } - sendUserToast('App restored from local autosave', false, [ - { - label: 'Discard local autosave and reload', - callback: reloadAction - }, - { - label: 'Show diff', - callback: async () => { - diffDrawer?.openDrawer() - diffDrawer?.setDiff({ - mode: 'simple', - original: deployed, - current: local, - title: `${savedApp?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`, - button: { text: 'Discard autosave', onClick: reloadAction } - }) - } - } - ]) app = { ...backendApp, value: localDraftValue } } else { // Local is missing or matches backend — wipe any stale entry so it @@ -227,6 +250,12 @@ + {#key redraw} {#if app} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 0203dab2fe..f3b49a0162 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -18,7 +18,8 @@ import { stateSnapshot } from '$lib/svelte5Utils.svelte' import { page } from '$app/state' import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte' + import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte' type RawAppDraft = { files: Record @@ -55,6 +56,36 @@ const draftHandle = UserDraft.use('raw_app', path) + // Local-draft staleness modal: opened when the remote has moved on since + // the local autosave was written. + let staleModalOpen = $state(false) + let staleModalCause = $state<'draft' | 'version'>('version') + let pendingBaseline: + | { baseline: RawAppDraft; backendSource: any; revs: UserDraftMeta } + | undefined = undefined + + function onStaleLoadLatest(): void { + if (!pendingBaseline) { + staleModalOpen = false + return + } + const { baseline, backendSource, revs } = pendingBaseline + UserDraft.remove('raw_app', path) + draftHandle.setDraftAndMeta(baseline, revs) + extractRawApp(backendSource) + pendingBaseline = undefined + staleModalOpen = false + redraw++ + } + + function onStaleKeepDraft(): void { + if (pendingBaseline) { + draftHandle.setMeta(pendingBaseline.revs, { force: true }) + } + pendingBaseline = undefined + staleModalOpen = false + } + // Persist the bundle whenever any of the four pieces of state changes. $effect(() => { if (!files) return @@ -123,7 +154,14 @@ } const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft - const localDraft = UserDraft.get('raw_app', path) + const localDraft = draftHandle.draft + const previousMeta = draftHandle.meta + const newRevs: UserDraftMeta = { + remoteRev: app_w_draft.versions + ? app_w_draft.versions[app_w_draft.versions.length - 1] + : undefined, + remoteDraftRev: app_w_draft.draft_created_at + } const backendBundle: RawAppDraft = { files: backendSource.value?.files ?? {}, runnables: backendSource.value?.runnables ?? {}, @@ -140,32 +178,18 @@ orderedJsonStringify(cleanValueProperties(localDraft)) !== orderedJsonStringify(cleanValueProperties(backendBundle)) ) { - const reloadAction = async () => { - UserDraft.remove('raw_app', path) - await loadApp() - redraw++ + const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev) + if (cause) { + pendingBaseline = { baseline: backendBundle, backendSource, revs: newRevs } + staleModalCause = cause + staleModalOpen = true + } else if ( + previousMeta.remoteRev === undefined && + previousMeta.remoteDraftRev === undefined + ) { + // Legacy entry — backfill meta so the next load can detect staleness. + draftHandle.setMeta(newRevs, { force: true }) } - const deployed = cleanValueProperties(app_w_draft as Value) - const local = { ...deployed, value: localDraft } - sendUserToast('App restored from local autosave', false, [ - { - label: 'Discard local autosave and reload', - callback: reloadAction - }, - { - label: 'Show diff', - callback: async () => { - diffDrawer?.openDrawer() - diffDrawer?.setDiff({ - mode: 'simple', - original: deployed, - current: local, - title: `${app_w_draft.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`, - button: { text: 'Discard autosave', onClick: reloadAction } - }) - } - } - ]) runnables = localDraft.runnables data = localDraft.data summary = localDraft.summary @@ -175,6 +199,7 @@ } else { if (localDraft != undefined) UserDraft.remove('raw_app', path) extractRawApp(backendSource) + draftHandle.setDraftAndMeta(backendBundle, newRevs) if (app_w_draft.draft && !app_w_draft.draft_only) { const reloadAction = () => { @@ -269,6 +294,12 @@ + {#if files} {#key redraw} 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 d91f369a07..44fcb9a180 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -18,12 +18,13 @@ import { sendUserToast } from '$lib/toast' import DiffDrawer from '$lib/components/DiffDrawer.svelte' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' + import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte' import type { ScheduleTrigger } from '$lib/components/triggers' import type { Trigger } from '$lib/components/triggers/utils' import { untrack } from 'svelte' import type { stepState } from '$lib/components/stepHistoryLoader.svelte' import { page } from '$app/state' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte' let version: undefined | number = $state(undefined) @@ -89,6 +90,33 @@ let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined) + // Local-draft staleness modal: opened when the remote has moved on since + // the local autosave was written. + let staleModalOpen = $state(false) + let staleModalCause = $state<'draft' | 'version'>('version') + let pendingBaseline: { baseline: Flow; revs: UserDraftMeta } | undefined = undefined + + function onStaleLoadLatest(): void { + if (!pendingBaseline) { + staleModalOpen = false + return + } + const { baseline, revs } = pendingBaseline + UserDraft.remove('flow', flowDraftPath) + flowHandle.setDraftAndMeta(baseline, revs) + pendingBaseline = undefined + staleModalOpen = false + loadFlow() + } + + function onStaleKeepDraft(): void { + if (pendingBaseline) { + flowHandle.setMeta(pendingBaseline.revs, { force: true }) + } + pendingBaseline = undefined + staleModalOpen = false + } + let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined) let selectedTriggerIndexFromUrl: number | undefined = $state(undefined) let loadedFromHistoryFromUrl: @@ -139,50 +167,38 @@ const backendFlow = flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft const localDraft = flowHandle.draft + const previousMeta = flowHandle.meta + const newRevs: UserDraftMeta = { + remoteRev: v, + remoteDraftRev: flowWithDraft.draft_created_at + } 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)) { + // Local matches backend exactly — silently drop the autosave. flow = backendFlow - flowHandle.draft = backendFlow + UserDraft.remove('flow', flowDraftPath) + flowHandle.setDraftAndMeta(backendFlow, newRevs) } 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() - } - } - }) - } - } - ]) + const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev) + if (cause) { + pendingBaseline = { baseline: backendFlow, revs: newRevs } + staleModalCause = cause + staleModalOpen = true + } else if ( + previousMeta.remoteRev === undefined && + previousMeta.remoteDraftRev === undefined + ) { + // Legacy entry — backfill meta so the next load can detect staleness. + flowHandle.setMeta(newRevs, { force: true }) + } } } else { flow = backendFlow - flowHandle.draft = backendFlow + flowHandle.setDraftAndMeta(backendFlow, newRevs) } if (flowWithDraft.draft != undefined && !nobackenddraft) { @@ -278,6 +294,12 @@ + {#if notFound}

Flow not found at path {page.params.path}