From 033d14a69aef7f0f85506054118feb663d07af9c Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 17 May 2026 00:37:32 +0200 Subject: [PATCH] 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`. --- .../src/lib/components/ResourceEditor.svelte | 106 ++++++++++++++++-- .../src/lib/components/VariableEditor.svelte | 87 ++++++++++++-- 2 files changed, 175 insertions(+), 18 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 5f1ed4f423..6e5a68c204 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -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 = $state({}) let fetchedResources: Record = $state({}) let perWsUser: Record = $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 = $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(() => 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('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 @@ } + +
{#if otherDirty.length > 0} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index a8e53b7e91..20e7366540 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -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 = $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 = $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(() => 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('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 @@ } + +