feat(frontend): staleness modal in resource/variable editors

Resource/variable editors only showed the restored-from-local toast;
they never surfaced the staleness modal when the backend item moved on
since the local autosave was written. Wire LocalDraftStaleModal +
checkStaleness using the backend `edited_at` as `remoteRev` (these
items have no DB-draft concept). Meta is backfilled on reload for
legacy autosaves and seeded on the first real edit via a guarded
effect, so an external edit is detectable as drift. Per-workspace
detection; the modal is a singleton driven by `pendingStale`.
This commit is contained in:
Diego Imbert
2026-05-17 00:37:32 +02:00
parent f233832550
commit 033d14a69a
2 changed files with 175 additions and 18 deletions
@@ -12,8 +12,9 @@
import { deepEqual } from 'fast-equals'
import { getUserExt } from '$lib/user'
import type { UserExt } from '$lib/stores'
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
interface Props {
canSave?: boolean
@@ -60,6 +61,46 @@
let existedInitially: Record<string, boolean> = $state({})
let fetchedResources: Record<string, Resource> = $state({})
let perWsUser: Record<string, UserExt | undefined> = $state({})
// Backend `edited_at` per workspace — the rev the staleness check
// compares the local autosave's recorded rev against. Resources have
// no DB-draft concept, so only `remoteRev` is ever populated.
let fetchedRev: Record<string, string | undefined> = $state({})
// Local-draft staleness modal: opened when the backend resource moved
// on (someone else edited it) since the local autosave was written.
let staleModalOpen = $state(false)
let pendingStale: { ws: string; backend: ResourceState } | undefined = undefined
function onStaleLoadLatest(): void {
if (!pendingStale) {
staleModalOpen = false
return
}
const { ws, backend } = pendingStale
// Drop the divergent autosave and reset the handle to the freshly
// fetched backend state. A later edit re-creates the autosave and
// the seeding effect records the new rev.
UserDraft.discard('resource', initialPath ?? '', backend, { workspace: ws })
initialStates[ws] = $state.snapshot(backend) as ResourceState
pendingStale = undefined
staleModalOpen = false
}
function onStaleKeepDraft(): void {
if (pendingStale) {
const { ws } = pendingStale
// Ack the new backend rev so the modal doesn't fire again until
// the backend moves once more. Keeps the local autosave intact.
UserDraft.saveMeta(
'resource',
initialPath ?? '',
{ remoteRev: fetchedRev[ws] },
{ workspace: ws }
)
}
pendingStale = undefined
staleModalOpen = false
}
const handlesArray = UserDraft.useMany<ResourceState>(() =>
workspaceSpecs.map((s) => ({
@@ -198,6 +239,7 @@
getUserExt(ws)
]).then(([r, user]) => {
fetchedResources[ws] = r
fetchedRev[ws] = r.edited_at
const s: ResourceState = {
path: r.path,
description: r.description ?? '',
@@ -205,20 +247,38 @@
labels: r.labels ?? undefined,
wsSpecific: r.ws_specific ?? false
}
// Surface the local autosave-vs-backend divergence before the
// handle is registered, so the user knows the form is showing
// their unsaved work. The "Reset to deployed" action drops the
// LS entry and re-seeds the handle from the just-fetched
// backend state.
// Reconcile the local autosave with the backend before the
// handle is registered. If the backend moved on since the
// autosave was written (recorded rev != current rev) surface
// the staleness modal; otherwise the form is just showing the
// user's unsaved work — a toast with a "Reset to deployed"
// escape is enough.
const persisted = UserDraft.get<ResourceState>('resource', initialPath ?? '', {
workspace: ws
})
const previousMeta = UserDraft.getMeta('resource', initialPath ?? '', { workspace: ws })
if (persisted !== undefined && !deepEqual(persisted, s)) {
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
const cause = checkStaleness(previousMeta, r.edited_at)
if (cause) {
pendingStale = { ws, backend: s }
staleModalOpen = true
} else {
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
// Legacy autosave (no rev recorded) — backfill so the
// next backend change is detectable as drift.
UserDraft.saveMeta(
'resource',
initialPath ?? '',
{ remoteRev: r.edited_at },
{ workspace: ws }
)
}
})
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
}
})
}
}
ensureHandle(ws, s)
initialStates[ws] = structuredClone(s)
@@ -232,6 +292,25 @@
})
})
// Seed the staleness rev the moment a real autosave appears. Until the
// user's first edit diverges the handle's draft from the backend
// baseline there's no autosave to attach a rev to; once it does, record
// the backend rev captured at fetch time so a later external edit is
// detectable as drift on the next open. Self-limiting: after the write
// `meta.remoteRev` is set so the guard fails on the re-run.
$effect(() => {
for (const ws of Object.keys(states)) {
const h = states[ws]
const rev = fetchedRev[ws]
const baseline = initialStates[ws]
if (!h || rev === undefined || baseline === undefined) continue
const draft = h.draft
if (draft === undefined || deepEqual(draft, baseline)) continue
if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue
untrack(() => h.setMeta({ remoteRev: rev }))
}
})
// Keep current.path bound to the outer `path` prop for consumers
$effect(() => {
if (current) path = current.path
@@ -316,6 +395,13 @@
}
</script>
<LocalDraftStaleModal
open={staleModalOpen}
cause="version"
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
<div>
<div class="flex flex-col gap-6 py-2">
{#if otherDirty.length > 0}
@@ -15,8 +15,9 @@
import { deepEqual } from 'fast-equals'
import { getUserExt } from '$lib/user'
import type { UserExt } from '$lib/stores'
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
const dispatch = createEventDispatcher()
@@ -41,6 +42,41 @@
let perWsUser: Record<string, UserExt | undefined> = $state({})
let selected: string | undefined = $state(undefined)
let pathError = $state('')
// Backend `edited_at` per workspace — the rev the staleness check
// compares the local autosave's recorded rev against. Variables have
// no DB-draft concept, so only `remoteRev` is ever populated.
let fetchedRev: Record<string, string | undefined> = $state({})
// Local-draft staleness modal: opened when the backend variable moved
// on (someone else edited it) since the local autosave was written.
let staleModalOpen = $state(false)
let pendingStale: { ws: string; backend: VariableState } | undefined = undefined
function onStaleLoadLatest(): void {
if (!pendingStale) {
staleModalOpen = false
return
}
const { ws, backend } = pendingStale
UserDraft.discard('variable', editPath ?? '', backend, { workspace: ws })
initialStates[ws] = $state.snapshot(backend) as VariableState
pendingStale = undefined
staleModalOpen = false
}
function onStaleKeepDraft(): void {
if (pendingStale) {
const { ws } = pendingStale
UserDraft.saveMeta(
'variable',
editPath ?? '',
{ remoteRev: fetchedRev[ws] },
{ workspace: ws }
)
}
pendingStale = undefined
staleModalOpen = false
}
const handlesArray = UserDraft.useMany<VariableState>(() =>
workspaceSpecs.map((s) => ({
@@ -120,6 +156,7 @@
VariableService.getVariable({ workspace: ws, path: p, decryptSecret: false }),
getUserExt(ws)
]).then(([v, user]) => {
fetchedRev[ws] = v.edited_at
const s: VariableState = {
path: v.path,
variable: {
@@ -130,16 +167,27 @@
labels: v.labels ?? undefined,
wsSpecific: v.ws_specific ?? false
}
// See ResourceEditor for the same pattern: tell the user the
// form is showing their local autosave (not the backend),
// with a one-click "Reset to deployed" escape.
// See ResourceEditor for the same pattern: a backend that
// moved on since the autosave was written → staleness modal;
// otherwise just a "showing your local autosave" toast with
// a "Reset to deployed" escape.
const persisted = UserDraft.get<VariableState>('variable', p, { workspace: ws })
const previousMeta = UserDraft.getMeta('variable', p, { workspace: ws })
if (persisted !== undefined && !deepEqual(persisted, s)) {
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.discard('variable', p, s, { workspace: ws })
const cause = checkStaleness(previousMeta, v.edited_at)
if (cause) {
pendingStale = { ws, backend: s }
staleModalOpen = true
} else {
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws })
}
})
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.discard('variable', p, s, { workspace: ws })
}
})
}
}
ensureHandle(ws, s)
initialStates[ws] = structuredClone(s)
@@ -150,6 +198,22 @@
})
})
// Seed the staleness rev once a real autosave appears (see
// ResourceEditor for the rationale). Self-limiting via the
// meta-already-set guard.
$effect(() => {
for (const ws of Object.keys(states)) {
const h = states[ws]
const rev = fetchedRev[ws]
const baseline = initialStates[ws]
if (!h || rev === undefined || baseline === undefined) continue
const draft = h.draft
if (draft === undefined || deepEqual(draft, baseline)) continue
if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue
untrack(() => h.setMeta({ remoteRev: rev }))
}
})
function reset() {
// Clearing workspaceSpecs triggers useMany's reconcile to release
// every acquired entry. The $derived `states` then collapses to {}.
@@ -247,6 +311,13 @@
}
</script>
<LocalDraftStaleModal
open={staleModalOpen}
cause="version"
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
<Drawer bind:this={drawer} size="50rem">
<DrawerContent
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}