diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 542ac1c9c6..bac54591ec 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -17,6 +17,7 @@ draftValuesEqual, flushDraftDelete, beginDraftSettleWindow, + isDraftSaving, settleDraftAfterWrite, type UserDraftHandle } from '$lib/userDraft.svelte' @@ -277,8 +278,16 @@ } }) + // A save in flight for this resource, in this editor or another holding the same + // cell: inline there is no drawer to close over the button, and duplicate tabs + // and warm sessions mount several editors over one item. Two writes at once and + // the older one landing last overwrites the newer deployment. + const saveInFlight = $derived( + dirtyWorkspaces.some((ws) => isDraftSaving('resource', initialPath, { workspace: ws })) + ) + $effect(() => { - canSave = anyDirty && dirtyValid && dirtyCanWrite + canSave = anyDirty && dirtyValid && dirtyCanWrite && !saveInFlight }) // Drive the parent drawer's "unsaved changes" banner. The drawer chrome @@ -368,10 +377,15 @@ // stay pointed at a path the item has moved off. const written: { ws: string; path: string }[] = [] // A host left mid-write releases the cell each settle below reads; only inside - // this window is what it was holding remembered. - const closeSettleWindows = payloads.map(({ ws }) => - beginDraftSettleWindow('resource', from, { workspace: ws }) - ) + // this window is what it was holding remembered. It is also what says a write + // is in flight for this cell, so a second editor on it cannot start one — + // checked before the first is opened, or this would see its own. + if (payloads.some(({ ws }) => isDraftSaving('resource', from, { workspace: ws }))) { + return { written: [], ok: false } + } + const closeSettleWindows = from + ? payloads.map(({ ws }) => beginDraftSettleWindow('resource', from, { workspace: ws })) + : [] try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -418,9 +432,7 @@ invalidateWorkspacePaths(ws) } sendUserToast( - payloads.length > 1 - ? `Saved resource in ${payloads.length} workspaces` - : `Saved resource` + payloads.length > 1 ? `Saved resource in ${payloads.length} workspaces` : `Saved resource` ) dispatch('refresh', written.find((w) => w.ws === effectiveWorkspace)?.path ?? from) return { written, ok: true } diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 41b41bdcf6..47a96bccf0 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -61,10 +61,6 @@ // truth (see editorBody). let editorGeneration = $state(0) let canSave = $state(true) - // A save in flight. Inline there is no drawer to close over the button, so a - // second click would start an unserialized second write: the older snapshot - // lands last and overwrites the newer deployment. - let savePending = $state(false) let resource_type: string | undefined = $state(undefined) let defaultValues: Record | undefined = $state(undefined) @@ -227,47 +223,41 @@ unifiedSize="md" startIcon={{ icon: Save }} on:click={async () => { - if (savePending) return - savePending = true - try { - // Where this save started. Read before the await: an inline host can re-point - // the editor at another resource, or re-scope the session to another - // workspace, while the write is in flight. - const from = path - const fromWs = effectiveWorkspace - // The workspace whose version is on screen, which `WsSpecificVersions` can - // make a linked one: a rename made there is that workspace's alone, but the - // form is showing it, so the form follows it. - const shownWs = selected - // Closed before the write is awaited, the way it always was: `save()` toasts its - // own failures and never rejects, so waiting would only add visible lag to every - // caller of this drawer. `onSaved` still fires after the write lands. - const saving = resourceEditor?.saveWritten() - drawer?.closeDrawer() - // What landed, per workspace, from the editor rather than the form: the form may - // be showing a linked workspace's variant, whose rename is not this host's. - const written = (await saving) ?? [] - if (written.length === 0) return - const submitted = - written.find((w) => w.ws === shownWs)?.path ?? written.find((w) => w.ws === fromWs)?.path - // Follow a rename: rendered inline there is no drawer to close, so the mounted - // editor stays, and the key below remounts it on the path the item moved to. - // The editor adopts its own new baseline, so a same-path save needs nothing - // here — remounting it would re-read over an edit made during the write. - // Only while this is still the resource it saved: re-pointed mid-write, - // `path` and the mounted editor are another one's. And only a move: pointing a - // create at the path it just made re-keys the blank form's cell onto it, and - // the mirror then posts that blank state as the new item's draft. - if (path && path === from && submitted) path = submitted - // One report per workspace written, each carrying its own — a linked - // workspace's rename moves the tabs acting on it and no others. Reported - // even when this drawer has moved on: the write is a fact about the item. - for (const w of written) onSaved?.(w.path, from, w.ws) - } finally { - savePending = false - } + // Where this save started. Read before the await: an inline host can re-point + // the editor at another resource, or re-scope the session to another + // workspace, while the write is in flight. + const from = path + const fromWs = effectiveWorkspace + // The workspace whose version is on screen, which `WsSpecificVersions` can + // make a linked one: a rename made there is that workspace's alone, but the + // form is showing it, so the form follows it. + const shownWs = selected + // Closed before the write is awaited, the way it always was: `save()` toasts its + // own failures and never rejects, so waiting would only add visible lag to every + // caller of this drawer. `onSaved` still fires after the write lands. + const saving = resourceEditor?.saveWritten() + drawer?.closeDrawer() + // What landed, per workspace, from the editor rather than the form: the form may + // be showing a linked workspace's variant, whose rename is not this host's. + const written = (await saving) ?? [] + if (written.length === 0) return + const submitted = + written.find((w) => w.ws === shownWs)?.path ?? written.find((w) => w.ws === fromWs)?.path + // Follow a rename: rendered inline there is no drawer to close, so the mounted + // editor stays, and the key below remounts it on the path the item moved to. + // The editor adopts its own new baseline, so a same-path save needs nothing + // here — remounting it would re-read over an edit made during the write. + // Only while this is still the resource it saved: re-pointed mid-write, + // `path` and the mounted editor are another one's. And only a move: pointing a + // create at the path it just made re-keys the blank form's cell onto it, and + // the mirror then posts that blank state as the new item's draft. + if (path && path === from && submitted) path = submitted + // One report per workspace written, each carrying its own — a linked + // workspace's rename moves the tabs acting on it and no others. Reported + // even when this drawer has moved on: the write is a fact about the item. + for (const w of written) onSaved?.(w.path, from, w.ws) }} - disabled={!canSave || savePending} + disabled={!canSave} > Save diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index acd47bad69..4e2765b2f8 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -27,6 +27,7 @@ draftValuesEqual, flushDraftDelete, beginDraftSettleWindow, + isDraftSaving, settleDraftAfterWrite, type UserDraftHandle } from '$lib/userDraft.svelte' @@ -283,14 +284,15 @@ form?.setCode(getV.value ?? '') } - // A save in flight. Inline there is no drawer to close over the button, so a - // second click would start an unserialized second write: the older snapshot - // lands last and overwrites the newer deployment. - let savePending = $state(false) + // A save in flight for this variable, in this editor or another holding the same + // cell: inline there is no drawer to close over the button, and duplicate tabs + // and warm sessions mount several editors over one item. Two writes at once and + // the older one landing last overwrites the newer deployment. + const saveInFlight = $derived( + dirtyWorkspaces.some((ws) => isDraftSaving('variable', editPath, { workspace: ws })) + ) async function save(): Promise { - if (savePending) return - savePending = true // Everything the writes need, read before the first await. An inline host can // re-point this editor at another variable mid-flight, and every one of these // would then be that variable's: the writes would send its state under this @@ -320,10 +322,13 @@ // moves the tabs acting on it and no others. const written: { ws: string; path: string }[] = [] // A host left mid-write releases the cell each settle below reads; only inside - // this window is what it was holding remembered. - const closeSettleWindows = payloads.map(({ ws }) => - beginDraftSettleWindow('variable', from ?? '', { workspace: ws }) - ) + // this window is what it was holding remembered. It is also what says a write + // is in flight for this cell, so a second editor on it cannot start one — + // checked before the first is opened, or this would see its own. + if (payloads.some(({ ws }) => isDraftSaving('variable', from, { workspace: ws }))) return + const closeSettleWindows = from + ? payloads.map(({ ws }) => beginDraftSettleWindow('variable', from, { workspace: ws })) + : [] try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -411,7 +416,6 @@ } } finally { for (const close of closeSettleWindows) close() - savePending = false } } @@ -456,7 +460,7 @@ {/if}