fix(frontend): persist full multi-workspace bundle for resources/variables

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<wsId, State>` 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) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-05-12 16:32:28 +02:00
parent 655189e087
commit 2dd5c6e19f
2 changed files with 75 additions and 39 deletions
@@ -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<string, ResourceState>
const dispatch = createEventDispatcher()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let initialPath = path
const resourceDraftHandle = UserDraft.use<ResourceState>('resource', initialPath ?? '')
const resourceDraftHandle = UserDraft.use<ResourceDraft>('resource', initialPath ?? '')
let states: Record<string, ResourceState> = $state({})
let initialStates: Record<string, ResourceState> = $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
@@ -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<string, VariableState>
// 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<VariableState>('variable', p) : undefined
const useLocal = local && !deepEqual(local, backendState)
states[ws] = useLocal ? local : backendState
const localBundle = UserDraft.get<VariableDraft>('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<VariableState>('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<VariableDraft>('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()
}