diff --git a/frontend/src/lib/components/common/confirmationModal/UserDraftConflictModal.svelte b/frontend/src/lib/components/common/confirmationModal/UserDraftConflictModal.svelte new file mode 100644 index 0000000000..6959ba809d --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/UserDraftConflictModal.svelte @@ -0,0 +1,130 @@ + + +{#if open && conflict} + +{/if} diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 03db43dc1a..61134e2453 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -1,8 +1,10 @@ import { get } from 'svelte/store' import { onDestroy, untrack } from 'svelte' import { deepEqual } from 'fast-equals' -import { workspaceStore } from './stores' +import { userStore, workspaceStore } from './stores' import { useLocalStorageValue } from './svelte5Utils.svelte' +import { UserDraftDbSyncer, isSyncableKind } from './userDraftDbSyncer.svelte' +import { UserDraftConflictStore } from './userDraftConflictStore.svelte' export const USER_DRAFT_ITEM_KINDS = [ 'script', @@ -348,8 +350,49 @@ export function localDraftDiffers( return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig)) } +/** + * Persist a draft to localStorage and (when the kind has a backend `draft` + * row) push it through the DbSyncer. Internal `_skipSync` opts pass-through + * lets the syncer write missed drafts back to LS without re-syncing them. + */ +function pushToSyncer( + itemKind: UserDraftItemKind, + path: string, + value: unknown, + workspace: string +): void { + if (!isSyncableKind(itemKind)) return + const user = get(userStore)?.username + if (!user) return + UserDraftDbSyncer.pushDrafts({ + workspace, + user, + drafts: [{ itemKind, path, value }], + onMissedDrafts: (drafts) => { + for (const d of drafts) { + UserDraft.save(d.typ as UserDraftItemKind, d.path, d.value, { + workspace, + _skipSync: true + }) + } + }, + onDraftsRejected: (rejected) => { + UserDraftConflictStore.enqueue( + rejected.map((r) => ({ + workspace, + user, + itemKind: r.typ, + rejected: r + })) + ) + } + }) +} + +type SaveOptions = UserDraftOptions & { _skipSync?: boolean } + export const UserDraft = { - save(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { + save(itemKind: UserDraftItemKind, path: string, value: V, opts?: SaveOptions): void { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) @@ -361,18 +404,21 @@ export const UserDraft = { const meta = extractMeta(current) entry.state.setWithoutPersist(wrap(value, meta)) persistDirect(localStorageKey(ws, itemKind, path), value, meta) - return + } else { + // No live handle: preserve any persisted meta so the staleness + // signal survives a write while the editor is closed. + const existing = readPersisted(localStorageKey(ws, itemKind, path)) + try { + localStorage.setItem( + localStorageKey(ws, itemKind, path), + JSON.stringify(stamp(wrap(value, extractMeta(existing)))) + ) + } catch (e) { + console.error('UserDraft.save: localStorage write failed', e) + } } - // No live handle: preserve any persisted meta so the staleness - // signal survives a write while the editor is closed. - const existing = readPersisted(localStorageKey(ws, itemKind, path)) - try { - localStorage.setItem( - localStorageKey(ws, itemKind, path), - JSON.stringify(stamp(wrap(value, extractMeta(existing)))) - ) - } catch (e) { - console.error('UserDraft.save: localStorage write failed', e) + if (!opts?._skipSync) { + pushToSyncer(itemKind, path, value, ws) } }, @@ -729,6 +775,22 @@ function acquireEntry( undefined, useLocalStorageOptions ) + // Mirror live-handle writes (e.g. `handle.draft = X` from an editor's + // $effect) to the DbSyncer. Skip the first run — the initial value + // came from localStorage or the default, not from a user mutation, so + // echoing it back would create a redundant request on every editor + // mount. + let firstRun = true + $effect(() => { + const stored = stateRef!.val + if (firstRun) { + firstRun = false + return + } + const value = stored === undefined ? undefined : (stored.value as unknown) + if (value === undefined) return + pushToSyncer(itemKind, path, value, workspace) + }) }) if (stateRef) { entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot }) diff --git a/frontend/src/lib/userDraftConflictStore.svelte.ts b/frontend/src/lib/userDraftConflictStore.svelte.ts new file mode 100644 index 0000000000..2b5efde94b --- /dev/null +++ b/frontend/src/lib/userDraftConflictStore.svelte.ts @@ -0,0 +1,37 @@ +/** + * Module-level queue of UserDraft sync conflicts (drafts rejected by the + * server because a newer version was saved since the client's last sync). + * + * Components anywhere in the app can call `enqueueConflicts()` to surface a + * conflict; the single `UserDraftConflictModal` mounted in the root layout + * consumes them one at a time so the user can decide per-conflict. + */ +import type { RejectedDraft, SyncableItemKind } from './userDraftDbSyncer.svelte' + +export type ConflictEntry = { + workspace: string + user: string + itemKind: SyncableItemKind + rejected: RejectedDraft +} + +let pending = $state([]) + +export const UserDraftConflictStore = { + get current(): ConflictEntry | undefined { + return pending[0] + }, + get hasAny(): boolean { + return pending.length > 0 + }, + enqueue(entries: ConflictEntry[]): void { + if (entries.length === 0) return + pending.push(...entries) + }, + dismiss(): void { + pending.shift() + }, + clear(): void { + pending.length = 0 + } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index c6fca60e37..bcf6d717e2 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -16,6 +16,8 @@ import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte' import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte' import ForkConflictModal from '$lib/components/ForkConflictModal.svelte' + import UserDraftConflictModal from '$lib/components/common/confirmationModal/UserDraftConflictModal.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { enterpriseLicense, isPremiumStore, @@ -99,6 +101,11 @@ goto('/user/login') } + // Bind UserDraftDbSyncer's lastSync cell to this layout's Svelte scope. + // Without this it still works via raw localStorage reads, but the value + // isn't reactive across tabs. + UserDraftDbSyncer.mount() + function onQueryChangeUserSettings() { if (userSettings && page.url.hash.startsWith(USER_SETTINGS_HASH)) { const mcpMode = page.url.hash.includes('-mcp') @@ -856,6 +863,8 @@ + +