From 2dd5c6e19fbe89f3ea4facf181d229f834fd4b3b Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 12 May 2026 16:32:28 +0200 Subject: [PATCH] fix(frontend): persist full multi-workspace bundle for resources/variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResourceEditor and VariableEditor can stage edits for several target workspaces in a single drawer session (see deployTo / states[ws] map). The previous UserDraft wiring only persisted states[$workspaceStore] — the user's session workspace — so any edit made under a different target workspace tab disappeared on refresh. Persist the entire `states: Record` bundle as the draft value instead. On lazy-fetch we pick the local state for that ws if present and divergent from the backend; on bootstrap for new resources/variables we restore states for every workspace the user had staged. The localStorage key still lives under the user's session workspace via UserDraft, but its contents now cover all target workspaces from that session. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/components/ResourceEditor.svelte | 61 +++++++++++++------ .../src/lib/components/VariableEditor.svelte | 53 ++++++++++------ 2 files changed, 75 insertions(+), 39 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 77bfc62e48..4e8d2cb363 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -44,12 +44,18 @@ wsSpecific: boolean } + // The local autosave is the entire `states` map, keyed by target workspace. + // This editor can edit several workspaces in one session (cross-workspace + // deploys); persisting only the current workspace would silently drop + // edits made on the other tabs. + type ResourceDraft = Record + const dispatch = createEventDispatcher() let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) let initialPath = path - const resourceDraftHandle = UserDraft.use('resource', initialPath ?? '') + const resourceDraftHandle = UserDraft.use('resource', initialPath ?? '') let states: Record = $state({}) let initialStates: Record = $state({}) @@ -130,16 +136,19 @@ ) // Bootstrap: ensure selected is set on mount (edit or new). For new - // resources we also prefer a local autosave (UserDraft) over defaults so - // returning to the drawer mid-edit keeps your work. + // resources we also rehydrate from a local autosave (UserDraft) so + // returning to the drawer mid-edit keeps your work — including edits + // staged for additional target workspaces. $effect(() => { if (selected !== undefined) return if (!effectiveWorkspace) return untrack(() => { selected = effectiveWorkspace if (!initialPath) { - const local = resourceDraftHandle.draft - const s: ResourceState = local ?? { + const localBundle = resourceDraftHandle.draft + const localFor = (ws: string): ResourceState | undefined => localBundle?.[ws] + + const baseState: ResourceState = { path: '', description: '', args: (defaultValues && Object.keys(defaultValues).length > 0 @@ -148,9 +157,23 @@ labels: undefined, wsSpecific: false } - states[effectiveWorkspace] = s - initialStates[effectiveWorkspace] = structuredClone(local ? { ...s, args: {} } : s) + + const local = localFor(effectiveWorkspace) + states[effectiveWorkspace] = local ? structuredClone(local) : baseState + // For new resources the "initial state" is the pristine empty + // state — that's the baseline dirty is computed against. + initialStates[effectiveWorkspace] = structuredClone(baseState) existedInitially[effectiveWorkspace] = false + + // Restore any other workspaces the user had staged edits for. + if (localBundle) { + for (const ws of Object.keys(localBundle)) { + if (ws === effectiveWorkspace) continue + states[ws] = structuredClone(localBundle[ws]) + initialStates[ws] = structuredClone(baseState) + existedInitially[ws] = false + } + } } }) }) @@ -173,12 +196,14 @@ labels: r.labels ?? undefined, wsSpecific: r.ws_specific ?? false } - // Local autosave wins for the user's own workspace if it - // diverges from the backend; for cross-workspace deploys we - // always start from the live backend value. - const local = ws === effectiveWorkspace ? resourceDraftHandle.draft : undefined - const useLocal = local && !deepEqual(local, backendState) - states[ws] = useLocal ? local : backendState + // If the local autosave has a saved state for *this* workspace + // and it diverges from the backend, use the local one. The + // localStorage entry itself lives under the user's session + // workspace (UserDraft's key) regardless of which target + // workspace this state belongs to. + const localState = resourceDraftHandle.draft?.[ws] + const useLocal = localState && !deepEqual(localState, backendState) + states[ws] = useLocal ? structuredClone(localState) : backendState initialStates[ws] = structuredClone(backendState) existedInitially[ws] = true perWsUser[ws] = user @@ -190,13 +215,11 @@ }) }) - // Auto-persist the current workspace's edit state to UserDraft on every - // mutation. useLocalStorageValue's lastSerialized check dedupes writes. + // Auto-persist the full multi-workspace edit bundle on every mutation. + // useLocalStorageValue's lastSerialized check dedupes writes. $effect(() => { - const s = states[effectiveWorkspace] - if (!s) return - readFieldsRecursively(s) - resourceDraftHandle.draft = s + readFieldsRecursively(states) + resourceDraftHandle.draft = states }) // Keep current.path bound to the outer `path` prop for consumers diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 5c3ae0a975..1700ae3650 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -74,6 +74,12 @@ }) ) + // The local autosave is the entire multi-workspace `states` bundle, so + // cross-workspace edits in the same drawer session are preserved across + // refresh. UserDraft keys on the user's session workspace regardless of + // which target workspace each state entry belongs to. + type VariableDraft = Record + // Lazy-fetch the variable for the selected workspace when not already cached $effect(() => { const ws = selected @@ -95,10 +101,10 @@ labels: v.labels ?? undefined, wsSpecific: v.ws_specific ?? false } - const local = - ws === $workspaceStore ? UserDraft.get('variable', p) : undefined - const useLocal = local && !deepEqual(local, backendState) - states[ws] = useLocal ? local : backendState + const localBundle = UserDraft.get('variable', p) + const localState = localBundle?.[ws] + const useLocal = localState && !deepEqual(localState, backendState) + states[ws] = useLocal ? structuredClone(localState) : backendState initialStates[ws] = structuredClone(backendState) existedInitially[ws] = true extraPerms[ws] = v.extra_perms ?? {} @@ -107,15 +113,12 @@ }) }) - // Persist the current workspace's edit state on every mutation. Empty - // path (new variable) stays in-memory only. + // Persist the full states bundle on every mutation. Empty path (new + // variable) stays in-memory only; UserDraft.save no-ops there. $effect(() => { - const ws = $workspaceStore - if (!ws) return - const s = states[ws] - if (!s) return - readFieldsRecursively(s) - UserDraft.save('variable', editPath ?? '', s) + if (Object.keys(states).length === 0) return + readFieldsRecursively(states) + UserDraft.save('variable', editPath ?? '', states) }) function reset() { @@ -131,20 +134,30 @@ reset() editPath = undefined const ws = $workspaceStore! - // Empty-path drafts live in-memory only; this just rehydrates a - // shared in-memory entry if one already exists on this page. - const local = UserDraft.get('variable', '') - const s: VariableState = local ?? { + // Empty-path drafts live in-memory only; rehydrate any in-memory + // bundle if one already exists on this page, including states + // staged for other target workspaces. + const localBundle = UserDraft.get('variable', '') + const baseState: VariableState = { path: '', variable: { value: '', is_secret: true, description: '' }, labels: undefined, wsSpecific: false } - states[ws] = s - initialStates[ws] = structuredClone( - local ? { ...s, variable: { ...s.variable, value: '' } } : s - ) + const localFor = (w: string): VariableState | undefined => localBundle?.[w] + + const localOwn = localFor(ws) + states[ws] = localOwn ? structuredClone(localOwn) : baseState + initialStates[ws] = structuredClone(baseState) existedInitially[ws] = false + if (localBundle) { + for (const w of Object.keys(localBundle)) { + if (w === ws) continue + states[w] = structuredClone(localBundle[w]) + initialStates[w] = structuredClone(baseState) + existedInitially[w] = false + } + } selected = ws drawer?.openDrawer() }