From fb221cb861ec0064f6e9b5e5d6205bb229f401e5 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 11 May 2026 11:46:55 +0200 Subject: [PATCH] feat(frontend): add UserDraft service for per-workspace local drafts Introduces UserDraft, a key-value store keyed by `{workspace}/{itemKind}/{path}` and backed by localStorage. Supports save/get/remove plus a reactive use() handle so multiple component instances observing the same draft stay in sync via a shared $state loaded through useLocalStorageValue. Designed to host drafts for scripts, flows, apps, raw apps, resources, variables, and all trigger kinds. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/userDraft.svelte.ts | 172 +++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 frontend/src/lib/userDraft.svelte.ts diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts new file mode 100644 index 0000000000..a952c7583e --- /dev/null +++ b/frontend/src/lib/userDraft.svelte.ts @@ -0,0 +1,172 @@ +import { get } from 'svelte/store' +import { onDestroy } from 'svelte' +import { workspaceStore } from './stores' +import { useLocalStorageValue } from './svelte5Utils.svelte' + +export type UserDraftItemKind = + | 'script' + | 'flow' + | 'app' + | 'raw_app' + | 'resource' + | 'variable' + | 'schedule' + | 'webhook' + | 'default_email' + | 'email' + | 'http' + | 'websocket' + | 'postgres' + | 'kafka' + | 'nats' + | 'mqtt' + | 'sqs' + | 'gcp' + | 'azure' + | 'poll' + | 'cli' + | 'nextcloud' + | 'google' + | 'github' + +export type UserDraftOptions = { + workspace?: string +} + +type DraftState = { val: V | undefined } + +type DraftEntry = { + count: number + state: DraftState +} + +const entries = new Map() + +function resolveWorkspace(opts?: UserDraftOptions): string { + const ws = opts?.workspace ?? get(workspaceStore) + if (!ws) { + throw new Error( + 'UserDraft: no workspace available (pass opts.workspace or set $workspaceStore)' + ) + } + return ws +} + +function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string { + return `${workspace}/${itemKind}/${path}` +} + +function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: string): string { + return `userdraft/w/${workspace}/${itemKind}/${path}` +} + +export type UserDraftHandle = { + get draft(): V | undefined + set draft(value: V | undefined) +} + +export const UserDraft = { + save(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + // Update the shared reactive state so all observers are notified. + entry.state.val = value + return + } + try { + localStorage.setItem(localStorageKey(ws, itemKind, path), JSON.stringify(value)) + } catch (e) { + console.error('UserDraft.save: localStorage write failed', e) + } + }, + + get( + itemKind: UserDraftItemKind, + path: string, + opts?: UserDraftOptions + ): V | undefined { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + return entry.state.val as V | undefined + } + try { + const raw = localStorage.getItem(localStorageKey(ws, itemKind, path)) + if (raw == null || raw === 'undefined') return undefined + return JSON.parse(raw) as V + } catch (e) { + console.error('UserDraft.get: localStorage read failed', e) + return undefined + } + }, + + remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const lk = localStorageKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + // Notify observers via the shared state setter. + entry.state.val = undefined + } + try { + // Always remove (the setter above would persist "undefined" as a string). + localStorage.removeItem(lk) + } catch (e) { + console.error('UserDraft.remove: localStorage remove failed', e) + } + }, + + use( + itemKind: UserDraftItemKind, + path: string, + opts?: UserDraftOptions + ): UserDraftHandle { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const lk = localStorageKey(ws, itemKind, path) + + let entry = entries.get(mk) + if (!entry) { + entry = { count: 1, state: useLocalStorageValue(lk, undefined) } + entries.set(mk, entry) + } else { + entry.count++ + } + + const sharedEntry = entry + + onDestroy(() => { + const e = entries.get(mk) + if (!e) return + e.count-- + if (e.count <= 0) { + entries.delete(mk) + } + }) + + return { + get draft(): V | undefined { + return sharedEntry.state.val as V | undefined + }, + set draft(value: V | undefined) { + sharedEntry.state.val = value + if (value === undefined) { + try { + localStorage.removeItem(lk) + } catch (e) { + console.error('UserDraft.use: localStorage remove failed', e) + } + } + } + } + } +} + +/** Test-only: clear all in-memory entries. */ +export function __resetUserDraftForTesting(): void { + entries.clear() +}