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.
This commit is contained in:
Diego Imbert
2026-05-12 22:47:27 +02:00
parent a32f257686
commit 9ca787a18b
4 changed files with 197 additions and 87 deletions
+28
View File
@@ -276,6 +276,34 @@ export const UserDraft = {
return unwrap(readPersisted<V>(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<unknown> | undefined
if (current === undefined) return
entry.state.val = wrap(current.value, meta)
}
if (isLocalOnly(path)) return
const existing = readPersisted<unknown>(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.
@@ -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>('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 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#key redraw}
{#if app}
@@ -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<string, string>
@@ -55,6 +56,36 @@
const draftHandle = UserDraft.use<RawAppDraft>('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<RawAppDraft>('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 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#if files}
{#key redraw}
@@ -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 @@
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#if notFound}
<div class="flex flex-col items-center justify-center h-full">
<h1 class="text-2xl font-bold">Flow not found at path {page.params.path}</h1>