mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 00:01:34 +00:00
feat(frontend): DB-backed user-scoped drafts; drop local-storage + conflict UX
Rework UserDraft into a purely in-memory two-way sync layer and push every
change to the backend draft table via a new UserDraftDbService.
- userDraft.svelte.ts: no more localStorage, staleness/meta tracking, GC, or
legacy migration. Reactive in-memory cells; the handle setter syncs each edit
through UserDraftDbService (debounced). Callers seed the loaded value with
setInitial() (no write-back); meta/setDraftAndMeta/setMeta kept as inert
shims so non-editor callers compile.
- userDraftDbService.ts (new): save({path, itemKind, content}) maps the kind to
a draft typ (script/flow/app; raw_app→app) and calls createDraft, or
deleteDraft when content is undefined. Debounced; skips kinds without a DB
draft and brand-new items (empty path).
- Remove all draft conflict UX: LocalDraftStaleModal, userDraftToast, the
legacy-migration module, the staleness checks/modals/restore toasts and the
URL-hash sync in the script/flow/app/raw-app editors.
- Drop draft_only everywhere it was read/sent (builders, rows, DraftBadge,
editor headers, list filters include_draft_only, copilot, sessions).
- Editors now load their own value and seed it; edits autosync. "Restore to
deployed" deletes the DB draft explicitly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,26 +4,16 @@
|
||||
|
||||
interface Props {
|
||||
has_draft?: boolean
|
||||
draft_only?: boolean
|
||||
}
|
||||
|
||||
let { has_draft = false, draft_only = false }: Props = $props()
|
||||
let { has_draft = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if has_draft}
|
||||
{#if draft_only}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
Never deployed and is only a draft
|
||||
{/snippet}
|
||||
<Badge small color="indigo">Draft only</Badge>
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
Is deployed and has a draft
|
||||
{/snippet}
|
||||
<Badge small color="indigo">+Draft</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
Is deployed and has a draft
|
||||
{/snippet}
|
||||
<Badge small color="indigo">+Draft</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
@@ -131,7 +131,6 @@
|
||||
onDeployError,
|
||||
onDetails,
|
||||
onSaveDraftError,
|
||||
onSaveDraftOnlyAtNewPath,
|
||||
onHistoryRestore,
|
||||
onNavigate
|
||||
}: FlowBuilderProps = $props()
|
||||
@@ -299,14 +298,7 @@
|
||||
loadingDraft = true
|
||||
try {
|
||||
const flow = cleanFlow(flowStore.val)
|
||||
if (newFlow || savedFlow?.draft_only) {
|
||||
if (savedFlow?.draft_only) {
|
||||
await FlowService.deleteFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
keepCaptures: true
|
||||
})
|
||||
}
|
||||
if (newFlow) {
|
||||
if (!initialPath || $pathStore != initialPath) {
|
||||
await CaptureService.moveCapturesAndConfigs({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -326,7 +318,6 @@
|
||||
value: flow.value,
|
||||
schema: flow.schema,
|
||||
tag: flow.tag,
|
||||
draft_only: true,
|
||||
ws_error_handler_muted: flow.ws_error_handler_muted,
|
||||
visible_to_runner_only: flow.visible_to_runner_only,
|
||||
on_behalf_of_email: flow.on_behalf_of_email,
|
||||
@@ -337,7 +328,7 @@
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath,
|
||||
path: newFlow ? $pathStore : initialPath,
|
||||
typ: 'flow',
|
||||
value: {
|
||||
...flow,
|
||||
@@ -348,11 +339,10 @@
|
||||
})
|
||||
|
||||
savedFlow = {
|
||||
...(newFlow || savedFlow?.draft_only
|
||||
...(newFlow
|
||||
? {
|
||||
...structuredClone($state.snapshot(flowStore.val)),
|
||||
path: $pathStore,
|
||||
draft_only: true
|
||||
path: $pathStore
|
||||
}
|
||||
: savedFlow),
|
||||
draft: {
|
||||
@@ -365,11 +355,6 @@
|
||||
let savedAtNewPath = false
|
||||
if (newFlow) {
|
||||
onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' })
|
||||
} else if (savedFlow?.draft_only && $pathStore !== initialPath) {
|
||||
savedAtNewPath = true
|
||||
initialPath = $pathStore
|
||||
onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' })
|
||||
// this is so we can use the flow builder outside of sveltekit
|
||||
}
|
||||
onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow })
|
||||
sendUserToast('Saved as draft')
|
||||
@@ -400,7 +385,7 @@
|
||||
|
||||
async function handleSaveFlowInternal(deploymentMsg?: string) {
|
||||
await compareVersions()
|
||||
if (onLatest || initialPath == '' || savedFlow?.draft_only) {
|
||||
if (onLatest || initialPath == '') {
|
||||
// Handle directly
|
||||
await saveFlow(deploymentMsg)
|
||||
} else {
|
||||
@@ -800,15 +785,13 @@
|
||||
}> = []
|
||||
|
||||
if (untrack(() => customUi).topBar?.extraDeployOptions != false) {
|
||||
if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) {
|
||||
dropdownItems.push({
|
||||
label: 'Exit & see details',
|
||||
// Use the deployed path, not the live `$pathStore` — the latter
|
||||
// reflects local rename edits that haven't been deployed yet,
|
||||
// which would land the user on a 404 details page.
|
||||
onClick: () => onDetails?.({ path: initialPath })
|
||||
})
|
||||
}
|
||||
dropdownItems.push({
|
||||
label: 'Exit & see details',
|
||||
// Use the deployed path, not the live `$pathStore` — the latter
|
||||
// reflects local rename edits that haven't been deployed yet,
|
||||
// which would land the user on a 404 details page.
|
||||
onClick: () => onDetails?.({ path: initialPath })
|
||||
})
|
||||
|
||||
if (!untrack(() => newFlow)) {
|
||||
dropdownItems.push({
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
|
||||
interface Props {
|
||||
canSave?: boolean
|
||||
@@ -80,42 +79,6 @@
|
||||
// no DB-draft concept, so only `remoteRev` is ever populated.
|
||||
let fetchedRev: Record<string, string | undefined> = $state({})
|
||||
|
||||
// Local-draft staleness modal: opened when the backend resource moved
|
||||
// on (someone else edited it) since the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let pendingStale: { ws: string; backend: ResourceState } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingStale) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { ws, backend } = pendingStale
|
||||
// Drop the divergent autosave and reset the handle to the freshly
|
||||
// fetched backend state. A later edit re-creates the autosave and
|
||||
// the seeding effect records the new rev.
|
||||
UserDraft.discard('resource', initialPath ?? '', backend, { workspace: ws })
|
||||
initialStates[ws] = $state.snapshot(backend) as ResourceState
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingStale) {
|
||||
const { ws } = pendingStale
|
||||
// Ack the new backend rev so the modal doesn't fire again until
|
||||
// the backend moves once more. Keeps the local autosave intact.
|
||||
UserDraft.saveMeta(
|
||||
'resource',
|
||||
initialPath ?? '',
|
||||
{ remoteRev: fetchedRev[ws] },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
const handlesArray = UserDraft.useMany<ResourceState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
itemKind: 'resource' as const,
|
||||
@@ -262,34 +225,6 @@
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
}
|
||||
// Reconcile the local autosave with the backend before the
|
||||
// handle is registered. If the backend moved on since the
|
||||
// autosave was written (recorded rev != current rev) surface
|
||||
// the staleness modal; otherwise the form is just showing the
|
||||
// user's unsaved work — a toast with a "Reset to deployed"
|
||||
// escape is enough.
|
||||
const persisted = UserDraft.get<ResourceState>('resource', initialPath ?? '', {
|
||||
workspace: ws
|
||||
})
|
||||
const previousMeta = UserDraft.getMeta('resource', initialPath ?? '', { workspace: ws })
|
||||
if (persisted !== undefined && !deepEqual(persisted, s)) {
|
||||
const cause = checkStaleness(previousMeta, r.edited_at)
|
||||
if (cause) {
|
||||
pendingStale = { ws, backend: s }
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy autosave (no rev recorded) — backfill so the
|
||||
// next backend change is detectable as drift.
|
||||
UserDraft.saveMeta(
|
||||
'resource',
|
||||
initialPath ?? '',
|
||||
{ remoteRev: r.edited_at },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
existedInitially[ws] = true
|
||||
@@ -438,13 +373,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause="version"
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col gap-6 py-2">
|
||||
{#if otherDirty.length > 0}
|
||||
|
||||
@@ -696,15 +696,7 @@
|
||||
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
|
||||
}
|
||||
let newHash = ''
|
||||
if (initialPath == '' || savedScript?.draft_only) {
|
||||
if (savedScript?.draft_only) {
|
||||
await ScriptService.deleteScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
keepCaptures: true
|
||||
})
|
||||
script.parent_hash = undefined
|
||||
}
|
||||
if (initialPath == '') {
|
||||
if (!initialPath || script.path != initialPath) {
|
||||
await CaptureService.moveCapturesAndConfigs({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -727,7 +719,6 @@
|
||||
language: script.language,
|
||||
kind: script.kind,
|
||||
tag: script.tag,
|
||||
draft_only: true,
|
||||
envs: script.envs,
|
||||
concurrent_limit: script.concurrent_limit,
|
||||
concurrency_time_window_s: script.concurrency_time_window_s,
|
||||
@@ -762,7 +753,7 @@
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath,
|
||||
path: initialPath == '' ? script.path : initialPath,
|
||||
typ: 'script',
|
||||
value: {
|
||||
...script,
|
||||
@@ -773,9 +764,7 @@
|
||||
|
||||
const clonedScript = structuredClone($state.snapshot(script))
|
||||
savedScript = {
|
||||
...(initialPath == '' || savedScript?.draft_only
|
||||
? { ...clonedScript, draft_only: true }
|
||||
: savedScript),
|
||||
...(initialPath == '' ? clonedScript : savedScript),
|
||||
draft: {
|
||||
...clonedScript,
|
||||
draft_triggers: draftTriggers
|
||||
@@ -783,7 +772,7 @@
|
||||
} as NewScriptWithDraftAndDraftTriggers
|
||||
|
||||
let savedAtNewPath = false
|
||||
if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) {
|
||||
if (initialPath == '') {
|
||||
savedAtNewPath = true
|
||||
initialPath = script.path
|
||||
onSaveInitial?.({ path: script.path, hash: newHash })
|
||||
@@ -862,10 +851,7 @@
|
||||
: [])
|
||||
]
|
||||
: []),
|
||||
...(!inSessionPane &&
|
||||
!script.draft_only &&
|
||||
script.kind === 'script' &&
|
||||
!script.auto_kind
|
||||
...(!inSessionPane && script.kind === 'script' && !script.auto_kind
|
||||
? [
|
||||
{
|
||||
label: 'Exit & See details',
|
||||
@@ -1960,7 +1946,7 @@
|
||||
{hasPreprocessor}
|
||||
canHavePreprocessor={canHavePreprocessor(script.language)}
|
||||
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
|
||||
isDeployed={savedScript && !savedScript?.draft_only}
|
||||
isDeployed={!!savedScript}
|
||||
schema={script.schema}
|
||||
runnableVersion={script.parent_hash}
|
||||
onDeployTrigger={handleDeployTrigger}
|
||||
@@ -2122,7 +2108,7 @@
|
||||
{template}
|
||||
tag={script.tag}
|
||||
lastSavedCode={savedScript?.draft?.content}
|
||||
lastDeployedCode={savedScript?.draft_only ? undefined : savedScript?.content}
|
||||
lastDeployedCode={savedScript?.content}
|
||||
bind:args
|
||||
bind:hasPreprocessor
|
||||
bind:captureTable
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -48,37 +47,6 @@
|
||||
// no DB-draft concept, so only `remoteRev` is ever populated.
|
||||
let fetchedRev: Record<string, string | undefined> = $state({})
|
||||
|
||||
// Local-draft staleness modal: opened when the backend variable moved
|
||||
// on (someone else edited it) since the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let pendingStale: { ws: string; backend: VariableState } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingStale) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { ws, backend } = pendingStale
|
||||
UserDraft.discard('variable', editPath ?? '', backend, { workspace: ws })
|
||||
initialStates[ws] = $state.snapshot(backend) as VariableState
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingStale) {
|
||||
const { ws } = pendingStale
|
||||
UserDraft.saveMeta(
|
||||
'variable',
|
||||
editPath ?? '',
|
||||
{ remoteRev: fetchedRev[ws] },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
const handlesArray = UserDraft.useMany<VariableState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
itemKind: 'variable' as const,
|
||||
@@ -173,23 +141,6 @@
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
}
|
||||
// See ResourceEditor for the same pattern: a backend that
|
||||
// moved on since the autosave was written → staleness modal;
|
||||
// otherwise just a "showing your local autosave" toast with
|
||||
// a "Reset to deployed" escape.
|
||||
const persisted = UserDraft.get<VariableState>('variable', p, { workspace: ws })
|
||||
const previousMeta = UserDraft.getMeta('variable', p, { workspace: ws })
|
||||
if (persisted !== undefined && !deepEqual(persisted, s)) {
|
||||
const cause = checkStaleness(previousMeta, v.edited_at)
|
||||
if (cause) {
|
||||
pendingStale = { ws, backend: s }
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws })
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
existedInitially[ws] = true
|
||||
@@ -315,13 +266,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause="version"
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
<Drawer bind:this={drawer} size="50rem">
|
||||
<DrawerContent
|
||||
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined
|
||||
@@ -377,7 +376,6 @@
|
||||
path: newEditedPath,
|
||||
summary: $summary,
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
}
|
||||
})
|
||||
@@ -400,7 +398,6 @@
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
path: newEditedPath,
|
||||
policy,
|
||||
draft_only: true,
|
||||
draft: {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
@@ -454,27 +451,10 @@
|
||||
try {
|
||||
policy = await updatePolicy($app, policy)
|
||||
let path = $appPath
|
||||
if (savedApp.draft_only) {
|
||||
await AppService.deleteApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: path
|
||||
})
|
||||
await AppService.createApp({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
value: $app!,
|
||||
summary: $summary,
|
||||
policy,
|
||||
path: newEditedPath || path,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
}
|
||||
})
|
||||
}
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: savedApp.draft_only ? newEditedPath || path : path,
|
||||
path: path,
|
||||
typ: 'app',
|
||||
value: {
|
||||
value: $app!,
|
||||
@@ -486,16 +466,7 @@
|
||||
})
|
||||
|
||||
savedApp = {
|
||||
...(savedApp?.draft_only
|
||||
? {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
path: savedApp.draft_only ? newEditedPath || path : path,
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
}
|
||||
: savedApp),
|
||||
...savedApp,
|
||||
draft: {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
@@ -508,7 +479,7 @@
|
||||
sendUserToast('Draft saved')
|
||||
if (!inSessionPane) UserDraft.remove('app', path)
|
||||
loading.saveDraft = false
|
||||
if (newApp || savedApp.draft_only) {
|
||||
if (newApp) {
|
||||
onSavedNewAppPath?.(newEditedPath || path)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -689,7 +660,7 @@
|
||||
action: () => {
|
||||
appReportingDrawerOpen = true
|
||||
},
|
||||
disabled: !savedApp || savedApp.draft_only
|
||||
disabled: !savedApp
|
||||
},
|
||||
{
|
||||
displayName: 'Diff',
|
||||
@@ -905,7 +876,7 @@
|
||||
<div class="flex flex-row gap-4">
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={!savedApp || savedApp.draft_only}
|
||||
disabled={!savedApp}
|
||||
on:click={async () => {
|
||||
if (!savedApp) {
|
||||
return
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Modal shown when the four route-level editors detect that the
|
||||
* remote state has moved on since the local autosave was captured —
|
||||
* either a teammate (or another tab) pushed a fresh "Save as draft"
|
||||
* via `DraftService` (`cause = 'draft'`), or the deployed version
|
||||
* changed (`cause = 'version'`).
|
||||
*
|
||||
* Sits above the per-browser `UserDraft` autosave layer and is
|
||||
* separate from the backend `DraftService` flow. The actual reset
|
||||
* actions live at each call site; this modal just owns the
|
||||
* "Load latest version" vs "Keep current draft" decision.
|
||||
*/
|
||||
import { classNames } from '$lib/utils'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from '../button/Button.svelte'
|
||||
import { CornerDownLeft, RefreshCcw } from 'lucide-svelte'
|
||||
|
||||
// 'url' is temporary — used by /scripts/{add,edit}'s URL-hash sync block
|
||||
// while we wait for a future PR to replace that legacy behavior.
|
||||
type Cause = 'draft' | 'version' | 'url'
|
||||
|
||||
let {
|
||||
open = false,
|
||||
cause = 'version',
|
||||
onLoadLatest,
|
||||
onKeepDraft
|
||||
}: {
|
||||
open?: boolean
|
||||
/** What changed on the remote since the local draft was created. */
|
||||
cause?: Cause
|
||||
onLoadLatest: () => void | Promise<void>
|
||||
onKeepDraft: () => void | Promise<void>
|
||||
} = $props()
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (!open) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
onLoadLatest()
|
||||
break
|
||||
case 'Escape':
|
||||
onKeepDraft()
|
||||
break
|
||||
}
|
||||
}
|
||||
function fadeFast(node: HTMLElement) {
|
||||
return fade(node, { duration: 100 })
|
||||
}
|
||||
|
||||
const title = $derived(
|
||||
cause === 'draft'
|
||||
? 'A newer draft was saved on the server'
|
||||
: cause === 'url'
|
||||
? 'The URL contains a different script payload'
|
||||
: 'A newer version was deployed on the server'
|
||||
)
|
||||
const body = $derived(
|
||||
cause === 'draft'
|
||||
? "The editor is showing your local autosave. Someone else (or another tab) pushed a newer draft to the server while you were editing — your copy is now behind. Load latest replaces what's on screen; Keep current leaves it alone."
|
||||
: cause === 'url'
|
||||
? 'The current page URL encodes a script (e.g. from a Fork link or shared URL) that differs from your local autosave. Load from URL replaces your local draft; Keep current draft ignores the URL payload.'
|
||||
: "The editor is showing your local autosave. A newer version was deployed while you were editing — your copy is now behind. Load latest replaces what's on screen; Keep current leaves it alone."
|
||||
)
|
||||
const loadLabel = $derived(cause === 'url' ? 'Load from URL' : 'Load latest version')
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydowncapture={onKeyDown} />
|
||||
|
||||
{#if open}
|
||||
<div transition:fadeFast|local class="fixed top-0 bottom-0 left-0 right-0 z-[9999]" role="dialog">
|
||||
<div
|
||||
class={classNames(
|
||||
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
|
||||
open ? 'ease-out duration-300 opacity-100' : 'ease-in duration-200 opacity-0'
|
||||
)}
|
||||
></div>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
class={classNames(
|
||||
'relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
|
||||
open
|
||||
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
|
||||
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
|
||||
)}
|
||||
>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800/50"
|
||||
>
|
||||
<RefreshCcw class="text-blue-700 dark:text-blue-300" />
|
||||
</div>
|
||||
<div class="ml-4 flex-1 text-left">
|
||||
<h3 class="text-lg font-medium text-primary">{title}</h3>
|
||||
<p class="mt-2 text-sm text-secondary">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
||||
<Button
|
||||
on:click={() => onLoadLatest()}
|
||||
color="dark"
|
||||
size="sm"
|
||||
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
|
||||
variant="accent"
|
||||
>
|
||||
<span class="min-w-20">{loadLabel}</span>
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => onKeepDraft()}
|
||||
variant="default"
|
||||
size="sm"
|
||||
shortCut={{ key: 'Esc', withoutModifier: true }}
|
||||
>
|
||||
Keep current draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -14,7 +14,6 @@
|
||||
import {
|
||||
ExternalLink,
|
||||
Eye,
|
||||
File,
|
||||
FileJson,
|
||||
FolderOpen,
|
||||
GitFork,
|
||||
@@ -37,7 +36,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
app: ListableApp & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
|
||||
app: ListableApp & { has_draft?: boolean; canWrite: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -86,7 +85,7 @@
|
||||
path={app.path}
|
||||
summary={app.summary}
|
||||
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={!app.draft_only}
|
||||
canFavorite={true}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
>
|
||||
@@ -98,7 +97,7 @@
|
||||
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
|
||||
{/if}
|
||||
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
|
||||
<DraftBadge has_draft={app.has_draft} draft_only={app.draft_only} />
|
||||
<DraftBadge has_draft={app.has_draft} />
|
||||
{#if app.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each app.labels.slice(0, 3) as label}
|
||||
@@ -155,40 +154,9 @@
|
||||
aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`}
|
||||
aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`}
|
||||
items={async () => {
|
||||
let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app
|
||||
let { canWrite, summary, execution_mode, path, has_draft } = app
|
||||
|
||||
const canEdit = canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
return [
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: async (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
|
||||
dispatch('change')
|
||||
} else {
|
||||
deleteConfirmedCallback = async () => {
|
||||
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
|
||||
dispatch('change')
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canEdit,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: $userStore?.operator ? 'View JSON' : 'View/Edit JSON',
|
||||
icon: File,
|
||||
action: () => {
|
||||
loadAppJson()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
displayName: 'Duplicate/Fork',
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
|
||||
flow: Flow & { has_draft?: boolean; canWrite: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
@@ -103,16 +103,14 @@
|
||||
<Row
|
||||
aiId={`flow-row-${flow.path}`}
|
||||
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
|
||||
href={flow.draft_only
|
||||
? `${base}/flows/edit/${flow.path}?nodraft=true`
|
||||
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
|
||||
href={`${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
|
||||
kind="flow"
|
||||
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
|
||||
{marked}
|
||||
path={flow.path}
|
||||
summary={flow.summary}
|
||||
{errorHandlerMuted}
|
||||
canFavorite={!flow.draft_only}
|
||||
canFavorite={true}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
>
|
||||
@@ -121,7 +119,7 @@
|
||||
<Badge color="red" baseClass="border">archived</Badge>
|
||||
{/if}
|
||||
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
|
||||
<DraftBadge has_draft={flow.has_draft} draft_only={flow.draft_only} />
|
||||
<DraftBadge has_draft={flow.has_draft} />
|
||||
{#if flow.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each flow.labels.slice(0, 3) as label}
|
||||
@@ -180,30 +178,9 @@
|
||||
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
|
||||
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
|
||||
items={async () => {
|
||||
let { draft_only, path, archived, has_draft } = flow
|
||||
let { path, archived, has_draft } = flow
|
||||
let owner = isOwner(path, $userStore, $workspaceStore)
|
||||
const canEdit = flow.canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
return [
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: (event) => {
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteFlow(path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteFlow(path)
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
displayName: 'View runs',
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
<Row
|
||||
aiId={`script-run-button-${script.path}`}
|
||||
aiDescription={`Button to access the form to run the script ${script.summary ?? script.path}`}
|
||||
href={script.draft_only || (script.auto_kind === 'lib' && script.kind !== 'preprocessor')
|
||||
href={script.auto_kind === 'lib' && script.kind !== 'preprocessor'
|
||||
? `${base}/scripts/edit/${script.path}`
|
||||
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
|
||||
kind="script"
|
||||
@@ -130,7 +130,7 @@
|
||||
summary={script.summary}
|
||||
{errorHandlerMuted}
|
||||
workspaceId={$workspaceStore ?? ''}
|
||||
canFavorite={!script.draft_only}
|
||||
canFavorite={true}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
>
|
||||
@@ -168,7 +168,7 @@
|
||||
>
|
||||
{/if}
|
||||
<SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} />
|
||||
<DraftBadge has_draft={script.has_draft} draft_only={script.draft_only} />
|
||||
<DraftBadge has_draft={script.has_draft} />
|
||||
{#if script.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each script.labels.slice(0, 3) as label}
|
||||
@@ -239,34 +239,6 @@
|
||||
items={async () => {
|
||||
let owner = isOwner(script.path, $userStore, $workspaceStore)
|
||||
const canEdit = script.canWrite && showEditButton
|
||||
if (script.draft_only) {
|
||||
return [
|
||||
{
|
||||
displayName: 'View code',
|
||||
icon: Code,
|
||||
action: () => {
|
||||
showCode(script.path, script.summary)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteScript(script.path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteScript(script.path)
|
||||
}
|
||||
}
|
||||
},
|
||||
type: dlt,
|
||||
disabled: !canEdit
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
displayName: 'View code',
|
||||
|
||||
@@ -424,10 +424,7 @@ export class AIChatManager {
|
||||
return {
|
||||
kind: 'flow',
|
||||
path: this.flowOptions?.path,
|
||||
deployed:
|
||||
!!this.flowOptions?.path &&
|
||||
!!this.flowOptions.lastDeployedFlow &&
|
||||
!this.flowOptions.lastDeployedFlow.draft_only
|
||||
deployed: !!this.flowOptions?.path && !!this.flowOptions.lastDeployedFlow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,8 +516,7 @@ export class AIChatManager {
|
||||
this.tools = globalToolsFor({ sessionPreview: this.isSessionChat })
|
||||
this.helpers = {
|
||||
...(this.isSessionChat ? { sessionId: this.sessionId } : {}),
|
||||
testActiveFlow: async (args?: Record<string, any>) =>
|
||||
this.flowAiChatHelpers?.testFlow(args)
|
||||
testActiveFlow: async (args?: Record<string, any>) => this.flowAiChatHelpers?.testFlow(args)
|
||||
} satisfies GlobalToolHelpers
|
||||
} else if (mode === AIMode.APP) {
|
||||
const customPrompt = getCombinedCustomPrompt(mode)
|
||||
|
||||
@@ -128,10 +128,7 @@ const INSTRUCTION_SUBJECTS = [
|
||||
// `datatable` is not a workspace item type, but the model can request the
|
||||
// datatable SDK reference (the wmill.datatable() runnable API) the same way.
|
||||
const INSTRUCTION_SUBJECTS_EXTRA = ['datatable'] as const
|
||||
const ALL_INSTRUCTION_SUBJECTS = [
|
||||
...INSTRUCTION_SUBJECTS,
|
||||
...INSTRUCTION_SUBJECTS_EXTRA
|
||||
] as const
|
||||
const ALL_INSTRUCTION_SUBJECTS = [...INSTRUCTION_SUBJECTS, ...INSTRUCTION_SUBJECTS_EXTRA] as const
|
||||
const MAX_LIST_LIMIT = 100
|
||||
type ActiveGlobalEditorType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
|
||||
type LiveEditorDraftKind = Parameters<typeof UserDraft.getLiveEditorDraft>[0]
|
||||
@@ -163,7 +160,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum)
|
||||
|
||||
const getInstructionsSchema = z.object({
|
||||
subject: instructionSubjectSchema.describe(
|
||||
"What to get authoring instructions for: a workspace item type (script, flow, resource, app) or \"datatable\" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don't need instructions — their tool schemas describe everything."
|
||||
'What to get authoring instructions for: a workspace item type (script, flow, resource, app) or "datatable" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.'
|
||||
),
|
||||
language: scriptLangSchema
|
||||
.optional()
|
||||
@@ -1208,7 +1205,6 @@ async function listWorkspaceItems(
|
||||
workspace,
|
||||
pathStart: pathPrefix,
|
||||
perPage,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
for (const script of scripts) items.push(scriptToItem(script, false))
|
||||
@@ -1219,7 +1215,6 @@ async function listWorkspaceItems(
|
||||
workspace,
|
||||
pathStart: pathPrefix,
|
||||
perPage,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
for (const flow of flows) items.push(flowToItem(flow, false))
|
||||
@@ -2208,13 +2203,7 @@ async function writeScriptDraft(
|
||||
content: args.content,
|
||||
language: args.language
|
||||
}
|
||||
UserDraft.setDraftAndMeta(
|
||||
'script',
|
||||
storagePath,
|
||||
draft,
|
||||
{ remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at },
|
||||
{ workspace }
|
||||
)
|
||||
UserDraft.save('script', storagePath, draft, { workspace })
|
||||
} else {
|
||||
const draft: NewScript = {
|
||||
path: args.path,
|
||||
@@ -2265,10 +2254,7 @@ async function writeFlowDraft(
|
||||
}
|
||||
UserDraft.save('flow', storagePath, draft, { workspace })
|
||||
} else if (backendExists) {
|
||||
const [existing, latestVersion] = await Promise.all([
|
||||
FlowService.getFlowByPathWithDraft({ workspace, path: args.path }),
|
||||
FlowService.getFlowLatestVersion({ workspace, path: args.path })
|
||||
])
|
||||
const existing = await FlowService.getFlowByPathWithDraft({ workspace, path: args.path })
|
||||
const base = (existing.draft ?? existing) as Flow
|
||||
const draft: Flow = {
|
||||
...structuredClone(base),
|
||||
@@ -2277,13 +2263,7 @@ async function writeFlowDraft(
|
||||
value,
|
||||
schema: draftValue.schema ?? base.schema
|
||||
}
|
||||
UserDraft.setDraftAndMeta(
|
||||
'flow',
|
||||
storagePath,
|
||||
draft,
|
||||
{ remoteRev: latestVersion.id, remoteDraftRev: existing.draft_created_at },
|
||||
{ workspace }
|
||||
)
|
||||
UserDraft.save('flow', storagePath, draft, { workspace })
|
||||
} else {
|
||||
const draft: Flow = {
|
||||
path: args.path,
|
||||
@@ -2381,11 +2361,10 @@ async function writeResourceDraft(args: CreateResource, ctx: WriteDraftCtx): Pro
|
||||
})
|
||||
} else if (backendExists) {
|
||||
const existing = await ResourceService.getResource({ workspace, path: args.path })
|
||||
UserDraft.setDraftAndMeta(
|
||||
UserDraft.save(
|
||||
'resource',
|
||||
args.path,
|
||||
createResourceToDraftState(args, resourceToDraftState(existing)),
|
||||
{ remoteRev: existing.edited_at },
|
||||
{ workspace }
|
||||
)
|
||||
} else {
|
||||
@@ -2418,11 +2397,10 @@ async function writeVariableDraft(args: CreateVariable, ctx: WriteDraftCtx): Pro
|
||||
path: args.path,
|
||||
decryptSecret: false
|
||||
})
|
||||
UserDraft.setDraftAndMeta(
|
||||
UserDraft.save(
|
||||
'variable',
|
||||
args.path,
|
||||
createVariableToDraftState(args, variableToDraftState(existing)),
|
||||
{ remoteRev: existing.edited_at },
|
||||
{ workspace }
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -354,11 +354,9 @@ export function saveGlobalAppDraft(
|
||||
): WorkspaceItem {
|
||||
const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path)
|
||||
const normalized = normalizeAppDraftValue(value)
|
||||
if (meta) {
|
||||
UserDraft.setDraftAndMeta('raw_app', storagePath, normalized, meta, { workspace })
|
||||
} else {
|
||||
UserDraft.save('raw_app', storagePath, normalized, { workspace })
|
||||
}
|
||||
// Persist (and update any live editor) regardless of meta — meta tracking
|
||||
// (staleness) was removed.
|
||||
UserDraft.save('raw_app', storagePath, normalized, { workspace })
|
||||
const stored = getGlobalDraft(workspace, 'app', path)
|
||||
if (!stored) throw new Error(`Could not read written app draft "${path}".`)
|
||||
return stored
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
hasPreprocessor={!!flowStore.val.value.preprocessor_module}
|
||||
canHavePreprocessor={true}
|
||||
args={previewArgs.val}
|
||||
isDeployed={savedFlow && !savedFlow?.draft_only}
|
||||
isDeployed={!!savedFlow}
|
||||
schema={flowStore.val.schema}
|
||||
{onDeployTrigger}
|
||||
/>
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
workspace: $workspaceStore!,
|
||||
showArchived: archived ? true : undefined,
|
||||
includeWithoutMain: includeWithoutMain ? true : undefined,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
|
||||
@@ -108,7 +107,6 @@
|
||||
await FlowService.listFlows({
|
||||
workspace: $workspaceStore!,
|
||||
showArchived: archived ? true : undefined,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
).map((x: Flow) => {
|
||||
@@ -124,17 +122,15 @@
|
||||
}
|
||||
|
||||
async function loadApps(): Promise<void> {
|
||||
apps = (await AppService.listApps({ workspace: $workspaceStore!, includeDraftOnly: true })).map(
|
||||
(app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) &&
|
||||
app.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...app
|
||||
}
|
||||
apps = (await AppService.listApps({ workspace: $workspaceStore! })).map((app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) &&
|
||||
app.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...app
|
||||
}
|
||||
)
|
||||
})
|
||||
loading = false
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined
|
||||
@@ -474,7 +473,6 @@
|
||||
path: newEditedPath,
|
||||
summary: summary,
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
},
|
||||
js,
|
||||
@@ -500,7 +498,6 @@
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
path: newEditedPath,
|
||||
policy,
|
||||
draft_only: true,
|
||||
draft: {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
@@ -558,33 +555,10 @@
|
||||
try {
|
||||
await computeTriggerables()
|
||||
let path = appPath
|
||||
if (savedApp.draft_only) {
|
||||
await AppService.deleteApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: path
|
||||
})
|
||||
let { css, js } = await getBundle()
|
||||
|
||||
await AppService.createAppRaw({
|
||||
workspace: $workspaceStore!,
|
||||
formData: {
|
||||
app: {
|
||||
value: app!,
|
||||
summary: summary,
|
||||
policy,
|
||||
path: newEditedPath || path,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
},
|
||||
js,
|
||||
css
|
||||
}
|
||||
})
|
||||
}
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: savedApp.draft_only ? newEditedPath || path : path,
|
||||
path: path,
|
||||
typ: 'app',
|
||||
value: {
|
||||
value: app!,
|
||||
@@ -596,16 +570,7 @@
|
||||
})
|
||||
|
||||
savedApp = {
|
||||
...(savedApp?.draft_only
|
||||
? {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
path: savedApp.draft_only ? newEditedPath || path : path,
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: customPath
|
||||
}
|
||||
: savedApp),
|
||||
...savedApp,
|
||||
draft: {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
@@ -618,7 +583,7 @@
|
||||
sendUserToast('Draft saved')
|
||||
if (!inSessionPane) UserDraft.remove('raw_app', path)
|
||||
loading.saveDraft = false
|
||||
if (newApp || savedApp.draft_only) {
|
||||
if (newApp) {
|
||||
dispatch('savedNewAppPath', newEditedPath || path)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -788,7 +753,7 @@
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!savedApp || savedApp.draft_only}
|
||||
disabled={!savedApp}
|
||||
on:click={async () => {
|
||||
if (!savedApp) {
|
||||
return
|
||||
|
||||
@@ -424,7 +424,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
value: result.value as any,
|
||||
path: result.path,
|
||||
policy: result.policy,
|
||||
draft_only: result.draft_only,
|
||||
draft_only: false,
|
||||
draft: result.draft,
|
||||
custom_path: result.custom_path
|
||||
}
|
||||
@@ -456,7 +456,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
value: result.value as any,
|
||||
path: result.path,
|
||||
policy: result.policy,
|
||||
draft_only: result.draft_only,
|
||||
draft_only: false,
|
||||
draft: result.draft,
|
||||
custom_path: result.custom_path
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
import Select from '../select/Select.svelte'
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
value?: string
|
||||
}
|
||||
|
||||
let { value = $bindable('') }: Props = $props();
|
||||
let { value = $bindable('') }: Props = $props()
|
||||
let darkMode = $state(false)
|
||||
|
||||
const { appPath } = getContext<AppViewerContext>('AppViewerContext')
|
||||
@@ -20,17 +20,15 @@
|
||||
let apps: ListableApp[] = $state([])
|
||||
|
||||
async function loadApps(): Promise<void> {
|
||||
apps = (await AppService.listApps({ workspace: $workspaceStore!, includeDraftOnly: true })).map(
|
||||
(app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) &&
|
||||
app.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...app
|
||||
}
|
||||
apps = (await AppService.listApps({ workspace: $workspaceStore! })).map((app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) &&
|
||||
app.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...app
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -102,7 +102,6 @@ export async function loadKind(
|
||||
if (kind === 'flow') {
|
||||
const flows = await FlowService.listFlows({
|
||||
workspace,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
items = flows.map((f: Flow) => ({
|
||||
@@ -113,7 +112,6 @@ export async function loadKind(
|
||||
} else if (kind === 'script') {
|
||||
const scripts = await ScriptService.listScripts({
|
||||
workspace,
|
||||
includeDraftOnly: true,
|
||||
withoutDescription: true
|
||||
})
|
||||
items = scripts.map((s: Script) => ({
|
||||
@@ -123,8 +121,7 @@ export async function loadKind(
|
||||
}))
|
||||
} else {
|
||||
const apps = await AppService.listApps({
|
||||
workspace,
|
||||
includeDraftOnly: true
|
||||
workspace
|
||||
})
|
||||
items = apps.map((a: ListableApp) => ({
|
||||
path: a.path,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { get } from 'svelte/store'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { workspaceStore } from './stores'
|
||||
import { useLocalStorageValue } from './svelte5Utils.svelte'
|
||||
import { UserDraftDbService } from './userDraftDbService'
|
||||
|
||||
export const USER_DRAFT_ITEM_KINDS = [
|
||||
'script',
|
||||
@@ -39,9 +39,9 @@ export type UserDraftOptions = {
|
||||
|
||||
export type UserDraftUseOptions<V> = UserDraftOptions & {
|
||||
/**
|
||||
* Initial value used when localStorage holds no draft for this
|
||||
* (workspace, itemKind, path). It is *not* eagerly persisted — the first
|
||||
* actual mutation is what writes to localStorage.
|
||||
* Initial in-memory value for this (workspace, itemKind, path). It is *not*
|
||||
* synced to the DB — only subsequent edits are. Callers are responsible for
|
||||
* loading the real value (e.g. from `getXByPathWithDraft`) and seeding it.
|
||||
*/
|
||||
defaultValue?: V
|
||||
}
|
||||
@@ -50,11 +50,6 @@ export type UserDraftListOptions = UserDraftOptions & {
|
||||
itemKinds?: readonly UserDraftItemKind[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A single (kind, path, workspace) tuple that `useMany` should hold a handle
|
||||
* for. The shape mirrors `use()`'s arguments, just bundled into one object
|
||||
* so a getter can return a list of them.
|
||||
*/
|
||||
export type UserDraftSpec<V> = {
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
@@ -63,68 +58,23 @@ export type UserDraftSpec<V> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the remote item's freshness at the moment the local draft was
|
||||
* written. Used by editor routes to detect that the remote has moved on
|
||||
* (someone else deployed, or saved a DB draft) so we can warn the user
|
||||
* before they push stale changes.
|
||||
* Vestigial type kept so callers that still pass/read a "meta" object compile.
|
||||
* The old staleness/conflict-resolution layer is gone; meta is ignored
|
||||
* (writes are dropped, reads return `{}`).
|
||||
*
|
||||
* - `remoteRev`: the deployed version's id/hash/timestamp at draft creation.
|
||||
* - `remoteDraftRev`: the DB-draft `created_at` at draft creation, only set
|
||||
* for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`).
|
||||
* @deprecated drafts no longer track remote revs / staleness.
|
||||
*/
|
||||
export type UserDraftMeta = {
|
||||
remoteRev?: string | number
|
||||
remoteDraftRev?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of what we actually persist. Wrapping the value lets us add
|
||||
* metadata (timestamps, originating user, schema version, ...) later
|
||||
* without breaking existing entries.
|
||||
*
|
||||
* `lastWrittenAt` is the unix-ms timestamp of the most recent write
|
||||
* (setter call or deep mutation flush). It's the GC signal —
|
||||
* `gcUserDrafts` sweeps entries that haven't been touched in N days.
|
||||
* Set at every persist via `useLocalStorageValue`'s `transformBeforePersist`,
|
||||
* `UserDraft.save`'s direct-write fallback, and `persistDirect`. Missing
|
||||
* (undefined) on entries written before this field was introduced;
|
||||
* `gcUserDrafts` backfills them on first sighting.
|
||||
*/
|
||||
type StoredDraft<V> = { value: V; lastWrittenAt?: number } & UserDraftMeta
|
||||
|
||||
function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefined {
|
||||
if (stored === undefined) return undefined
|
||||
return { ...stored, lastWrittenAt: Date.now() }
|
||||
}
|
||||
|
||||
type DraftState<V> = {
|
||||
val: StoredDraft<V> | undefined
|
||||
skipNextWriteOnce(): void
|
||||
setWithoutPersist(newVal: StoredDraft<V> | undefined): void
|
||||
}
|
||||
|
||||
type DraftEntry = {
|
||||
count: number
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
state: DraftState<unknown>
|
||||
/**
|
||||
* Tears down the `$effect.root` scope that owns the entry's
|
||||
* `useLocalStorageValue` reactivity — its `$state` cell and the persist
|
||||
* `$effect` deep-mutation loop. Called when the refcount hits 0.
|
||||
*
|
||||
* `undefined` only when the test runtime's broken `$effect.root` forced
|
||||
* us through the fallback path (see `acquireEntry`).
|
||||
*/
|
||||
destroyRoot?: () => void
|
||||
}
|
||||
|
||||
export type UserDraftEntry<V = unknown> = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
value: V | undefined
|
||||
/** Always `{}` — kept for shape compatibility. */
|
||||
meta: UserDraftMeta
|
||||
persisted: boolean
|
||||
live: boolean
|
||||
@@ -148,6 +98,22 @@ export type ClearLiveEditorDraftOptions = UserDraftOptions & {
|
||||
storagePath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A single in-memory draft cell. Reactive via `$state` so editors can bind to
|
||||
* it; the refcount keeps it alive while at least one `use*` spec references it.
|
||||
*/
|
||||
class DraftCell {
|
||||
value = $state<unknown>(undefined)
|
||||
}
|
||||
|
||||
type DraftEntry = {
|
||||
count: number
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
cell: DraftCell
|
||||
}
|
||||
|
||||
const entries = new Map<string, DraftEntry>()
|
||||
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
|
||||
|
||||
@@ -161,115 +127,14 @@ function resolveWorkspace(opts?: UserDraftOptions): string {
|
||||
return ws
|
||||
}
|
||||
|
||||
function wrap<V>(value: V | undefined, meta?: UserDraftMeta): StoredDraft<V> | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const out: StoredDraft<V> = { value }
|
||||
if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev
|
||||
if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev
|
||||
return out
|
||||
}
|
||||
|
||||
function unwrap<V>(stored: StoredDraft<V> | undefined): V | undefined {
|
||||
return stored?.value
|
||||
}
|
||||
|
||||
function extractMeta(stored: StoredDraft<unknown> | undefined): UserDraftMeta {
|
||||
if (!stored) return {}
|
||||
const meta: UserDraftMeta = {}
|
||||
if (stored.remoteRev !== undefined) meta.remoteRev = stored.remoteRev
|
||||
if (stored.remoteDraftRev !== undefined) meta.remoteDraftRev = stored.remoteDraftRev
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the rev metadata recorded against the local draft to the current
|
||||
* backend revs. Returns the staleness cause, or `null` when the local draft
|
||||
* is still based on the latest backend state we know about.
|
||||
*
|
||||
* - Entries with no recorded meta (legacy entries written before this field
|
||||
* existed) report `null` — we can't tell if they're stale, and we'd rather
|
||||
* trust the local autosave than spam the user with false positives.
|
||||
* - DB-draft staleness wins over deployed-version staleness: a remote DB
|
||||
* draft is the more recent state to reconcile against.
|
||||
* - If a DB draft existed when the local autosave was created but now no
|
||||
* longer exists on the remote (someone discarded it), we report `version`
|
||||
* because the deployed version is now the canonical "latest saved".
|
||||
*/
|
||||
export type UserDraftStalenessCause = 'draft' | 'version'
|
||||
|
||||
export function checkStaleness(
|
||||
meta: UserDraftMeta,
|
||||
currentRev: string | number | undefined,
|
||||
currentDraftRev?: string | number | undefined
|
||||
): UserDraftStalenessCause | null {
|
||||
if (meta.remoteRev === undefined && meta.remoteDraftRev === undefined) return null
|
||||
if (meta.remoteDraftRev !== currentDraftRev) {
|
||||
return currentDraftRev !== undefined ? 'draft' : 'version'
|
||||
}
|
||||
if (currentRev !== undefined && meta.remoteRev !== currentRev) return 'version'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous localStorage write, bypassing the entry's debounced setter
|
||||
* and its first-write skip. See `setMeta({ force: true })`.
|
||||
*/
|
||||
function persistDirect<V>(key: string, value: V | undefined, meta: UserDraftMeta): void {
|
||||
try {
|
||||
const next = stamp(wrap(value, meta))
|
||||
if (next === undefined) {
|
||||
localStorage.removeItem(key)
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(next))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('UserDraft: localStorage write failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function readPersisted<V>(key: string): StoredDraft<V> | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null || raw === 'undefined') return undefined
|
||||
const parsed = JSON.parse(raw)
|
||||
// Defensive: ignore pre-wrapping payloads (no `.value`).
|
||||
if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined
|
||||
return parsed as StoredDraft<V>
|
||||
} catch (e) {
|
||||
console.error('UserDraft: localStorage read failed', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string {
|
||||
return `${workspace}/${itemKind}`
|
||||
}
|
||||
|
||||
function parseLocalStorageKey(
|
||||
key: string,
|
||||
workspace: string,
|
||||
itemKinds: readonly UserDraftItemKind[]
|
||||
): { itemKind: UserDraftItemKind; path: string } | undefined {
|
||||
const prefix = `userdraft/w/${workspace}/`
|
||||
if (!key.startsWith(prefix)) return undefined
|
||||
const rest = key.slice(prefix.length)
|
||||
for (const itemKind of itemKinds) {
|
||||
const kindPrefix = `${itemKind}/`
|
||||
if (rest.startsWith(kindPrefix)) {
|
||||
return { itemKind, path: rest.slice(kindPrefix.length) }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function snapshotDraftValue<V>(value: V | undefined): V | undefined {
|
||||
if (value === undefined) return undefined
|
||||
try {
|
||||
@@ -285,35 +150,26 @@ function snapshotDraftValue<V>(value: V | undefined): V | undefined {
|
||||
|
||||
export type UserDraftHandle<V> = {
|
||||
get draft(): V | undefined
|
||||
/** Set the draft and sync the change to the DB (debounced). */
|
||||
set draft(value: V | undefined)
|
||||
/**
|
||||
* Read the rev metadata stored alongside the current draft. Empty object
|
||||
* if the entry has no draft or no rev was ever recorded.
|
||||
* Seed the in-memory value WITHOUT syncing to the DB. Use when loading the
|
||||
* value the editor already fetched from the backend, so re-binding it does
|
||||
* not write it straight back.
|
||||
*/
|
||||
setInitial(value: V | undefined): void
|
||||
/** @deprecated meta is ignored — alias of `draft = value`. */
|
||||
get meta(): UserDraftMeta
|
||||
/**
|
||||
* Set value AND rev metadata in one write (no extra persist). Later
|
||||
* `draft = X` writes preserve the rev metadata.
|
||||
*/
|
||||
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void
|
||||
/**
|
||||
* Update rev metadata without touching the value. `{ force: true }` also
|
||||
* persists synchronously — use when this may be the entry's first write,
|
||||
* else the ack is lost on remount.
|
||||
*/
|
||||
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
|
||||
/** @deprecated meta is ignored — sets the value and syncs it. */
|
||||
setDraftAndMeta(value: V | undefined, meta?: UserDraftMeta): void
|
||||
/** @deprecated no-op (staleness tracking removed). */
|
||||
setMeta(meta?: UserDraftMeta, opts?: { force?: boolean }): void
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON round-trip normalization. localStorage persistence stringifies the
|
||||
* draft, which silently drops keys whose value is `undefined`, turns `Date`
|
||||
* into a string, etc. A freshly-built config object (e.g. a trigger editor's
|
||||
* `getXConfig()`) keeps those `undefined`-valued keys, so a raw
|
||||
* `deepEqual(persistedDraft, freshConfig)` reports spurious differences
|
||||
* (`{ a: undefined }` ≠ `{}`). Normalize BOTH sides through the same
|
||||
* round-trip before comparing. Returns the input unchanged if it can't be
|
||||
* serialized (e.g. a cyclic structure) — better a false "differs" than a
|
||||
* throw inside a load/effect path.
|
||||
* JSON round-trip normalization. Drops `undefined`-valued keys, turns `Date`
|
||||
* into a string, etc., so two structurally-equal configs compare equal even if
|
||||
* one was freshly built (keeps `undefined` keys) and the other round-tripped.
|
||||
*/
|
||||
export function normalizeForCompare<V>(value: V | undefined): V | undefined {
|
||||
if (value === undefined) return undefined
|
||||
@@ -325,20 +181,9 @@ export function normalizeForCompare<V>(value: V | undefined): V | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the persisted local autosave (`localDraft`, as returned by
|
||||
* `UserDraft.get`) meaningfully differs from the freshly-built
|
||||
* `currentConfig`. Editor restore guards use this to decide whether to
|
||||
* overlay the local autosave and toast.
|
||||
*
|
||||
* Returns `false` when there is no local draft. Normalizes both sides (see
|
||||
* `normalizeForCompare`) so a draft that round-trips equal to the deployed
|
||||
* config — e.g. one written by merely opening then closing the editor with
|
||||
* no edits — is correctly treated as "no meaningful draft" instead of
|
||||
* spuriously triggering a restore on every reopen.
|
||||
*
|
||||
* Typed as a guard: a `true` result narrows `localDraft` to non-nullish
|
||||
* `V`, mirroring the `localCfg && …` narrowing it replaces so call sites
|
||||
* can pass the draft straight into `loadXConfig(...)` without re-checking.
|
||||
* Whether `localDraft` meaningfully differs from `currentConfig` (after
|
||||
* `normalizeForCompare`). Typed as a guard so a `true` result narrows
|
||||
* `localDraft` to non-nullish `V`.
|
||||
*/
|
||||
export function localDraftDiffers<V>(
|
||||
localDraft: V | undefined | null,
|
||||
@@ -348,61 +193,49 @@ export function localDraftDiffers<V>(
|
||||
return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig))
|
||||
}
|
||||
|
||||
function setCell(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: unknown | undefined,
|
||||
sync: boolean
|
||||
): void {
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
entry.cell.value = value
|
||||
}
|
||||
if (sync) {
|
||||
UserDraftDbService.save({ path, itemKind, content: snapshotDraftValue(value), workspace })
|
||||
}
|
||||
}
|
||||
|
||||
export const UserDraft = {
|
||||
/** Set the draft value and sync it to the DB (debounced). */
|
||||
save<V>(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) {
|
||||
// Static writes are external mutations. Update live observers and
|
||||
// force the storage slot to match, even if the live entry still has
|
||||
// its initial-write skip armed.
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
const meta = extractMeta(current)
|
||||
entry.state.setWithoutPersist(wrap(value, meta))
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
return
|
||||
}
|
||||
// No live handle: preserve any persisted meta so the staleness
|
||||
// signal survives a write while the editor is closed.
|
||||
const existing = readPersisted<unknown>(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)
|
||||
}
|
||||
setCell(ws, itemKind, path, value, true)
|
||||
},
|
||||
|
||||
/**
|
||||
* @deprecated meta is ignored. Seeds the in-memory value WITHOUT syncing
|
||||
* (it was historically used to install a known/loaded baseline). Use
|
||||
* `save` to persist a programmatic change.
|
||||
*/
|
||||
setDraftAndMeta<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V | undefined,
|
||||
meta: UserDraftMeta,
|
||||
_meta?: UserDraftMeta,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
// Static writes represent explicit external draft mutations. A
|
||||
// freshly acquired live entry may still have the initial-write skip
|
||||
// armed, so force the storage slot to match the live value.
|
||||
entry.state.setWithoutPersist(wrap(value, meta))
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
return
|
||||
}
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
setCell(ws, itemKind, path, value, false)
|
||||
},
|
||||
|
||||
/**
|
||||
* Autosave gate: persist `value` only when it differs (after
|
||||
* `normalizeForCompare`) from the `deployed` baseline; otherwise remove
|
||||
* any draft. Without this, opening and closing an editor with no edits
|
||||
* would leave a no-op draft that `has()` / restore guards treat as
|
||||
* unsaved work.
|
||||
* Sync `value` only if it differs (after `normalizeForCompare`) from the
|
||||
* `deployed` baseline; otherwise delete the draft.
|
||||
*/
|
||||
saveIfChanged<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
@@ -424,129 +257,75 @@ export const UserDraft = {
|
||||
opts?: UserDraftOptions
|
||||
): V | undefined {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
return snapshotDraftValue(unwrap(entry.state.val as StoredDraft<V> | undefined))
|
||||
}
|
||||
return snapshotDraftValue(unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path))))
|
||||
const entry = entries.get(mapKey(ws, itemKind, path))
|
||||
return snapshotDraftValue(untrack(() => entry?.cell.value as V | undefined))
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the rev metadata for an entry without touching the value, and
|
||||
* persist immediately. Used by editor routes that don't hold a live
|
||||
* handle (apps, raw apps) — they read the local draft via `UserDraft.get`
|
||||
* and the handle is created later inside the child editor.
|
||||
*
|
||||
* No-op when the entry has no draft to attach meta to.
|
||||
*/
|
||||
/** @deprecated meta is ignored — always returns `{}`. */
|
||||
saveMeta(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
meta: UserDraftMeta,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
if (current === undefined) return
|
||||
entry.state.val = wrap(current.value, meta)
|
||||
}
|
||||
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
|
||||
if (existing === undefined) return
|
||||
persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta)
|
||||
_itemKind: UserDraftItemKind,
|
||||
_path: string,
|
||||
_meta?: UserDraftMeta,
|
||||
_opts?: UserDraftOptions
|
||||
): void {},
|
||||
|
||||
/** @deprecated meta is ignored — always returns `{}`. */
|
||||
getMeta(_itemKind: UserDraftItemKind, _path: string, _opts?: UserDraftOptions): UserDraftMeta {
|
||||
return {}
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the rev metadata for the entry. Returns an empty object if there
|
||||
* is no entry. Useful for staleness checks before reading the draft.
|
||||
*/
|
||||
getMeta(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): UserDraftMeta {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) return extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
|
||||
return extractMeta(readPersisted<unknown>(localStorageKey(ws, itemKind, path)))
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a draft currently exists for (workspace, itemKind, path).
|
||||
* Falls back to the persisted localStorage entry when no live handle is
|
||||
* registered. Useful for distinguishing "first visit" from "returning
|
||||
* visit with unsaved local changes".
|
||||
*/
|
||||
has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) return entry.state.val !== undefined
|
||||
return readPersisted(localStorageKey(ws, itemKind, path)) !== undefined
|
||||
const entry = entries.get(mapKey(ws, itemKind, path))
|
||||
return untrack(() => entry?.cell.value) !== undefined
|
||||
},
|
||||
|
||||
/** Drop the draft: clear the in-memory value and delete the DB draft. */
|
||||
remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey(ws, itemKind, path))
|
||||
} catch (e) {
|
||||
console.error('UserDraft.remove: localStorage remove failed', e)
|
||||
}
|
||||
setCell(ws, itemKind, path, undefined, true)
|
||||
},
|
||||
|
||||
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
UserDraft.discard(itemKind, path, undefined, opts)
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the in-memory value to `fallback` (e.g. the deployed baseline) and
|
||||
* delete the DB draft. `fallback` is deep-cloned so the handle and the
|
||||
* caller's baseline don't share a proxy.
|
||||
*/
|
||||
discard<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
fallback: V | undefined,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const safeFallback = snapshotDraftValue(fallback)
|
||||
setCell(ws, itemKind, path, safeFallback, false)
|
||||
UserDraftDbService.save({ path, itemKind, content: undefined, workspace: ws })
|
||||
},
|
||||
|
||||
list<V = unknown>(opts?: UserDraftListOptions): UserDraftEntry<V>[] {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS
|
||||
const out = new Map<string, UserDraftEntry<V>>()
|
||||
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key)
|
||||
}
|
||||
for (const key of keys) {
|
||||
const parsed = parseLocalStorageKey(key, ws, itemKinds)
|
||||
if (!parsed) continue
|
||||
const stored = readPersisted<V>(key)
|
||||
if (stored === undefined) continue
|
||||
out.set(mapKey(ws, parsed.itemKind, parsed.path), {
|
||||
workspace: ws,
|
||||
itemKind: parsed.itemKind,
|
||||
path: parsed.path,
|
||||
value: snapshotDraftValue(unwrap(stored)),
|
||||
meta: extractMeta(stored),
|
||||
persisted: true,
|
||||
live: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const out: UserDraftEntry<V>[] = []
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue
|
||||
const stored = untrack(() => entry.state.val as StoredDraft<V> | undefined)
|
||||
const mk = mapKey(entry.workspace, entry.itemKind, entry.path)
|
||||
if (stored === undefined) {
|
||||
out.delete(mk)
|
||||
continue
|
||||
}
|
||||
const existing = out.get(mk)
|
||||
out.set(mk, {
|
||||
const value = untrack(() => entry.cell.value as V | undefined)
|
||||
if (value === undefined) continue
|
||||
out.push({
|
||||
workspace: entry.workspace,
|
||||
itemKind: entry.itemKind,
|
||||
path: entry.path,
|
||||
value: snapshotDraftValue(unwrap(stored)),
|
||||
meta: extractMeta(stored),
|
||||
persisted: existing?.persisted ?? false,
|
||||
value: snapshotDraftValue(value),
|
||||
meta: {},
|
||||
persisted: false,
|
||||
live: true
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(out.values())
|
||||
return out
|
||||
},
|
||||
|
||||
setLiveEditorDraft(spec: LiveEditorDraftSpec): void {
|
||||
@@ -577,72 +356,20 @@ export const UserDraft = {
|
||||
liveEditorDrafts.delete(key)
|
||||
},
|
||||
|
||||
/**
|
||||
* Like `remove`, but also resets any live handle's `draft` to
|
||||
* `fallback` in-memory (so reactive readers see it immediately) and
|
||||
* skips re-persisting it, leaving the LS slot empty until the next real
|
||||
* edit. Pass the deployed baseline as `fallback`.
|
||||
*
|
||||
* `fallback` is deep-cloned before being installed — otherwise a caller
|
||||
* who passes their own live `$state` baseline (e.g. resource/variable
|
||||
* editors' `initialStates[ws]`) would end up with `handle.draft` and the
|
||||
* baseline pointing at the *same* proxy; subsequent edits would mutate
|
||||
* both sides in lock-step and the dirty check would never fire.
|
||||
*/
|
||||
discard<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
fallback: V | undefined,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
const safeFallback = snapshotDraftValue(fallback)
|
||||
if (entry) {
|
||||
// Drop any queued debounced write owned by this live entry before
|
||||
// resetting the in-memory value. Otherwise a timer from the old
|
||||
// entry can outlive unmount and later delete a freshly written
|
||||
// draft for the same key.
|
||||
entry.state.setWithoutPersist(wrap(safeFallback) as StoredDraft<unknown> | undefined)
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey(ws, itemKind, path))
|
||||
} catch (e) {
|
||||
console.error('UserDraft.discard: localStorage remove failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
use<V = unknown>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
opts?: UserDraftUseOptions<V>
|
||||
): UserDraftHandle<V> {
|
||||
// `use()` is a single-spec wrapper around `useMany`. We untrack the
|
||||
// getter so that reactive opts (e.g. `$workspaceStore`) are captured
|
||||
// once at call time — the current `use()` contract is "the handle
|
||||
// stays bound to this workspace until the component unmounts." Use
|
||||
// `useMany` directly if you want spec changes to release/acquire
|
||||
// entries as you go.
|
||||
const handles = UserDraft.useMany<V>(() =>
|
||||
untrack(() => [
|
||||
{
|
||||
itemKind,
|
||||
path,
|
||||
workspace: opts?.workspace,
|
||||
defaultValue: opts?.defaultValue
|
||||
}
|
||||
{ itemKind, path, workspace: opts?.workspace, defaultValue: opts?.defaultValue }
|
||||
])
|
||||
)
|
||||
return handles[0]
|
||||
},
|
||||
|
||||
useMany<V = unknown>(getSpecs: () => UserDraftSpec<V>[]): UserDraftHandle<V>[] {
|
||||
// Reactive handles array, reconciled against the latest `getSpecs()`
|
||||
// output. Indices line up with the spec array. Handles for the same
|
||||
// (workspace, kind, path) tuple are reused across reconciles so
|
||||
// callers can capture a reference and keep it alive — only the
|
||||
// underlying entry's refcount moves.
|
||||
const handles = $state<UserDraftHandle<V>[]>([])
|
||||
const acquired = new Set<string>()
|
||||
const handleCache = new Map<string, UserDraftHandle<V>>()
|
||||
@@ -677,21 +404,12 @@ export const UserDraft = {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip no-op mutations (handles are cached by mapKey, so an
|
||||
// unchanged spec set yields reference-equal arrays). `untrack` so
|
||||
// this effect doesn't subscribe to its own `handles` write —
|
||||
// otherwise it self-loops (`effect_update_depth_exceeded`).
|
||||
// Downstream notification still propagates.
|
||||
untrack(() => {
|
||||
const unchanged = handles.length === next.length && handles.every((h, i) => h === next[i])
|
||||
if (!unchanged) handles.splice(0, handles.length, ...next)
|
||||
})
|
||||
}
|
||||
|
||||
// Synchronous initial reconcile so single-spec callers (`use()`) get a
|
||||
// populated `handles[0]` before the function returns. Reactive reads
|
||||
// inside `getSpecs()` here are intentionally not tracked — the
|
||||
// `$effect` below picks up any subsequent dependency changes.
|
||||
untrack(reconcile)
|
||||
$effect(reconcile)
|
||||
onDestroy(() => {
|
||||
@@ -716,40 +434,9 @@ function acquireEntry(
|
||||
existing.count++
|
||||
return
|
||||
}
|
||||
// `useLocalStorageValue`'s internal persist `$effect` would otherwise
|
||||
// parent to `useMany`'s reconcile effect and be torn down on the next
|
||||
// reconcile. `$effect.root` gives the entry its own scope, disposed only
|
||||
// by `releaseEntry`.
|
||||
const useLocalStorageOptions = {
|
||||
// First value is the baseline (don't persist it); coalesce edits.
|
||||
saveInitialValue: false,
|
||||
debounce: 500,
|
||||
// Stamp `lastWrittenAt` at persist time so deep mutations also bump
|
||||
// the GC clock (the setter doesn't re-run for those).
|
||||
transformBeforePersist: stamp<unknown>
|
||||
} as const
|
||||
let stateRef: DraftState<unknown> | undefined
|
||||
const destroyRoot = $effect.root(() => {
|
||||
stateRef = useLocalStorageValue<StoredDraft<unknown> | undefined>(
|
||||
localStorageKey(workspace, itemKind, path),
|
||||
wrap(defaultValue),
|
||||
undefined,
|
||||
useLocalStorageOptions
|
||||
)
|
||||
})
|
||||
if (stateRef) {
|
||||
entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot })
|
||||
return
|
||||
}
|
||||
// Fallback for the vitest runtime where `$effect.root`'s callback isn't
|
||||
// invoked. Unreachable in production (Svelte runs it synchronously).
|
||||
const state = useLocalStorageValue<StoredDraft<unknown> | undefined>(
|
||||
localStorageKey(workspace, itemKind, path),
|
||||
wrap(defaultValue),
|
||||
undefined,
|
||||
useLocalStorageOptions
|
||||
)
|
||||
entries.set(mk, { count: 1, workspace, itemKind, path, state })
|
||||
const cell = new DraftCell()
|
||||
cell.value = defaultValue
|
||||
entries.set(mk, { count: 1, workspace, itemKind, path, cell })
|
||||
}
|
||||
|
||||
function releaseEntry(mk: string): void {
|
||||
@@ -757,7 +444,6 @@ function releaseEntry(mk: string): void {
|
||||
if (!entry) return
|
||||
entry.count--
|
||||
if (entry.count <= 0) {
|
||||
entry.destroyRoot?.()
|
||||
entries.delete(mk)
|
||||
}
|
||||
}
|
||||
@@ -767,96 +453,36 @@ function makeHandle<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string
|
||||
): UserDraftHandle<V> {
|
||||
// The handle reads `entries.get(mk)` on every access. The entry it points
|
||||
// at is stable as long as the refcount stays > 0 (which `useMany` keeps
|
||||
// the case for as long as a spec references it). If the refcount drops to
|
||||
// 0 and the entry is destroyed, reads return `undefined` rather than
|
||||
// throwing — the consumer should already have been torn down by that point.
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
const stateOf = (): DraftState<unknown> | undefined => entries.get(mk)?.state
|
||||
const cellOf = (): DraftCell | undefined => entries.get(mk)?.cell
|
||||
return {
|
||||
get draft(): V | undefined {
|
||||
return unwrap(stateOf()?.val as StoredDraft<V> | undefined)
|
||||
return cellOf()?.value as V | undefined
|
||||
},
|
||||
set draft(value: V | undefined) {
|
||||
// Preserve existing rev metadata on a value edit. `untrack` the
|
||||
// read: callers often set this from inside a `$effect` mirroring
|
||||
// `$state` into the handle; a tracked read would subscribe that
|
||||
// effect to the cell it's about to write (self-loop →
|
||||
// effect_update_depth_exceeded).
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
state.val = wrap(value, extractMeta(current))
|
||||
const cell = cellOf()
|
||||
if (!cell) return
|
||||
const prev = untrack(() => cell.value)
|
||||
cell.value = value
|
||||
// Skip syncing no-op writes (e.g. a `bind:` writing the seeded value
|
||||
// straight back). Only real edits hit the DB.
|
||||
if (!deepEqual(normalizeForCompare(value), normalizeForCompare(prev as V | undefined))) {
|
||||
UserDraftDbService.save({ path, itemKind, content: snapshotDraftValue(value), workspace })
|
||||
}
|
||||
},
|
||||
setInitial(value: V | undefined): void {
|
||||
const cell = cellOf()
|
||||
if (!cell) return
|
||||
cell.value = value
|
||||
},
|
||||
get meta(): UserDraftMeta {
|
||||
return extractMeta(stateOf()?.val as StoredDraft<unknown> | undefined)
|
||||
return {}
|
||||
},
|
||||
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void {
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
state.val = wrap(value, meta)
|
||||
setDraftAndMeta(value: V | undefined, _meta?: UserDraftMeta): void {
|
||||
// Seed without syncing (historically used to install a loaded baseline).
|
||||
this.setInitial(value)
|
||||
},
|
||||
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void {
|
||||
// Read under `untrack` for the same reason as `set draft` above —
|
||||
// avoid making any surrounding effect re-fire on the write below.
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
if (current === undefined) return
|
||||
state.val = wrap(current.value, meta)
|
||||
if (opts?.force) {
|
||||
persistDirect(localStorageKey(workspace, itemKind, path), current.value, meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default GC retention window: 30 days. Entries that haven't been touched
|
||||
* (no setter call, no deep-mutation persist) for this long are swept on
|
||||
* the next `gcUserDrafts` invocation.
|
||||
*/
|
||||
export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Sweep stale UserDraft entries from localStorage. Walks every
|
||||
* `userdraft/w/...` key, checks its `lastWrittenAt` stamp, and removes
|
||||
* any entry older than `maxAgeMs`.
|
||||
*
|
||||
* Entries written before `lastWrittenAt` was introduced lack the field;
|
||||
* we backfill them to `now()` on first sighting so they participate in
|
||||
* the next sweep cycle rather than getting wiped immediately.
|
||||
*
|
||||
* Safe to call on every load and on a timer (e.g. every 30 min) — live
|
||||
* entries get their stamp refreshed on every persist, so the sweep only
|
||||
* touches truly stale records.
|
||||
*/
|
||||
export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
const now = Date.now()
|
||||
const cutoff = now - maxAgeMs
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k != null && k.startsWith('userdraft/w/')) keys.push(k)
|
||||
}
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) continue
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed == null || typeof parsed !== 'object') continue
|
||||
if (typeof parsed.lastWrittenAt !== 'number') {
|
||||
// Pre-GC-feature entry. Backfill so the next sweep can decide.
|
||||
parsed.lastWrittenAt = now
|
||||
localStorage.setItem(key, JSON.stringify(parsed))
|
||||
continue
|
||||
}
|
||||
if (parsed.lastWrittenAt < cutoff) localStorage.removeItem(key)
|
||||
} catch (e) {
|
||||
console.error('UserDraft GC: failed to inspect', key, e)
|
||||
}
|
||||
setMeta(_meta?: UserDraftMeta, _opts?: { force?: boolean }): void {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { DraftService } from './gen'
|
||||
import { workspaceStore } from './stores'
|
||||
import type { UserDraftItemKind } from './userDraft.svelte'
|
||||
|
||||
/**
|
||||
* Persists `UserDraft` changes to the backend `draft` table. `UserDraft` is a
|
||||
* purely in-memory two-way sync layer; this service is the bridge that turns
|
||||
* each in-memory change into a `DraftService.createDraft` / `deleteDraft` call.
|
||||
*
|
||||
* Only the kinds that have a backing DB draft are persisted (script/flow/app,
|
||||
* and raw_app which is stored under the `app` draft type). Every other kind
|
||||
* (resource, variable, the trigger_* kinds — which are folded into their
|
||||
* parent script/flow draft via `draft_triggers`) is in-memory only and is a
|
||||
* no-op here.
|
||||
*
|
||||
* Writes are debounced per (workspace, typ, path) so a burst of edits collapses
|
||||
* into a single network call; the latest payload always wins.
|
||||
*/
|
||||
|
||||
type DraftTyp = 'script' | 'flow' | 'app'
|
||||
|
||||
const ITEM_KIND_TO_DRAFT_TYP: Partial<Record<UserDraftItemKind, DraftTyp>> = {
|
||||
script: 'script',
|
||||
flow: 'flow',
|
||||
app: 'app',
|
||||
raw_app: 'app'
|
||||
}
|
||||
|
||||
export type UserDraftDbSaveArgs = {
|
||||
path: string
|
||||
itemKind: UserDraftItemKind
|
||||
/** The draft content to persist. `undefined` deletes the DB draft. */
|
||||
content: unknown | undefined
|
||||
/** Defaults to the current `$workspaceStore`. */
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 600
|
||||
|
||||
type Pending = {
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
content: unknown | undefined
|
||||
workspace: string
|
||||
typ: DraftTyp
|
||||
path: string
|
||||
}
|
||||
|
||||
const pending = new Map<string, Pending>()
|
||||
|
||||
function key(workspace: string, typ: DraftTyp, path: string): string {
|
||||
return `${workspace}/${typ}/${path}`
|
||||
}
|
||||
|
||||
async function flush(k: string): Promise<void> {
|
||||
const p = pending.get(k)
|
||||
if (!p) return
|
||||
pending.delete(k)
|
||||
try {
|
||||
if (p.content === undefined) {
|
||||
await DraftService.deleteDraft({ workspace: p.workspace, kind: p.typ, path: p.path })
|
||||
} else {
|
||||
await DraftService.createDraft({
|
||||
workspace: p.workspace,
|
||||
requestBody: { path: p.path, typ: p.typ, value: p.content }
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// Drafts are best-effort autosave — never interrupt the editor with a
|
||||
// toast or modal (see the drafts UX simplification). Log and move on.
|
||||
console.error('UserDraftDbService: failed to sync draft', p.typ, p.path, e)
|
||||
}
|
||||
}
|
||||
|
||||
export const UserDraftDbService = {
|
||||
/**
|
||||
* Schedule a debounced persist of `content` for (workspace, itemKind, path).
|
||||
* `content === undefined` deletes the draft. No-op for kinds without a DB
|
||||
* draft.
|
||||
*/
|
||||
save({ path, itemKind, content, workspace }: UserDraftDbSaveArgs): void {
|
||||
const typ = ITEM_KIND_TO_DRAFT_TYP[itemKind]
|
||||
if (!typ) return
|
||||
// A brand-new item is edited under the empty storage path until it gets a
|
||||
// real path; there is nothing valid to persist a draft against yet (the
|
||||
// explicit "Save as draft" with a real path handles that).
|
||||
if (path === '') return
|
||||
const ws = workspace ?? get(workspaceStore)
|
||||
if (!ws) return
|
||||
const k = key(ws, typ, path)
|
||||
const existing = pending.get(k)
|
||||
if (existing) clearTimeout(existing.timer)
|
||||
const timer = setTimeout(() => flush(k), DEBOUNCE_MS)
|
||||
pending.set(k, { timer, content, workspace: ws, typ, path })
|
||||
},
|
||||
|
||||
/** Whether `itemKind` is persisted to the DB (vs in-memory only). */
|
||||
hasDbDraft(itemKind: UserDraftItemKind): boolean {
|
||||
return ITEM_KIND_TO_DRAFT_TYP[itemKind] !== undefined
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
migrateLegacyUserDrafts,
|
||||
__resetUserDraftLegacyMigrationForTesting
|
||||
} from './userDraftLegacyMigration'
|
||||
|
||||
function encodeLegacy(value: unknown): string {
|
||||
return btoa(encodeURIComponent(JSON.stringify(value)))
|
||||
}
|
||||
|
||||
function wrapped<V>(value: V): string {
|
||||
return JSON.stringify({ value })
|
||||
}
|
||||
|
||||
// Read a migrated entry, strip the GC `lastWrittenAt` stamp so assertions
|
||||
// can match the `{ value }` shape regardless of when the migration ran.
|
||||
function storedShape(key: string): string | null {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
delete parsed.lastWrittenAt
|
||||
return JSON.stringify(parsed)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
__resetUserDraftLegacyMigrationForTesting()
|
||||
})
|
||||
|
||||
describe('migrateLegacyUserDrafts', () => {
|
||||
it('migrates a legacy app draft to the workspace-scoped key with a { value } wrapper', () => {
|
||||
// Shape mirrors what the legacy AppEditor wrote: `encodeState($appStore)`,
|
||||
// i.e. the inner App value, not the wrapping AppWithLastVersion.
|
||||
const legacyApp = {
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
theme: undefined,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
}
|
||||
localStorage.setItem('app-u/me/dashboard', encodeLegacy(legacyApp))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dashboard')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp))
|
||||
})
|
||||
|
||||
it('migrates a legacy empty-path app draft (the `app` literal key)', () => {
|
||||
const legacyApp = {
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
}
|
||||
localStorage.setItem('app', encodeLegacy(legacyApp))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/app/')).toBe(wrapped(legacyApp))
|
||||
})
|
||||
|
||||
it('migrates a legacy flow draft and strips the view-state envelope', () => {
|
||||
const flow = { summary: 'f', value: { modules: [] }, path: 'u/me/myflow' }
|
||||
const legacyBundle = {
|
||||
flow,
|
||||
path: 'u/me/myflow',
|
||||
selectedId: 'settings',
|
||||
draft_triggers: [{ id: 't1' }],
|
||||
selected_trigger: null,
|
||||
loadedFromHistory: undefined
|
||||
}
|
||||
localStorage.setItem('flow-u/me/myflow', encodeLegacy(legacyBundle))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('flow-u/me/myflow')).toBeNull()
|
||||
// Only the inner Flow survives; the view-state envelope is dropped.
|
||||
expect(storedShape('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow))
|
||||
})
|
||||
|
||||
it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => {
|
||||
const legacy = {
|
||||
files: { 'index.tsx': 'export default () => null' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
}
|
||||
localStorage.setItem('rawapp-u/me/site', encodeLegacy(legacy))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('rawapp-u/me/site')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/raw_app/u/me/site')).toBe(
|
||||
wrapped({ ...legacy, summary: '' })
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves an existing new-format entry instead of overwriting it', () => {
|
||||
// Old and new both exist for the same item — the new one is presumed
|
||||
// fresher.
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
const existingNew = wrapped({ value: 'new' })
|
||||
localStorage.setItem('userdraft/w/main/app/u/me/dash', existingNew)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBe(existingNew)
|
||||
})
|
||||
|
||||
it('is idempotent — the second invocation is a no-op', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
migrateLegacyUserDrafts('main')
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).not.toBeNull()
|
||||
|
||||
// Drop the migrated entry to detect any re-migration attempt.
|
||||
localStorage.removeItem('userdraft/w/main/app/u/me/dash')
|
||||
// Drop the source too, so re-running couldn't even find a source.
|
||||
// (The sentinel alone should be enough; this just clarifies the intent.)
|
||||
migrateLegacyUserDrafts('main')
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
|
||||
})
|
||||
|
||||
it('skips entirely when no workspace is available', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
migrateLegacyUserDrafts('')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('handles malformed legacy payloads without throwing', () => {
|
||||
localStorage.setItem('app-u/me/garbled', 'not-base64!!!')
|
||||
expect(() => migrateLegacyUserDrafts('main')).not.toThrow()
|
||||
// Migration didn't migrate, didn't crash — leaves the entry alone.
|
||||
expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!')
|
||||
})
|
||||
|
||||
it('leaves keys whose path does not match the legacy `u|f/owner/name` shape alone', () => {
|
||||
// A future feature or neighbouring code might pick a key like
|
||||
// `app-recent` for its own purposes. The path doesn't look like a
|
||||
// Windmill item path, so the migration must skip it.
|
||||
localStorage.setItem('app-recent', 'whatever')
|
||||
localStorage.setItem('app-some_other_app', 'whatever')
|
||||
// `flow-u/me/foo` matches the shape and would be migrated, but the
|
||||
// payload also needs to look like a Windmill draft (asserted below).
|
||||
localStorage.setItem('flow-u/me/foo', encodeLegacy({ flow: { value: { modules: [] } } }))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-recent')).toBe('whatever')
|
||||
expect(localStorage.getItem('app-some_other_app')).toBe('whatever')
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/foo')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('skips legacy-shaped keys whose payload does not look like a Windmill draft', () => {
|
||||
// `app-u/me/dash` matches LEGACY_PATH_SHAPE and decodes to valid JSON,
|
||||
// but none of the App-shape fields (grid/fullscreen/theme/
|
||||
// unusedInlineScripts/hiddenInlineScripts) are present. Treat it as
|
||||
// unrelated and leave it untouched.
|
||||
const unrelated = encodeLegacy({ random: 'data', count: 7 })
|
||||
localStorage.setItem('app-u/me/dash', unrelated)
|
||||
const unrelatedFlow = encodeLegacy({ stepsState: {} })
|
||||
localStorage.setItem('flow-u/me/bar', unrelatedFlow)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).toBe(unrelated)
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
|
||||
expect(localStorage.getItem('flow-u/me/bar')).toBe(unrelatedFlow)
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('migrates multiple legacy entries in a single invocation', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/a',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
localStorage.setItem(
|
||||
'flow-u/me/b',
|
||||
encodeLegacy({ flow: { summary: '', value: { modules: [] }, path: 'u/me/b' } })
|
||||
)
|
||||
localStorage.setItem(
|
||||
'rawapp-u/me/c',
|
||||
encodeLegacy({ files: {}, runnables: {}, data: { tables: [] } })
|
||||
)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/a')).not.toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/b')).not.toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/c')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* One-off migration from the pre-UserDraft localStorage autosave entries to
|
||||
* the workspace-scoped `userdraft/w/{ws}/{kind}/{path}` format.
|
||||
*
|
||||
* Legacy keys (global, not workspace-scoped — assumed to belong to the user's
|
||||
* current workspace at migration time):
|
||||
*
|
||||
* `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })`
|
||||
* `app` / `app-{path}` base64 of `encodeState(App)`
|
||||
* `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })`
|
||||
*
|
||||
* Target keys: `userdraft/w/{workspace}/{flow|app|raw_app}/{path}` storing
|
||||
* `JSON.stringify({ value: <transformed legacy value> })`.
|
||||
*
|
||||
* Idempotent: writes a sentinel under `MIGRATION_FLAG` after the first run so
|
||||
* subsequent invocations are no-ops. Existing new-format entries are never
|
||||
* overwritten — when both an old and a new entry exist for the same item, the
|
||||
* old one is simply dropped on the assumption that the new entry is the more
|
||||
* recent edit.
|
||||
*
|
||||
* This file is intentionally standalone — it does not import from
|
||||
* `userDraft.svelte.ts` so the new code stays uncluttered by the legacy
|
||||
* decoders.
|
||||
*/
|
||||
|
||||
const MIGRATION_FLAG = 'userdraft/legacy_migrated_v1'
|
||||
|
||||
type LegacyKind = 'flow' | 'app' | 'raw_app'
|
||||
|
||||
const LEGACY_PREFIXES: ReadonlyArray<{ prefix: string; newKind: LegacyKind }> = [
|
||||
// `rawapp` is listed before `app` even though our matcher uses exact /
|
||||
// dash-separated comparison (so there's no ambiguity); it documents the
|
||||
// intent that raw apps are a distinct kind, not a sub-case of apps.
|
||||
{ prefix: 'rawapp', newKind: 'raw_app' },
|
||||
{ prefix: 'flow', newKind: 'flow' },
|
||||
{ prefix: 'app', newKind: 'app' }
|
||||
]
|
||||
|
||||
/**
|
||||
* A Windmill item path: `u/<owner>/<name…>` or `f/<folder>/<name…>`. The
|
||||
* `<name…>` segment may itself contain slashes, so we don't constrain it
|
||||
* past requiring at least one character. Used to reject incidentally-named
|
||||
* localStorage keys (e.g. `app-recent` from a future feature, or a
|
||||
* neighbouring app's data) before treating them as Windmill drafts.
|
||||
*/
|
||||
const LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/
|
||||
|
||||
function matchLegacyKey(
|
||||
key: string
|
||||
): { prefix: string; newKind: LegacyKind; path: string } | undefined {
|
||||
for (const { prefix, newKind } of LEGACY_PREFIXES) {
|
||||
if (key === prefix) return { prefix, newKind, path: '' }
|
||||
if (key.startsWith(prefix + '-')) {
|
||||
const path = key.slice(prefix.length + 1)
|
||||
if (!LEGACY_PATH_SHAPE.test(path)) return undefined
|
||||
return { prefix, newKind, path }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function decodeLegacyState(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(atob(raw)))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-kind shape gate. The legacy keys (`app-foo`, `flow-foo`, ...) are
|
||||
* unusual enough that nothing else in the codebase has used them, but
|
||||
* matching `LEGACY_PATH_SHAPE` doesn't prove the payload is actually a
|
||||
* Windmill draft (any base64-of-JSON could pass). Promoting a stray payload
|
||||
* would silently surface as a phantom "Restored from local storage" toast
|
||||
* on the next edit, so we reject anything that doesn't carry the fields the
|
||||
* legacy writers actually produced.
|
||||
*/
|
||||
function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean {
|
||||
if (decoded == null || typeof decoded !== 'object') return false
|
||||
const obj = decoded as Record<string, unknown>
|
||||
switch (kind) {
|
||||
case 'flow':
|
||||
// Legacy FlowBuilder wrote { flow, path, selectedId, draft_triggers, ... }.
|
||||
return obj.flow != null && typeof obj.flow === 'object'
|
||||
case 'app':
|
||||
// Legacy AppEditor wrote `encodeState($appStore)`, i.e. the inner App
|
||||
// value (see `frontend/src/lib/components/apps/types.ts`) — NOT the
|
||||
// wrapping AppWithLastVersion. It carries `grid`, `fullscreen`,
|
||||
// `theme`, `unusedInlineScripts`, `hiddenInlineScripts` among other
|
||||
// fields — any one of those is a strong signal it's actually a
|
||||
// Windmill app payload.
|
||||
return (
|
||||
'grid' in obj ||
|
||||
'fullscreen' in obj ||
|
||||
'theme' in obj ||
|
||||
'unusedInlineScripts' in obj ||
|
||||
'hiddenInlineScripts' in obj
|
||||
)
|
||||
case 'raw_app':
|
||||
// Legacy RawAppEditor wrote { files, runnables, data }.
|
||||
return 'files' in obj || 'runnables' in obj || 'data' in obj
|
||||
}
|
||||
}
|
||||
|
||||
function transformLegacyValue(kind: LegacyKind, decoded: unknown): unknown {
|
||||
const obj = decoded as Record<string, unknown>
|
||||
switch (kind) {
|
||||
case 'flow':
|
||||
// The legacy bundle wrapped the Flow alongside view-state fields
|
||||
// (selectedId, draft_triggers, ...). The new entry stores only the
|
||||
// Flow — the view-state lives elsewhere or is re-derived.
|
||||
return obj.flow
|
||||
case 'app':
|
||||
// Legacy stored the App directly.
|
||||
return obj
|
||||
case 'raw_app':
|
||||
// Legacy bundle missed the `summary` field that the new editor adds.
|
||||
return {
|
||||
files: obj.files ?? {},
|
||||
runnables: obj.runnables ?? {},
|
||||
data: obj.data ?? {},
|
||||
summary: typeof obj.summary === 'string' ? obj.summary : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function newKey(workspace: string, kind: LegacyKind, path: string): string {
|
||||
return `userdraft/w/${workspace}/${kind}/${path}`
|
||||
}
|
||||
|
||||
function listLocalStorageKeys(): string[] {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k != null) out.push(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the legacy → new-format migration. Idempotent: returns immediately if a
|
||||
* previous run completed (signalled by `MIGRATION_FLAG`).
|
||||
*
|
||||
* The migration is workspace-scoped because the legacy keys had no notion of
|
||||
* workspace — we treat the caller's current workspace as the owner of any
|
||||
* surviving legacy entries.
|
||||
*/
|
||||
export function migrateLegacyUserDrafts(workspace: string): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
if (!workspace) return
|
||||
if (localStorage.getItem(MIGRATION_FLAG) !== null) return
|
||||
|
||||
try {
|
||||
for (const key of listLocalStorageKeys()) {
|
||||
const match = matchLegacyKey(key)
|
||||
if (!match) continue
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) continue
|
||||
|
||||
try {
|
||||
const decoded = decodeLegacyState(raw)
|
||||
if (!isPlausibleLegacyValue(match.newKind, decoded)) continue
|
||||
const value = transformLegacyValue(match.newKind, decoded)
|
||||
const target = newKey(workspace, match.newKind, match.path)
|
||||
if (value !== undefined && localStorage.getItem(target) == null) {
|
||||
// `lastWrittenAt` makes the migrated entry visible to
|
||||
// `gcUserDrafts`. We stamp it as "now" so a freshly-migrated
|
||||
// autosave gets the full retention window — sweeping it
|
||||
// immediately on the first GC pass would lose work the
|
||||
// legacy migration just rescued.
|
||||
localStorage.setItem(target, JSON.stringify({ value, lastWrittenAt: Date.now() }))
|
||||
}
|
||||
localStorage.removeItem(key)
|
||||
} catch (e) {
|
||||
console.error('UserDraft legacy migration: failed to migrate', key, e)
|
||||
}
|
||||
}
|
||||
localStorage.setItem(MIGRATION_FLAG, new Date().toISOString())
|
||||
} catch (e) {
|
||||
console.error('UserDraft legacy migration: aborted', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the sentinel so the migration can re-run. */
|
||||
export function __resetUserDraftLegacyMigrationForTesting(): void {
|
||||
try {
|
||||
localStorage.removeItem(MIGRATION_FLAG)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* "Restored from local storage" toast, shown when an editor reopens on a
|
||||
* local autosave that differs from the backend. Owns only the wording and
|
||||
* which reset actions are offered; the reset side-effects live at each call
|
||||
* site (route-specific state).
|
||||
*/
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
export type RestoreFromLocalActions = {
|
||||
/** Drop the local autosave, apply the backend DB draft. Offered when `hasBackendDraft`. */
|
||||
onResetToSavedDraft?: () => void | Promise<void>
|
||||
/** Drop the local autosave, load the deployed version. Offered when `hasDeployed`. */
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Show the toast with up to two reset actions, gated by what the backend has. */
|
||||
export function notifyRestoredFromLocal(
|
||||
hasBackendDraft: boolean,
|
||||
hasDeployed: boolean,
|
||||
{ onResetToSavedDraft, onResetToDeployed }: RestoreFromLocalActions
|
||||
): void {
|
||||
const actions: Array<{ label: string; callback: () => void | Promise<void> }> = []
|
||||
if (hasBackendDraft && onResetToSavedDraft) {
|
||||
actions.push({ label: 'Reset to saved draft', callback: onResetToSavedDraft })
|
||||
}
|
||||
if (hasDeployed && onResetToDeployed) {
|
||||
actions.push({ label: 'Reset to deployed', callback: onResetToDeployed })
|
||||
}
|
||||
if (actions.length === 0) return
|
||||
sendUserToast('Restored from local storage', false, actions)
|
||||
}
|
||||
@@ -58,8 +58,6 @@
|
||||
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
|
||||
import { gcUserDrafts } from '$lib/userDraft.svelte'
|
||||
import { setContext, untrack } from 'svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { Menubar } from '$lib/components/meltComponents'
|
||||
@@ -424,19 +422,6 @@
|
||||
$effect(() => {
|
||||
$workspaceStore && untrack(() => onLoad())
|
||||
})
|
||||
$effect(() => {
|
||||
if ($workspaceStore) untrack(() => migrateLegacyUserDrafts($workspaceStore!))
|
||||
})
|
||||
// Sweep UserDraft entries that haven't been touched in 30 days. Runs
|
||||
// once on mount and on a 30-min timer so a single very long session
|
||||
// also clears out stale autosaves over time. Live entries stamp
|
||||
// `lastWrittenAt` on every persist, so the sweep only touches truly
|
||||
// dormant records.
|
||||
$effect(() => {
|
||||
gcUserDrafts()
|
||||
const interval = setInterval(() => gcUserDrafts(), 30 * 60 * 1000)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
$effect(() => {
|
||||
innerWidth && untrack(() => changeCollapsed())
|
||||
})
|
||||
|
||||
@@ -7,23 +7,18 @@
|
||||
DraftService
|
||||
} from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { cleanValueProperties, orderedJsonStringify, type Value } from '$lib/utils'
|
||||
import { replaceState } from '$app/navigation'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
let app = $state(
|
||||
undefined as (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined
|
||||
)
|
||||
let app = $state(undefined as (AppWithLastVersion & { value: any }) | undefined)
|
||||
let savedApp:
|
||||
| {
|
||||
value: App
|
||||
@@ -31,69 +26,15 @@
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
let redraw = $state(0)
|
||||
let path = page.params.path ?? ''
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: AppWithLastVersion & { draft_only?: boolean; value: any }; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
// Backend revs at the most recent `loadApp` — handed to AppEditor as
|
||||
// `initialRevs` so the very first local autosave persists with a meta
|
||||
// stamp. Without it the next reload's staleness check has nothing to
|
||||
// compare against and the first external deploy/draft slips through.
|
||||
let currentRevs = $state<UserDraftMeta | undefined>(undefined)
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
// `discard` (not `remove`) so the entry's in-memory state.val is
|
||||
// cleared synchronously. `redraw++` remounts AppEditor on the next
|
||||
// microtask, but Svelte may mount the new instance before the old
|
||||
// one's onDestroy releases its handle — the new instance would
|
||||
// then re-acquire the SAME entry whose state.val still has the
|
||||
// stale autosave, ignoring the just-emptied LS. Same reason every
|
||||
// "reset" path below uses discard.
|
||||
UserDraft.discard('app', path, undefined)
|
||||
currentRevs = pendingBaseline.revs
|
||||
app = pendingBaseline.baseline
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
redraw++
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
UserDraft.saveMeta('app', path, pendingBaseline.revs)
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
// `?nodraft=true` is the callers' way of saying "skip the local autosave
|
||||
// on this load." Wipe the UserDraft entry and strip the flag from the
|
||||
// URL synchronously, before any descendant reads it. A plain reload
|
||||
// (no nodraft) restores normally.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('app', path)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
/** Increments per `loadApp` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
* navigation races a reload) bail at the next checkpoint after their
|
||||
* captured token no longer matches. */
|
||||
let loadAppToken = 0
|
||||
async function loadApp(): Promise<void> {
|
||||
const tok = ++loadAppToken
|
||||
@@ -108,7 +49,6 @@
|
||||
value: app_w_draft_.value as App,
|
||||
path: app_w_draft_.path,
|
||||
policy: app_w_draft_.policy,
|
||||
draft_only: app_w_draft_.draft_only,
|
||||
draft:
|
||||
app_w_draft_.draft?.['summary'] !== undefined // backward compatibility for old drafts missing metadata
|
||||
? app_w_draft_.draft
|
||||
@@ -124,104 +64,14 @@
|
||||
custom_path: app_w_draft_.custom_path
|
||||
}
|
||||
|
||||
// Resolve the app value: backend draft > deployed, then overlay any
|
||||
// local autosave from UserDraft if present.
|
||||
const backendApp = app_w_draft.draft
|
||||
// The editor works off the backend DB draft when present, otherwise the
|
||||
// deployed version. AppEditor seeds its own in-memory draft handle from
|
||||
// this value; edits sync back to the DB.
|
||||
app = app_w_draft.draft
|
||||
? app_w_draft.summary !== undefined
|
||||
? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & {
|
||||
draft_only?: boolean
|
||||
value: any
|
||||
})
|
||||
: ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & {
|
||||
draft_only?: boolean
|
||||
value: any
|
||||
})
|
||||
? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & { value: any })
|
||||
: ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & { value: any })
|
||||
: app_w_draft
|
||||
|
||||
const localDraftValue = UserDraft.get<App>('app', path)
|
||||
const previousMeta = UserDraft.getMeta('app', path)
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: app_w_draft.versions
|
||||
? app_w_draft.versions[app_w_draft.versions.length - 1]
|
||||
: undefined,
|
||||
remoteDraftRev: app_w_draft.draft_created_at
|
||||
}
|
||||
currentRevs = newRevs
|
||||
if (
|
||||
localDraftValue != undefined &&
|
||||
orderedJsonStringify(cleanValueProperties(localDraftValue)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendApp.value))
|
||||
) {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendApp, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
UserDraft.saveMeta('app', path, newRevs)
|
||||
}
|
||||
const appPath = backendApp.path
|
||||
const hasBackendDraft = app_w_draft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.discard('app', path, undefined)
|
||||
currentRevs = newRevs
|
||||
app = backendApp
|
||||
redraw++
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${appPath}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
})
|
||||
}
|
||||
app = { ...backendApp, value: localDraftValue }
|
||||
} else {
|
||||
// Local is missing or matches backend — wipe any stale entry so it
|
||||
// doesn't haunt the next session and use the backend value.
|
||||
if (localDraftValue != undefined) UserDraft.remove('app', path)
|
||||
app = backendApp
|
||||
}
|
||||
|
||||
if (app_w_draft.draft && !app_w_draft.draft_only && localDraftValue == undefined) {
|
||||
const reloadAction = () => {
|
||||
app = app_w_draft
|
||||
redraw++
|
||||
}
|
||||
|
||||
const deployed = cleanValueProperties(app_w_draft as Value)
|
||||
const draft = cleanValueProperties(app ?? {})
|
||||
sendUserToast('app loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -245,7 +95,6 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${savedApp.draft.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -257,6 +106,7 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
// Explicit user action: delete the DB draft synchronously before reloading.
|
||||
if (savedApp.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -264,7 +114,7 @@
|
||||
path: savedApp.path
|
||||
})
|
||||
}
|
||||
UserDraft.discard('app', path, undefined)
|
||||
UserDraft.remove('app', path)
|
||||
goto(`/apps/edit/${savedApp.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -288,12 +138,6 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
{#key redraw}
|
||||
{#if app}
|
||||
@@ -315,7 +159,6 @@
|
||||
{diffDrawer}
|
||||
version={app.versions ? app.versions[app.versions.length - 1] : undefined}
|
||||
newApp={false}
|
||||
initialRevs={currentRevs}
|
||||
replaceStateFn={(path) => replaceState(path, page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
>
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
|
||||
import { AppService, DraftService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively,
|
||||
type Value
|
||||
} from '$lib/utils'
|
||||
import { readFieldsRecursively } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
@@ -18,14 +13,7 @@
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
UserDraft,
|
||||
checkStaleness,
|
||||
localDraftDiffers,
|
||||
type UserDraftMeta
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
|
||||
|
||||
type RawAppDraft = {
|
||||
files: Record<string, string>
|
||||
@@ -41,7 +29,6 @@
|
||||
/** Data configuration including tables and creation policy */
|
||||
let data: RawAppData = $state({ ...DEFAULT_DATA })
|
||||
let newPath = $state('')
|
||||
// let lastVersion = 0
|
||||
let policy: any = $state({})
|
||||
let summary = $state('')
|
||||
|
||||
@@ -55,57 +42,17 @@
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
let redraw = $state(0)
|
||||
let path = page.params.path ?? ''
|
||||
|
||||
// `?nodraft=true` is the callers' way of saying "skip the local autosave
|
||||
// on this load." Wipe the UserDraft entry and strip the flag from the
|
||||
// URL synchronously, before the handle is created. A plain reload (no
|
||||
// nodraft) restores normally.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('raw_app', path)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
const draftHandle = UserDraft.use<RawAppDraft>('raw_app', path)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: RawAppDraft; backendSource: any; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, backendSource, revs } = pendingBaseline
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(baseline, revs)
|
||||
extractRawApp(backendSource)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
redraw++
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
draftHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
// Persist the bundle whenever any of the four pieces of state changes.
|
||||
// Persist the bundle whenever any of the pieces of state changes. The
|
||||
// handle's setter syncs the change to the DB (debounced); writes equal to
|
||||
// the seeded baseline are skipped, so loading doesn't write back.
|
||||
$effect(() => {
|
||||
const currentFiles = files
|
||||
if (!currentFiles) return
|
||||
@@ -124,8 +71,8 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Reflect an external UserDraft.save into the form. Idempotent; the
|
||||
// `!files` guard skips the reload window so it doesn't fight loadApp.
|
||||
// Reflect an external UserDraft.save (e.g. from the AI copilot) into the
|
||||
// form. Idempotent; the `!files` guard skips the reload window.
|
||||
$effect(() => {
|
||||
const d = draftHandle.draft
|
||||
const currentFiles = files
|
||||
@@ -170,13 +117,11 @@
|
||||
}
|
||||
files = app.value.files
|
||||
summary = app.summary
|
||||
// lastVersion = app.version
|
||||
policy = app.policy
|
||||
newPath = app.path
|
||||
}
|
||||
|
||||
/** Increments per `loadApp` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
/** Increments per `loadApp` call. Stale loads bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
let loadAppToken = 0
|
||||
async function loadApp(): Promise<void> {
|
||||
@@ -192,20 +137,13 @@
|
||||
value: app_w_draft_.value as any,
|
||||
path: app_w_draft_.path,
|
||||
policy: app_w_draft_.policy,
|
||||
draft_only: app_w_draft_.draft_only,
|
||||
draft: app_w_draft_.draft,
|
||||
custom_path: app_w_draft_.custom_path
|
||||
}
|
||||
|
||||
// The editor works off the backend DB draft when present, otherwise the
|
||||
// deployed version.
|
||||
const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft
|
||||
const localDraft = draftHandle.draft
|
||||
const previousMeta = draftHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: app_w_draft.versions
|
||||
? app_w_draft.versions[app_w_draft.versions.length - 1]
|
||||
: undefined,
|
||||
remoteDraftRev: app_w_draft.draft_created_at
|
||||
}
|
||||
const backendBundle: RawAppDraft = {
|
||||
files: backendSource.value?.files ?? {},
|
||||
runnables: backendSource.value?.runnables ?? {},
|
||||
@@ -219,88 +157,10 @@
|
||||
custom_path: backendSource.custom_path ?? app_w_draft.custom_path
|
||||
}
|
||||
|
||||
if (
|
||||
localDraft != undefined &&
|
||||
orderedJsonStringify(cleanValueProperties(localDraft)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendBundle))
|
||||
) {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendBundle, backendSource, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
draftHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const appPath = app_w_draft.path
|
||||
const hasBackendDraft = app_w_draft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
extractRawApp(backendSource)
|
||||
redraw++
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('raw_app', path)
|
||||
// UserDraft.remove only clears localStorage. Drop the
|
||||
// entry's in-memory state too so loadApp doesn't re-read
|
||||
// the stale autosave and re-fire the same toast.
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
})
|
||||
}
|
||||
runnables = localDraft.runnables
|
||||
data = localDraft.data
|
||||
summary = localDraft.summary
|
||||
policy = localDraft.policy ?? app_w_draft.policy
|
||||
newPath = app_w_draft.path
|
||||
files = localDraft.files
|
||||
} else {
|
||||
if (localDraft != undefined) UserDraft.remove('raw_app', path)
|
||||
extractRawApp(backendSource)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
|
||||
if (app_w_draft.draft && !app_w_draft.draft_only) {
|
||||
const reloadAction = () => {
|
||||
extractRawApp(app_w_draft)
|
||||
redraw++
|
||||
}
|
||||
|
||||
const deployed = cleanValueProperties(app_w_draft as Value)
|
||||
const draft = cleanValueProperties({ files, runnables })
|
||||
sendUserToast('app loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
// Seed the handle (no DB write-back) before populating the form so the
|
||||
// persist effect's first write matches and is skipped.
|
||||
draftHandle.setInitial(backendBundle)
|
||||
extractRawApp(backendSource)
|
||||
}
|
||||
|
||||
run(() => {
|
||||
@@ -322,12 +182,6 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('raw_app', path)
|
||||
// Drop the in-memory handle state so loadApp sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare
|
||||
// the stale in-memory meta against the freshly fetched backend and
|
||||
// fire a spurious modal.
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/apps/edit/${savedApp.draft.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -339,6 +193,7 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
// Explicit user action: delete the DB draft synchronously before reloading.
|
||||
if (savedApp.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -347,7 +202,6 @@
|
||||
})
|
||||
}
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/apps/edit/${savedApp.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -371,12 +225,6 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
{#if files}
|
||||
{#key redraw}
|
||||
|
||||
@@ -4,39 +4,24 @@
|
||||
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
|
||||
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
|
||||
import { initialArgsStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
decodeState,
|
||||
emptySchema,
|
||||
orderedJsonStringify,
|
||||
type StateStore
|
||||
} from '$lib/utils'
|
||||
import { decodeState, emptySchema, type StateStore } from '$lib/utils'
|
||||
import { initFlow } from '$lib/components/flows/flowStore.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import type { ScheduleTrigger } from '$lib/components/triggers'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
|
||||
import { page } from '$app/state'
|
||||
import {
|
||||
UserDraft,
|
||||
checkStaleness,
|
||||
type UserDraftMeta,
|
||||
type UserDraftHandle
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
|
||||
let version: undefined | number = $state(undefined)
|
||||
|
||||
// `initialArgs` is captured once at mount — it's the session's initial
|
||||
// argument set. The flow draft itself lives in UserDraft and is re-read
|
||||
// per loadFlow() so picker navigation doesn't reuse the original path's
|
||||
// state.
|
||||
// argument set.
|
||||
const urlArgs = page.url.searchParams.get('initial_args')
|
||||
|
||||
let initialArgs = $state({})
|
||||
@@ -56,21 +41,10 @@
|
||||
// Derived so client-side nav (breadcrumb) re-keys the handle to the new path.
|
||||
let flowDraftPath = $derived(page.params.path ?? '')
|
||||
|
||||
// `?nodraft=true` is the callers' way of saying "skip the local autosave
|
||||
// on this load." Wipe the UserDraft entry and strip the flag from the
|
||||
// URL synchronously, before the handle is created — same pattern as
|
||||
// /flows/add. A plain reload (no nodraft) restores normally.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
// `useMany` keyed off the reactive `flowDraftPath` re-keys the handle on nav;
|
||||
// `flowHandle` proxies the current handle so `flowStore` keeps a fixed ref.
|
||||
const flowHandles = UserDraft.useMany<Flow>(() => [{ itemKind: 'flow', path: flowDraftPath }])
|
||||
const flowHandle: UserDraftHandle<Flow> = {
|
||||
const flowHandle: Pick<UserDraftHandle<Flow>, 'draft' | 'setInitial'> = {
|
||||
get draft() {
|
||||
return flowHandles[0]?.draft
|
||||
},
|
||||
@@ -78,14 +52,8 @@
|
||||
const handle = flowHandles[0]
|
||||
if (handle) handle.draft = value
|
||||
},
|
||||
get meta() {
|
||||
return flowHandles[0]?.meta ?? {}
|
||||
},
|
||||
setDraftAndMeta(value, meta) {
|
||||
flowHandles[0]?.setDraftAndMeta(value, meta)
|
||||
},
|
||||
setMeta(meta, opts) {
|
||||
flowHandles[0]?.setMeta(meta, opts)
|
||||
setInitial(value) {
|
||||
flowHandles[0]?.setInitial(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,12 +70,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// While `seeding` is true, writes through `flowStore` seed the in-memory
|
||||
// draft WITHOUT syncing to the DB — the value was just loaded from the
|
||||
// backend, so re-binding it must not write it straight back. Real user edits
|
||||
// (after load) flip `seeding` off and go through the syncing setter.
|
||||
let seeding = false
|
||||
export const flowStore: StateStore<Flow> = {
|
||||
get val() {
|
||||
return flowHandle.draft ?? emptyFlow()
|
||||
},
|
||||
set val(v: Flow) {
|
||||
flowHandle.draft = v
|
||||
if (seeding) {
|
||||
flowHandle.setInitial(v)
|
||||
} else {
|
||||
flowHandle.draft = v
|
||||
}
|
||||
}
|
||||
}
|
||||
const flowStateStore = $state({ val: {} })
|
||||
@@ -120,37 +97,8 @@
|
||||
|
||||
let selectedId: string = $state('settings-metadata')
|
||||
|
||||
let nobackenddraft = false
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline: { baseline: Flow; revs: UserDraftMeta } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, revs } = pendingBaseline
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(baseline, revs)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
flowHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
|
||||
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
|
||||
let loadedFromHistoryFromUrl:
|
||||
@@ -160,21 +108,16 @@
|
||||
let flowBuilder: FlowBuilder | undefined = $state(undefined)
|
||||
let notFound = $state(false)
|
||||
/** Increments per `loadFlow` call. Each in-flight load checks its captured
|
||||
* token against this before writing shared state — if a newer load started
|
||||
* (e.g. picker navigation while a draft-discard reload is in flight),
|
||||
* token against this before writing shared state — if a newer load started,
|
||||
* the older promise no-ops at the next checkpoint. */
|
||||
let loadFlowToken = 0
|
||||
async function loadFlow(): Promise<void> {
|
||||
const tok = ++loadFlowToken
|
||||
loading = true
|
||||
let flow: Flow
|
||||
// Builder-dependent setup is captured here and applied AFTER the builder
|
||||
// remounts (see end of loadFlow): during a reload renderEditor is false,
|
||||
// so flowBuilder is unmounted and direct calls would no-op.
|
||||
seeding = true
|
||||
let draftTriggersToApply: Trigger[] | undefined = undefined
|
||||
let applyPrimarySchedule = false
|
||||
// Currently there is no way to get version of flow with flow.
|
||||
// So we have to request it here
|
||||
const v = (
|
||||
await FlowService.getFlowLatestVersion({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -203,115 +146,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
const backendFlow =
|
||||
flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft
|
||||
const localDraft = flowHandle.draft
|
||||
const previousMeta = flowHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: v,
|
||||
remoteDraftRev: flowWithDraft.draft_created_at
|
||||
}
|
||||
// The editor works off the backend DB draft when present, otherwise the
|
||||
// deployed version. Seeding (not assigning) avoids writing the
|
||||
// freshly-loaded value straight back to the DB.
|
||||
const flow = flowWithDraft.draft != undefined ? flowWithDraft.draft : flowWithDraft
|
||||
flowHandle.setInitial(flow)
|
||||
|
||||
if (localDraft != undefined) {
|
||||
const localClean = cleanValueProperties(localDraft)
|
||||
const backendClean = cleanValueProperties(backendFlow)
|
||||
if (orderedJsonStringify(localClean) === orderedJsonStringify(backendClean)) {
|
||||
// Local matches backend exactly — silently drop the autosave.
|
||||
flow = backendFlow
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
} else {
|
||||
flow = localDraft
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendFlow, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
flowHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const flowPath = backendFlow.path
|
||||
const hasBackendDraft = flowWithDraft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !flowWithDraft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
loadFlow()
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: flowPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// UserDraft.remove only clears localStorage. Drop the
|
||||
// entry's in-memory state too so loadFlow doesn't re-read
|
||||
// the stale autosave and re-fire the same toast.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
nobackenddraft = true
|
||||
loadFlow()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flow = backendFlow
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
}
|
||||
|
||||
if (flowWithDraft.draft != undefined && !nobackenddraft) {
|
||||
if (flowWithDraft.draft != undefined) {
|
||||
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
|
||||
applyPrimarySchedule = true
|
||||
draftTriggersToApply = flowWithDraft?.draft?.['draft_triggers']
|
||||
|
||||
if (!flowWithDraft.draft_only && localDraft == undefined) {
|
||||
const deployed = cleanValueProperties(flowWithDraft)
|
||||
const draft = cleanValueProperties(flow)
|
||||
const reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: flow.path
|
||||
})
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// UserDraft.remove only clears localStorage. The
|
||||
// flowHandle's in-memory state still holds the now-
|
||||
// deleted DB draft + its meta — loadFlow would treat it
|
||||
// as a local autosave and the staleness check would fire
|
||||
// a spurious "newer version was deployed" modal because
|
||||
// remoteDraftRev moved from "defined" to "undefined".
|
||||
// Drop the in-memory state first.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
nobackenddraft = true
|
||||
loadFlow()
|
||||
}
|
||||
sendUserToast('flow loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
draftTriggersToApply = undefined
|
||||
}
|
||||
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
@@ -327,6 +171,8 @@
|
||||
if (applyPrimarySchedule) flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
flowBuilder?.setDraftTriggers(draftTriggersToApply)
|
||||
flowBuilder?.loadFlowState()
|
||||
// Loading is done — subsequent edits should sync to the DB.
|
||||
seeding = false
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -335,14 +181,12 @@
|
||||
page.params.path
|
||||
if ($workspaceStore) {
|
||||
untrack(() => {
|
||||
nobackenddraft = false // fresh nav reconsiders the backend draft
|
||||
renderEditor = false // remount the builder for the navigated-to flow
|
||||
loadFlow().catch((e: any) => {
|
||||
// A failed load must NOT leave renderEditor stuck false — otherwise
|
||||
// the editor pane disappears and never remounts. Surface the error
|
||||
// and remount so the user isn't stranded on a blank pane.
|
||||
// A failed load must NOT leave renderEditor stuck false.
|
||||
console.error('Failed to load flow', e)
|
||||
sendUserToast(`Failed to load flow: ${e?.body ?? e?.message ?? e}`, true)
|
||||
seeding = false
|
||||
renderEditor = true
|
||||
})
|
||||
})
|
||||
@@ -357,12 +201,7 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// Drop the in-memory handle state so loadFlow sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare
|
||||
// the stale in-memory meta against the freshly fetched backend and
|
||||
// fire a spurious modal.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
// Re-seed from the backend draft (drops the in-memory edits).
|
||||
goto(`/flows/edit/${savedFlow.draft.path}`)
|
||||
loadFlow()
|
||||
}
|
||||
@@ -373,6 +212,8 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
// Explicit user action: delete the DB draft synchronously (don't rely on
|
||||
// the debounced autosync) before reloading the deployed version.
|
||||
if (savedFlow.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -381,21 +222,12 @@
|
||||
})
|
||||
}
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/flows/edit/${savedFlow.path}`)
|
||||
loadFlow()
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
{#if notFound}
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<h1 class="text-2xl font-bold">Flow not found at path {page.params.path}</h1>
|
||||
|
||||
@@ -6,18 +6,8 @@
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
import { editPathFor } from '$lib/components/workspacePicker'
|
||||
import type { Schema } from '$lib/common'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
decodeState,
|
||||
emptySchema,
|
||||
emptyString,
|
||||
encodeState,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively,
|
||||
sendUserToast
|
||||
} from '$lib/utils'
|
||||
import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
@@ -124,62 +114,13 @@
|
||||
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: '' })
|
||||
})
|
||||
|
||||
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
|
||||
// Legacy behavior: the URL hash both seeds the editor on load AND stays in
|
||||
// sync with edits (encoded back into the hash, debounced). Asks the user
|
||||
// via modal when the URL payload would clobber an existing local autosave.
|
||||
let urlConflictModalOpen = $state(false)
|
||||
let pendingUrlPayload: Script | undefined = undefined
|
||||
|
||||
// A base64-JSON script payload in the URL hash (e.g. a Fork link) is an
|
||||
// explicit "open this script" intent: seed the editor with it.
|
||||
if (urlScript) {
|
||||
const seeded = { ...defaultScript(), ...urlScript } as Script
|
||||
const existing = scriptHandle.draft
|
||||
if (existing) {
|
||||
const localClean = orderedJsonStringify(cleanValueProperties(existing))
|
||||
const seededClean = orderedJsonStringify(cleanValueProperties(seeded))
|
||||
if (localClean !== seededClean) {
|
||||
pendingUrlPayload = seeded
|
||||
urlConflictModalOpen = true
|
||||
}
|
||||
} else {
|
||||
scriptHandle.draft = seeded
|
||||
sendUserToast('Loaded from URL')
|
||||
}
|
||||
scriptHandle.draft = { ...defaultScript(), ...urlScript } as Script
|
||||
sendUserToast('Loaded from URL')
|
||||
}
|
||||
|
||||
function onUrlConflictUseUrl() {
|
||||
if (pendingUrlPayload) {
|
||||
scriptHandle.draft = pendingUrlPayload
|
||||
sendUserToast('Loaded from URL')
|
||||
}
|
||||
pendingUrlPayload = undefined
|
||||
urlConflictModalOpen = false
|
||||
}
|
||||
function onUrlConflictKeepLocal() {
|
||||
pendingUrlPayload = undefined
|
||||
urlConflictModalOpen = false
|
||||
}
|
||||
|
||||
let _urlHashSyncTimeout: number | undefined
|
||||
$effect(() => {
|
||||
const draft = scriptHandle.draft
|
||||
if (!draft) return
|
||||
// Gate while the conflict modal is open so we don't overwrite the URL
|
||||
// payload before the user has decided.
|
||||
if (urlConflictModalOpen) return
|
||||
readFieldsRecursively(draft)
|
||||
if (typeof window === 'undefined') return
|
||||
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
|
||||
_urlHashSyncTimeout = setTimeout(() => {
|
||||
const snapshot = $state.snapshot(scriptHandle.draft)
|
||||
if (!snapshot) return
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = encodeState(snapshot)
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}, 500)
|
||||
})
|
||||
// === END TEMP URL-HASH SYNC ===
|
||||
|
||||
async function loadTemplate(): Promise<void> {
|
||||
if (urlScript) return
|
||||
if (templatePath) {
|
||||
@@ -257,14 +198,6 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- TEMP URL-HASH SYNC: conflict modal (remove with future PR) -->
|
||||
<LocalDraftStaleModal
|
||||
open={urlConflictModalOpen}
|
||||
cause="url"
|
||||
onLoadLatest={onUrlConflictUseUrl}
|
||||
onKeepDraft={onUrlConflictKeepLocal}
|
||||
/>
|
||||
|
||||
{#if scriptHandle.draft}
|
||||
<ScriptBuilder
|
||||
{initialArgs}
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { ScriptService, type NewScript, type NewScriptWithDraft, DraftService } from '$lib/gen'
|
||||
import { ScriptService, type NewScript, type NewScriptWithDraft } from '$lib/gen'
|
||||
|
||||
import { initialArgsStore, workspaceStore } from '$lib/stores'
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
encodeState,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively
|
||||
} from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import type { ScheduleTrigger } from '$lib/components/triggers'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { get } from 'svelte/store'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import {
|
||||
UserDraft,
|
||||
checkStaleness,
|
||||
type UserDraftMeta,
|
||||
type UserDraftHandle
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
|
||||
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
|
||||
|
||||
@@ -40,8 +27,8 @@
|
||||
|
||||
let hash = $derived(page.url.searchParams.get('hash') ?? undefined)
|
||||
|
||||
// When viewing a specific historical hash we don't want to load or write a
|
||||
// local draft — that view is read-only relative to drafts.
|
||||
// When viewing a specific historical hash we don't want a draft — that view
|
||||
// is read-only relative to drafts.
|
||||
let draftPath = $derived(hash ? '' : (page.params.path ?? ''))
|
||||
|
||||
// `useMany` keyed off the reactive `draftPath` re-keys the handle on nav;
|
||||
@@ -49,7 +36,7 @@
|
||||
const scriptHandles = UserDraft.useMany<EditableScript>(() => [
|
||||
{ itemKind: 'script', path: draftPath }
|
||||
])
|
||||
const scriptHandle: UserDraftHandle<EditableScript> = {
|
||||
const scriptHandle: Pick<UserDraftHandle<EditableScript>, 'draft' | 'setInitial'> = {
|
||||
get draft() {
|
||||
return scriptHandles[0]?.draft
|
||||
},
|
||||
@@ -57,14 +44,8 @@
|
||||
const handle = scriptHandles[0]
|
||||
if (handle) handle.draft = value
|
||||
},
|
||||
get meta() {
|
||||
return scriptHandles[0]?.meta ?? {}
|
||||
},
|
||||
setDraftAndMeta(value, meta) {
|
||||
scriptHandles[0]?.setDraftAndMeta(value, meta)
|
||||
},
|
||||
setMeta(meta, opts) {
|
||||
scriptHandles[0]?.setMeta(meta, opts)
|
||||
setInitial(value) {
|
||||
scriptHandles[0]?.setInitial(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,14 +62,8 @@
|
||||
})
|
||||
|
||||
/** Some pages base64-JSON-encode a NewScript-like payload into the URL
|
||||
* hash on `/scripts/edit/<path>#…`. Treat it as a one-shot seed that
|
||||
* wins over local autosave + backend draft + deployed: apply, toast,
|
||||
* strip from the URL. Same logic as /scripts/add, kept in this file for
|
||||
* a faithful mirror of its decoder.
|
||||
*
|
||||
* Can't reuse `decodeState` from utils.ts — it fires its own error toast
|
||||
* on parse failure, which would noise up the UI for unrelated anchors.
|
||||
*/
|
||||
* hash on `/scripts/edit/<path>#…` (e.g. Fork links). Treat it as a one-shot
|
||||
* seed: apply it over the loaded baseline, then strip it from the URL. */
|
||||
function decodeUrlScriptSeed(): Partial<EditableScript> | undefined {
|
||||
const fragment = page.url.hash.startsWith('#') ? page.url.hash.slice(1) : ''
|
||||
if (!fragment) return undefined
|
||||
@@ -102,11 +77,7 @@
|
||||
}
|
||||
let urlScriptSeed = decodeUrlScriptSeed()
|
||||
|
||||
// Seed from the URL so ScriptBuilder mounts with a populated `initialPath`
|
||||
// even when `scriptHandle.draft` is already defined synchronously from a
|
||||
// local autosave. An empty initialPath flips ScriptBuilder's
|
||||
// `metadataOpen` heuristic (intended for /scripts/add) into "true" and
|
||||
// pops the settings drawer open on /edit.
|
||||
// Seed from the URL so ScriptBuilder mounts with a populated `initialPath`.
|
||||
let initialPath: string = $state(hash ? '' : (page.params.path ?? ''))
|
||||
|
||||
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
|
||||
@@ -121,24 +92,6 @@
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote (deployed or DB
|
||||
// draft) has moved on since the user's autosave was created.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline: { baseline: EditableScript; revs: UserDraftMeta } | undefined = undefined
|
||||
|
||||
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
|
||||
// Legacy behavior: URL hash both seeds the editor and stays in sync with
|
||||
// edits. Asks the user via modal when the URL value would clobber an
|
||||
// existing local autosave that differs from it.
|
||||
let urlConflictModalOpen = $state(false)
|
||||
let urlConflictPending: { seed: EditableScript; revs: UserDraftMeta } | undefined = undefined
|
||||
// Gates the URL-sync effect until the initial URL-seed has been resolved
|
||||
// (silent apply OR modal closed) so it doesn't overwrite the URL payload
|
||||
// before the user has decided.
|
||||
let initialUrlSeedResolved = $state(!urlScriptSeed)
|
||||
// === END TEMP URL-HASH SYNC ===
|
||||
|
||||
function applyBaseline(baseline: EditableScript): void {
|
||||
initialPath = baseline.path
|
||||
scriptBuilder?.setDraftTriggers(baseline.draft_triggers)
|
||||
@@ -149,47 +102,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, revs } = pendingBaseline
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(baseline, revs)
|
||||
applyBaseline(baseline)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
scriptHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
|
||||
function onUrlConflictUseUrl(): void {
|
||||
if (urlConflictPending) {
|
||||
const { seed, revs } = urlConflictPending
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(seed, revs)
|
||||
applyBaseline(seed)
|
||||
sendUserToast('Loaded from URL')
|
||||
}
|
||||
urlConflictPending = undefined
|
||||
urlConflictModalOpen = false
|
||||
initialUrlSeedResolved = true
|
||||
}
|
||||
function onUrlConflictKeepLocal(): void {
|
||||
urlConflictPending = undefined
|
||||
urlConflictModalOpen = false
|
||||
initialUrlSeedResolved = true
|
||||
}
|
||||
// === END TEMP URL-HASH SYNC ===
|
||||
|
||||
/** Increments per `loadScript` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
@@ -204,7 +116,7 @@
|
||||
})
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
|
||||
scriptHandle.draft = { ...scriptByHash, parent_hash: hash, lock: undefined }
|
||||
scriptHandle.setInitial({ ...scriptByHash, parent_hash: hash, lock: undefined })
|
||||
} else {
|
||||
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -213,161 +125,26 @@
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptWithDraft))
|
||||
|
||||
const localDraft = scriptHandle.draft
|
||||
const previousMeta = scriptHandle.meta
|
||||
// The editor works off the backend DB draft when present, otherwise
|
||||
// the deployed version. Seeding (not assigning) avoids writing this
|
||||
// freshly-loaded value straight back to the DB.
|
||||
const backendDraft = scriptWithDraft.draft
|
||||
? ({ ...scriptWithDraft.draft } as EditableScript)
|
||||
: undefined
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: scriptWithDraft.hash,
|
||||
remoteDraftRev: scriptWithDraft.draft_created_at
|
||||
}
|
||||
|
||||
// Compute the fully-baked initial value once so the assignment
|
||||
// below is a single write — otherwise post-load mutations like
|
||||
// `parent_hash = ...` would count as a second write under
|
||||
// useLocalStorageValue's saveInitialValue=false contract and get
|
||||
// persisted before the user has touched anything.
|
||||
const baseline = (backendDraft ?? (scriptWithDraft as EditableScript)) as EditableScript
|
||||
const bakedBaseline: EditableScript = {
|
||||
let bakedBaseline: EditableScript = {
|
||||
...baseline,
|
||||
parent_hash: topHash ?? scriptWithDraft.hash
|
||||
}
|
||||
|
||||
if (urlScriptSeed) {
|
||||
// === TEMP URL-HASH SYNC branch (remove with future PR) ===
|
||||
// URL hash seed competes with the local autosave on load.
|
||||
// When they differ, defer to a user-facing modal instead of
|
||||
// silently overwriting.
|
||||
const seeded = { ...bakedBaseline, ...urlScriptSeed } as EditableScript
|
||||
if (localDraft != undefined) {
|
||||
const localClean = orderedJsonStringify(cleanValueProperties(localDraft))
|
||||
const seededClean = orderedJsonStringify(cleanValueProperties(seeded))
|
||||
if (localClean === seededClean) {
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(seeded, newRevs)
|
||||
initialUrlSeedResolved = true
|
||||
} else {
|
||||
urlConflictPending = { seed: seeded, revs: newRevs }
|
||||
urlConflictModalOpen = true
|
||||
}
|
||||
} else {
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(seeded, newRevs)
|
||||
sendUserToast('Loaded from URL')
|
||||
initialUrlSeedResolved = true
|
||||
}
|
||||
bakedBaseline = { ...bakedBaseline, ...urlScriptSeed } as EditableScript
|
||||
urlScriptSeed = undefined
|
||||
// === END TEMP URL-HASH SYNC branch ===
|
||||
} else if (localDraft != undefined) {
|
||||
const reference = backendDraft ?? scriptWithDraft
|
||||
const referenceClean = cleanValueProperties(reference)
|
||||
const localClean = cleanValueProperties(localDraft)
|
||||
if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) {
|
||||
// Local matches the saved version — silently drop it and use the saved one.
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
} else {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
// Remote moved on since the local autosave was written —
|
||||
// surface the choice via modal. The local draft stays on
|
||||
// screen until the user picks.
|
||||
pendingBaseline = { baseline: bakedBaseline, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry (no meta recorded) — backfill so future
|
||||
// loads can detect staleness even if the user doesn't edit.
|
||||
scriptHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const scriptPath = bakedBaseline.path
|
||||
const hasBackendDraft = !!backendDraft
|
||||
notifyRestoredFromLocal(hasBackendDraft, !scriptWithDraft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
applyBaseline(bakedBaseline)
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: scriptPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('script', draftPath)
|
||||
// UserDraft.remove only clears localStorage. The entry's
|
||||
// in-memory state is kept alive by this route's handle, so
|
||||
// loadScript would re-read the stale autosave and the toast
|
||||
// would fire again. Drop the in-memory state first.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${scriptPath}`)
|
||||
loadScript()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (backendDraft) {
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
if (bakedBaseline['primary_schedule']) {
|
||||
savedPrimarySchedule = bakedBaseline['primary_schedule']
|
||||
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
}
|
||||
scriptBuilder?.setDraftTriggers(bakedBaseline.draft_triggers)
|
||||
|
||||
if (!scriptWithDraft.draft_only) {
|
||||
const reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: bakedBaseline.path
|
||||
})
|
||||
UserDraft.remove('script', draftPath)
|
||||
// UserDraft.remove only clears localStorage. The
|
||||
// scriptHandle's in-memory state still holds the now-
|
||||
// deleted DB draft + its meta — loadScript would treat
|
||||
// it as a local autosave and the staleness check
|
||||
// would fire a spurious "newer version was deployed"
|
||||
// modal because remoteDraftRev moved from "defined"
|
||||
// to "undefined". Drop the in-memory state first.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${bakedBaseline.path}`)
|
||||
loadScript()
|
||||
}
|
||||
const deployed = cleanValueProperties(scriptWithDraft)
|
||||
const draft = cleanValueProperties(bakedBaseline)
|
||||
sendUserToast('Script loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
}
|
||||
scriptHandle.setInitial(bakedBaseline)
|
||||
}
|
||||
|
||||
if (scriptHandle.draft) {
|
||||
initialPath = scriptHandle.draft.path
|
||||
scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers)
|
||||
scriptBuilder?.setCode(scriptHandle.draft.content)
|
||||
applyBaseline(scriptHandle.draft)
|
||||
}
|
||||
fullyLoaded = true
|
||||
renderEditor = true
|
||||
@@ -382,8 +159,7 @@
|
||||
renderEditor = false // remount the builder for the navigated-to script
|
||||
loadScript().catch((e: any) => {
|
||||
// A failed load must NOT leave renderEditor stuck false — otherwise
|
||||
// the editor pane disappears and never remounts. Surface the error
|
||||
// and remount so the user isn't stranded on a blank pane.
|
||||
// the editor pane disappears and never remounts.
|
||||
console.error('Failed to load script', e)
|
||||
sendUserToast(`Failed to load script: ${e?.body ?? e?.message ?? e}`, true)
|
||||
renderEditor = true
|
||||
@@ -392,31 +168,6 @@
|
||||
}
|
||||
})
|
||||
|
||||
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
|
||||
// Mirror the current draft to the URL hash on every edit (debounced).
|
||||
let _urlHashSyncTimeout: number | undefined
|
||||
$effect(() => {
|
||||
const draft = scriptHandle.draft
|
||||
if (!draft) return
|
||||
// Wait until the initial URL-seed has been resolved (silent apply or
|
||||
// modal closed) so we don't clobber the URL payload prematurely.
|
||||
if (!initialUrlSeedResolved) {
|
||||
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
|
||||
return
|
||||
}
|
||||
readFieldsRecursively(draft)
|
||||
if (typeof window === 'undefined') return
|
||||
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
|
||||
_urlHashSyncTimeout = setTimeout(() => {
|
||||
const snapshot = $state.snapshot(scriptHandle.draft)
|
||||
if (!snapshot) return
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = encodeState(snapshot)
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}, 500)
|
||||
})
|
||||
// === END TEMP URL-HASH SYNC ===
|
||||
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
|
||||
async function restoreDraft() {
|
||||
@@ -425,12 +176,7 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('script', draftPath)
|
||||
// Drop the in-memory handle state so loadScript sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare the
|
||||
// stale in-memory meta against the freshly fetched backend and fire
|
||||
// a spurious modal.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
// Re-seed from the backend draft (drops the in-memory edits).
|
||||
goto(`/scripts/edit/${savedScript.draft.path}`)
|
||||
loadScript()
|
||||
}
|
||||
@@ -441,34 +187,14 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
if (savedScript.draft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: savedScript.path
|
||||
})
|
||||
}
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
// Delete the DB draft and reload the deployed version.
|
||||
UserDraft.discard('script', draftPath, undefined)
|
||||
goto(`/scripts/edit/${savedScript.path}`)
|
||||
loadScript()
|
||||
}
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
<!-- TEMP URL-HASH SYNC: conflict modal (remove with future PR) -->
|
||||
<LocalDraftStaleModal
|
||||
open={urlConflictModalOpen}
|
||||
cause="url"
|
||||
onLoadLatest={onUrlConflictUseUrl}
|
||||
onKeepDraft={onUrlConflictKeepLocal}
|
||||
/>
|
||||
{#if scriptHandle.draft && renderEditor}
|
||||
<ScriptBuilder
|
||||
bind:this={scriptBuilder}
|
||||
|
||||
Reference in New Issue
Block a user