diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index b077cc412a..36c0ccc065 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -335,10 +335,9 @@ current.path = npath } - /** The path the acting workspace's write landed on, or undefined if it failed — - * it toasts its own failure, so a caller only needs this to know whether to run - * bookkeeping of its own, and where the item ended up. */ - export async function save(): Promise { + /** What the save deployed: one entry per workspace written, with the path it + * wrote there. Empty when nothing landed — it toasts its own failure. */ + export async function save(): Promise<{ ws: string; path: string }[]> { // Everything the writes send, read before the first await. The form stays // editable while they are in flight, so read later these would be whatever // the user has since typed — sent under an earlier workspace's path, and @@ -353,12 +352,11 @@ // The path the ACTING workspace's write used. `WsSpecificVersions` can point // the form at a linked workspace, and a rename made there is that workspace's // alone — reporting it would move a host that is looking at this one. - const savedPath = payloads.find((pl) => pl.ws === effectiveWorkspace)?.s.path ?? from - // Set once the acting workspace's own write has committed. The workspaces are - // written in sequence and a later one throwing aborts the rest, but what this - // one wrote is already deployed — a caller told nothing would stay pointed at - // a path it has moved off. - let committed: string | undefined = undefined + // Every workspace this save wrote, in order, each with the path it wrote there. + // The workspaces go in sequence and a later one throwing aborts the rest, but + // what an earlier one wrote is deployed — a caller told nothing about it would + // stay pointed at a path the item has moved off. + const written: { ws: string; path: string }[] = [] try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -394,7 +392,7 @@ // `remove`. See VariableEditor for the full rationale. initialStates[ws] = s existedInitially[ws] = true - if (ws === effectiveWorkspace) committed = s.path + written.push({ ws, path: s.path }) // `s.path`, not the reported one: each workspace settles against the path // its own write used. settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, { workspace: ws }) @@ -407,15 +405,14 @@ ? `Saved resource in ${payloads.length} workspaces` : `Saved resource` ) - dispatch('refresh', savedPath) - return savedPath + dispatch('refresh', written.find((w) => w.ws === effectiveWorkspace)?.path ?? from) + return written } catch (err) { sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true) - // Following the rename remounts on the new path, so a workspace whose write - // never happened loses sight of its draft here — it stays on the server under - // the path that workspace still holds the item at, listed and editable from - // there. Re-keying it instead would move a path it never wrote. - return committed + // The workspaces that never got their write keep their drafts at their own + // paths, listed and editable from there — moving one onto a rename it never + // wrote would be worse than not showing it here. + return written } } diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index d16c5afe32..9990280fe7 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -63,8 +63,9 @@ let resourceEditor: | { - /** The path it wrote to, or undefined when it failed; it toasts its own error. */ - save: () => Promise + /** One entry per workspace written, with the path it wrote there; empty when + * nothing landed. It toasts its own error. */ + save: () => Promise<{ ws: string; path: string }[]> localDraftDeployed: () => unknown localDraftCurrent: () => unknown /** False when the discard removed the resource (it was draft-only). */ @@ -176,7 +177,10 @@ getCurrent={() => resourceEditor?.localDraftCurrent()} onDiscard={async () => { const from = path - const fromWs = effectiveWorkspace + // The workspace the editor is showing, which `WsSpecificVersions` can point at + // a linked one: the discard deletes that workspace's draft, so that is whose + // tabs it is about. + const fromWs = selected ?? effectiveWorkspace if ((await resourceEditor?.discardLocalDraft()) === false && from) onRemoved?.(from, fromWs) }} @@ -221,23 +225,24 @@ // caller of this drawer. `onSaved` still fires after the write lands. const saving = resourceEditor?.save() drawer?.closeDrawer() - // The path the write landed on, from the editor rather than the form: the form - // may be showing a linked workspace's variant, whose rename is not this host's. - // Undefined means the write failed — a rejected rename (a name collision, say) - // — and everything below would move this host onto a path it never created. - const submitted = await saving - if (!submitted) return - // An inline host re-pointed this editor while the write was in flight, so - // `path` and the mounted editor are another resource's now. Moving either - // onto this write's result would move that one instead. - if (path !== from) return + // 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 === fromWs)?.path // Rendered inline there is no drawer to close, so the mounted editor would // otherwise keep the pre-save baseline. Follow a rename before remounting, - // or it comes back up on a path the save just moved the item off. - const savedPath = submitted - if (savedPath) path = savedPath - editorGeneration++ - onSaved?.(savedPath, from, fromWs) + // or it comes back up on a path the save just moved the item off. Only while + // this is still the resource it saved: re-pointed mid-write, `path` and the + // mounted editor are another one's, and moving them would move that one. + if (path === from) { + if (submitted) path = submitted + editorGeneration++ + } + // 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} > diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 0dea6612e5..163a37aabb 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -295,15 +295,10 @@ ini: $state.snapshot(initialStates[ws]) as VariableState, existed: !!existedInitially[ws] })) - // The path the ACTING workspace's write used. `WsSpecificVersions` can point - // the form at a linked workspace, and a rename made there is that workspace's - // alone — reporting it would move a host that is looking at this one. - const savedPath = payloads.find((pl) => pl.ws === fromWs)?.s.path ?? from - // Set once the acting workspace's own write has committed. The workspaces are - // written in sequence and a later one throwing aborts the rest, but what this - // one wrote is already deployed — a host told nothing would stay pointed at a - // path it has moved off. - let committed: string | undefined = undefined + // What this editor itself follows: `WsSpecificVersions` can point the form at a + // linked workspace, and a rename made there is that workspace's alone. + const actingPath = payloads.find((pl) => pl.ws === fromWs)?.s.path ?? from + let actingCommitted = false try { for (const { ws, s, ini, existed } of payloads) { if (existed) { @@ -347,7 +342,12 @@ // resets the handle to it via `discard` (not `remove` — blanking the cell // to `undefined` reads as dirty), and keeps an edit made mid-request. Each // workspace settles against the path its own write used, not the reported one. - if (ws === fromWs) committed = s.path + if (ws === fromWs) actingCommitted = true + // Per workspace, as each write lands. Each carries its own workspace and + // the path it wrote there, so a linked workspace's rename moves the tabs + // acting on it and no others — and a workspace written before a later one + // threw is still reported, because it is deployed. + onSaved?.(s.path, from, ws) settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { workspace: ws }) @@ -363,22 +363,17 @@ // `editPath` is the variable it moved to. if (editPath === from) { // A rename moved the item; the drawer host closes over it, but an inline one - // stays mounted, so follow the new path here and tell the host about it. - if (savedPath && savedPath !== editPath) editPath = savedPath + // stays mounted, so follow the new path here. + if (actingPath && actingPath !== editPath) editPath = actingPath drawer?.closeDrawer() } - onSaved?.(savedPath, from, fromWs) } catch (err) { sendUserToast(`Could not save variable: ${err.body}`, true) - if (committed) { - onSaved?.(committed, from, fromWs) - // Reopened, not re-keyed: re-keying would bring the handles back from the - // values this drawer opened on, which describe neither what the committed - // workspace now has deployed nor the draft the failed one still holds at - // its own path. That draft stays there, listed and editable from its - // workspace — moving it onto a rename it never wrote would be worse. - if (editPath === from) editVariable(committed) - } + // Reopened, so the handles come from the server: re-keying them would bring + // back the values this drawer opened on, which describe neither what the + // committed workspace now has deployed nor the draft the failed one still + // holds — that one stays at its own path, editable from its workspace. + if (actingCommitted && actingPath && editPath === from) editVariable(actingPath) } } @@ -391,16 +386,18 @@ getCurrent={() => current} onDiscard={async () => { if (!selected) return + // The workspace the editor is showing, which `WsSpecificVersions` can point at + // a linked one: the discard deletes that workspace's draft, so that is whose + // tabs it is about. const ws = selected const from = editPath ?? '' - const fromWs = curWs UserDraft.discard('variable', from, initialStates[ws], { workspace: ws }) // A draft-only variable has no deployed row under the draft, so discarding // it removed the variable: `initialStates` holds a synthesized stand-in, not // a baseline to fall back to. Only once the delete has landed — until then // the variable is still there, and a host would leave on a row it can see. const landed = await flushDraftDelete('variable', from, { workspace: ws }) - if (!existedInitially[ws] && landed && from && fromWs) onRemoved?.(from, fromWs) + if (!existedInitially[ws] && landed && from) onRemoved?.(from, ws) }} disabled={!can_write} /> diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index a59cb4041e..e1887fefc6 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -65,12 +65,14 @@ onLoad: (frame: HTMLIFrameElement) => void /** A hosted entity editor wrote its item: saved it (`to`, the path it wrote * to) or removed it. The page reaches every tab on that item, across warm - * sessions. */ + * sessions — `fromTabId` being the one that reported, which has settled its + * own state already. */ onEntityWritten: (ev: { kind: EntityEditorKind path: string workspace: string to?: string + fromTabId: string }) => void } = $props() @@ -196,13 +198,19 @@ const reportRemoved = $derived( slot.kind === 'entity' ? (kind: EntityEditorKind, fromPath: string, fromWs: string) => - onEntityWritten({ kind, path: fromPath, workspace: fromWs }) + onEntityWritten({ kind, path: fromPath, workspace: fromWs, fromTabId: tab.id }) : undefined ) const reportSaved = $derived( slot.kind === 'entity' ? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) => - onEntityWritten({ kind, path: fromPath, workspace: fromWs, to: newPath }) + onEntityWritten({ + kind, + path: fromPath, + workspace: fromWs, + to: newPath, + fromTabId: tab.id + }) : undefined ) diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte index 77eafab1ad..24cd364d4b 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -71,13 +71,11 @@ showDraftBanner {onRemoved} onUpdate={(saved: string | undefined, from: string, fromWs: string) => { - // The write landed after the tab was pointed at another schedule: the - // remount below would take that one back through a load it never asked - // for, and the rename is not its rename. - if (from !== path) return - generation++ - // A draft-only schedule opens with its path editable (saving CREATEs), so - // the save can land somewhere other than where the tab is pointed. + // Remount only while this tab is still on the schedule that was saved: + // pointed elsewhere since, it would be taken back through a load it never + // asked for. The report goes out either way — the tabs on that schedule + // elsewhere have to hear, and a draft-only save can land on a new path. + if (from === path) generation++ if (saved) onSavedTo?.(saved, from, fromWs) }} > diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 0399030862..297e3ca6ba 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -1480,8 +1480,8 @@ getCurrent={() => draftSync.current} onDiscard={async () => { // The path the discard started from: it awaits a reload of the runnable, - // and an inline host can re-point the editor in the meantime — after - // which this discard's outcome is no longer about what is on screen. + // and an inline host can re-point the editor in the meantime. What it + // removed is still that schedule, which is what the report is about. const from = initialPath const fromWs = wsId const wasDraftOnly = draftOnly @@ -1491,8 +1491,7 @@ const landed = await flushDraftDelete('trigger_schedule', from, { workspace: fromWs ?? undefined }) - if (wasDraftOnly && landed && from && fromWs && initialPath === from) - onRemoved?.(from, fromWs) + if (wasDraftOnly && landed && from && fromWs) onRemoved?.(from, fromWs) }} disabled={!can_write} /> diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 20d69429c2..a2027b62b1 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -620,6 +620,7 @@ workspace: string /** The path it wrote to; absent when the write removed the item. */ to?: string + fromTabId: string }) { const page = entityListPage(ev.kind)?.path if (!page) return @@ -633,9 +634,11 @@ // The tab's own location, so the list it came from keeps its filters. if (!ev.to) owner.retargetTabTo(tab.id, entityListHref(loc)) else if (ev.to !== ev.path) owner.retargetTabTo(tab.id, entityEditorHref(loc, ev.to)) - // Same path: nothing to re-point, but every editor on it holds a baseline - // the deploy has replaced, and would offer to discard back to it. - else tabHosts[tabKey(s.id, tab.id)]?.reload({ entity: 'refresh' }) + // Same path: nothing to re-point, but every OTHER editor on it holds a + // baseline the deploy has replaced and would offer to discard back to it. + // The reporting one has settled its own, including an edit typed while the + // save was in flight, which a remount would re-read from under. + else if (tab.id !== ev.fromTabId) tabHosts[tabKey(s.id, tab.id)]?.reload({ entity: 'refresh' }) } } }