feat(frontend): wire raw app editor to UserDraft

/apps_raw/edit owns the canonical raw-app state (files, runnables,
data, summary) in four $state vars; a single $effect deep-tracks them
and forwards the bundle to a UserDraft.use<RawAppDraft> handle so each
mutation tick persists at userdraft/w/{ws}/raw_app/{path} (deduped by
useLocalStorageValue's serialized check). On load the route overlays
the local autosave on top of backend.draft/deployed and offers a
"Discard / Show diff" toast when they diverge; matching local entries
are silently dropped. Deploy, save-as-draft rename, restore-draft and
restore-deployed each call UserDraft.remove on the route path.

/apps_raw/add keeps the same shape (UserDraft.use with empty path)
so the draft is in-memory only and we drop it explicitly when the
initial save creates the real path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-05-11 23:37:54 +02:00
co-authored by Claude Opus 4.7
parent 1e77e46624
commit b7ca257239
3 changed files with 110 additions and 6 deletions
@@ -4,6 +4,7 @@
import UndoRedo from '$lib/components/common/button/UndoRedo.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { rawAppToHubUrl } from '$lib/hub'
import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
import YAML from 'yaml'
@@ -233,6 +234,7 @@
}
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('raw_app', path)
dispatch('savedNewAppPath', path)
} catch (e) {
sendUserToast('Error creating app', e)
@@ -342,6 +344,7 @@
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('raw_app', appPath)
if (appPath !== npath) {
dispatch('savedNewAppPath', npath)
}
@@ -420,6 +423,9 @@
}
draftDrawerOpen = false
// /apps_raw/add was in-memory (empty path); drop that entry so it
// doesn't shadow a future fresh /add visit before we navigate.
UserDraft.remove('raw_app', appPath)
dispatch('savedNewAppPath', newEditedPath)
} catch (e) {
sendUserToast('Error saving initial draft', e)
@@ -520,6 +526,7 @@
}
sendUserToast('Draft saved')
UserDraft.remove('raw_app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
dispatch('savedNewAppPath', newEditedPath || path)
@@ -11,6 +11,8 @@
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import FileEditorIcon from '$lib/components/raw_apps/FileEditorIcon.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { readFieldsRecursively } from '$lib/utils'
import { react18Template, react19Template, svelte5Template } from './templates'
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
@@ -97,6 +99,23 @@
})
/** Data configuration including tables and creation policy */
let data: RawAppData = $state({ ...DEFAULT_DATA })
// Empty path → in-memory only; this just keeps the API uniform with
// /apps_raw/edit so multiple components reading the draft stay in sync.
const draftHandle = UserDraft.use<{
files: Record<string, string>
runnables: Record<string, Runnable>
data: RawAppData
summary: string
}>('raw_app', '')
$effect(() => {
readFieldsRecursively(files)
readFieldsRecursively(runnables)
readFieldsRecursively(data)
void summary
draftHandle.draft = { files, runnables, data, summary }
})
loadApp()
function extractValue(value: any) {
@@ -3,7 +3,12 @@
import { AppService, DraftService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { cleanValueProperties, type Value } from '$lib/utils'
import {
cleanValueProperties,
orderedJsonStringify,
readFieldsRecursively,
type Value
} from '$lib/utils'
import { afterNavigate, replaceState } from '$app/navigation'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
@@ -13,6 +18,15 @@
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { page } from '$app/state'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { UserDraft } from '$lib/userDraft.svelte'
type RawAppDraft = {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
summary: string
}
let files: Record<string, string> | undefined = $state(undefined)
let runnables = $state({})
/** Data configuration including tables and creation policy */
@@ -39,6 +53,18 @@
let redraw = $state(0)
let path = page.params.path ?? ''
const draftHandle = UserDraft.use<RawAppDraft>('raw_app', path)
// Persist the bundle whenever any of the four pieces of state changes.
$effect(() => {
if (!files) return
readFieldsRecursively(files)
readFieldsRecursively(runnables)
readFieldsRecursively(data)
void summary
draftHandle.draft = { files, runnables, data, summary }
})
let nodraft = page.url.searchParams.get('nodraft')
afterNavigate(() => {
@@ -90,10 +116,61 @@
custom_path: app_w_draft_.custom_path
}
if (app_w_draft.draft) {
extractRawApp(app_w_draft.draft)
const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft
const localDraft = UserDraft.get<RawAppDraft>('raw_app', path)
const backendBundle: RawAppDraft = {
files: backendSource.value?.files ?? {},
runnables: backendSource.value?.runnables ?? {},
data:
backendSource.value?.data ??
(backendSource.value?.datatables
? { ...DEFAULT_DATA, tables: backendSource.value.datatables }
: { ...DEFAULT_DATA }),
summary: backendSource.summary ?? ''
}
if (!app_w_draft.draft_only) {
if (
localDraft != undefined &&
orderedJsonStringify(cleanValueProperties(localDraft)) !==
orderedJsonStringify(cleanValueProperties(backendBundle))
) {
const reloadAction = async () => {
UserDraft.remove('raw_app', path)
await loadApp()
redraw++
}
const deployed = cleanValueProperties(app_w_draft as Value)
const local = { ...deployed, value: localDraft }
sendUserToast('App restored from local autosave', false, [
{
label: 'Discard local autosave and reload',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: local,
title: `${app_w_draft.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
button: { text: 'Discard autosave', onClick: reloadAction }
})
}
}
])
runnables = localDraft.runnables
data = localDraft.data
summary = localDraft.summary
policy = app_w_draft.policy
newPath = app_w_draft.path
files = localDraft.files
} else {
if (localDraft != undefined) UserDraft.remove('raw_app', path)
extractRawApp(backendSource)
if (app_w_draft.draft && !app_w_draft.draft_only) {
const reloadAction = () => {
extractRawApp(app_w_draft)
redraw++
@@ -121,8 +198,6 @@
}
])
}
} else {
extractRawApp(app_w_draft)
}
}
@@ -138,6 +213,7 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('raw_app', path)
goto(`/apps/edit/${savedApp.draft.path}`)
await loadApp()
redraw++
@@ -156,6 +232,7 @@
path: savedApp.path
})
}
UserDraft.remove('raw_app', path)
goto(`/apps/edit/${savedApp.path}`)
await loadApp()
redraw++
@@ -185,6 +262,7 @@
<div class="h-screen">
<RawAppEditor
on:savedNewAppPath={(event) => {
UserDraft.remove('raw_app', path)
goto(`/apps_raw/edit/${event.detail}`)
newPath = event.detail
}}