diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 53bbcdb10c..0cffdc2400 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -34,7 +34,7 @@ sendUserToast, urlParamsToObject } from '$lib/utils' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import AppPreview from './AppPreview.svelte' import ComponentList from './componentsPanel/ComponentList.svelte' import ContextPanel from './contextPanel/ContextPanel.svelte' @@ -69,6 +69,7 @@ path, policy, summary, + deployedBaseline = undefined, fromHub = false, diffDrawer = undefined, savedApp = $bindable(undefined), @@ -87,6 +88,17 @@ migrateApp(untrack(() => app)) + // Migrated clone of the deployed baseline for the autosave `discardIf`. The + // live `stateApp` is `migrateApp`'d on mount, so the baseline must be too or + // an unedited draft would never compare equal. Captured once per mount (the + // route remounts AppEditor on path change), `undefined` for draft-only paths. + const migratedDeployedBaseline = untrack(() => { + if (!deployedBaseline) return undefined + const clone = structuredClone($state.snapshot(deployedBaseline)) as App + migrateApp(clone) + return clone + }) + // Inside a session pane the AIChatManager is injected via context. Sessions // have their own state machinery (sessionRuntime + per-fork backend), and // the user-facing $workspaceStore stays on the main workspace even when @@ -101,7 +113,13 @@ const appDraftHandle = inSessionPane ? undefined : // `canBeDisabled`: page editor's AutosaveIndicator carries the toggle. - UserDraft.use('app', appDraftPath, { canBeDisabled: true }) + // `discardIf`: an autosave reverting to the deployed app deletes the + // draft instead of persisting a no-op copy. + UserDraft.use('app', appDraftPath, { + canBeDisabled: true, + discardIf: (val) => + migratedDeployedBaseline !== undefined && draftValuesEqual(val, migratedDeployedBaseline) + }) // Suspend autosave around mount so the `firstMirror` seed write isn't POSTed // as the user's first edit; `onMount`-then-`tick` resumes once effects settle. if (appDraftHandle) UserDraft.stopSync('app', appDraftPath) diff --git a/frontend/src/lib/components/apps/migrateApp.ts b/frontend/src/lib/components/apps/migrateApp.ts new file mode 100644 index 0000000000..7e6d4f12cb --- /dev/null +++ b/frontend/src/lib/components/apps/migrateApp.ts @@ -0,0 +1,32 @@ +import type { App } from './types' +import { gridColumns } from './gridUtils' +import { allItems } from './editor/appUtilsCore' + +/** + * Normalize an `App` in place to the current schema: default `hiddenInlineScripts` + * type, migrate the legacy `doNotRecomputeOnInputChanged` flag, and default + * `fullHeight` on every grid item. Lives in its own light module (no app-editor + * component imports) so non-editor callers — e.g. the localStorage→DB draft + * migration — can reuse it without pulling the whole `apps/utils` graph. + */ +export function migrateApp(app: App) { + ;(app?.hiddenInlineScripts ?? []).forEach((x) => { + if (x.type == undefined) { + //@ts-ignore + x.type = 'inline' + } + //TODO: remove after migration is done + if (x.doNotRecomputeOnInputChanged != undefined) { + x.recomputeOnInputChanged = !x.doNotRecomputeOnInputChanged + x.doNotRecomputeOnInputChanged = undefined + } + }) + + allItems(app.grid, app.subgrids).forEach((x) => { + gridColumns.forEach((column: number) => { + if (x?.[column]?.fullHeight === undefined) { + x[column].fullHeight = false + } + }) + }) +} diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 8eef469472..90c7ba82b9 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -144,6 +144,10 @@ export interface AppEditorProps { path: string policy: Policy summary: string + /** Deployed app value the autosave `discardIf` compares against, so an + * edit reverting to deployed clears the draft instead of leaving a no-op. + * `undefined` for draft-only paths (no deployed baseline). */ + deployedBaseline?: App | undefined fromHub?: boolean diffDrawer?: DiffDrawerI | undefined savedApp?: diff --git a/frontend/src/lib/components/apps/utils.ts b/frontend/src/lib/components/apps/utils.ts index cc197762b3..85bdd774eb 100644 --- a/frontend/src/lib/components/apps/utils.ts +++ b/frontend/src/lib/components/apps/utils.ts @@ -2,7 +2,14 @@ import type { Schema } from '$lib/common' import { twMerge } from 'tailwind-merge' import { type AppComponent } from './editor/component' -import { isRunnableByName, isRunnableByPath, type AppInput, type InputType, type ResultAppInput, type StaticAppInput } from './inputType' +import { + isRunnableByName, + isRunnableByPath, + type AppInput, + type InputType, + type ResultAppInput, + type StaticAppInput +} from './inputType' import type { Output } from './rx' import type { App, @@ -11,30 +18,12 @@ import type { HorizontalAlignment, VerticalAlignment } from './types' -import { gridColumns } from './gridUtils' import { allItems, BG_PREFIX } from './editor/appUtilsCore' -export function migrateApp(app: App) { - ;(app?.hiddenInlineScripts ?? []).forEach((x) => { - if (x.type == undefined) { - //@ts-ignore - x.type = 'inline' - } - //TODO: remove after migration is done - if (x.doNotRecomputeOnInputChanged != undefined) { - x.recomputeOnInputChanged = !x.doNotRecomputeOnInputChanged - x.doNotRecomputeOnInputChanged = undefined - } - }) - - allItems(app.grid, app.subgrids).forEach((x) => { - gridColumns.forEach((column: number) => { - if (x?.[column]?.fullHeight === undefined) { - x[column].fullHeight = false - } - }) - }) -} +// `migrateApp` moved to its own light module so non-editor callers can reuse it +// without pulling the whole `apps/utils` graph; re-exported here for existing +// `from '../utils'` importers. +export { migrateApp } from './migrateApp' export function processSubcomponents(data: AppComponent, fn: (data: AppComponent) => void) { if (data.type == 'tablecomponent' && Array.isArray(data.actionButtons)) { @@ -133,7 +122,7 @@ export function isScriptByNameDefined(appInput: AppInput | undefined): boolean { return false } - if (appInput.type === 'runnable' && isRunnableByName(appInput.runnable)) { + if (appInput.type === 'runnable' && isRunnableByName(appInput.runnable)) { return appInput.runnable?.name != undefined } @@ -402,10 +391,7 @@ export function getAllScriptNames(app: App): string[] { const names = (allItems(app.grid, app?.subgrids) ?? []).reduce((acc, gridItem: GridItem) => { const { componentInput } = gridItem.data - if ( - componentInput?.type === 'runnable' && - isRunnableByName(componentInput.runnable) - ) { + if (componentInput?.type === 'runnable' && isRunnableByName(componentInput.runnable)) { acc.push(componentInput.runnable.name) } diff --git a/frontend/src/lib/components/usePageDraftSync.svelte.ts b/frontend/src/lib/components/usePageDraftSync.svelte.ts index 6edbe98070..f0cdf2b780 100644 --- a/frontend/src/lib/components/usePageDraftSync.svelte.ts +++ b/frontend/src/lib/components/usePageDraftSync.svelte.ts @@ -11,7 +11,7 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' * overwrite-as-fresh), the deployed-baseline `seed`, and the draft `remove`. * The entity-specific backend load and new-draft template stay in the page. */ -export interface PageDraftSyncOptions { +export interface PageDraftSyncOptions { itemKind: UserDraftItemKind /** Reactive draft storage path. `''` (e.g. viewing a historical hash) * releases the handle and skips registry/sync work. */ @@ -22,6 +22,14 @@ export interface PageDraftSyncOptions { * draft's own `path`, used by home-page deep links). Omit to skip * registry registration entirely (e.g. read-only hash views). */ effectivePath?: () => string | undefined + /** Predicate: is the value about to autosave back at the deployed + * baseline? When true the syncer POSTs a delete instead of a + * baseline-equal draft, so editing back to deployed clears the draft + * instead of leaving a no-op behind. MUST read the deployed baseline + * reactively (it's captured once per re-keyed acquire) and use + * `draftValuesEqual` so it can't disagree with the "unsaved changes" + * banner. Return false for draft-only items (no deployed baseline). */ + discardIf?: (val: V) => boolean } export interface PageDraftSync { @@ -41,14 +49,15 @@ export interface PageDraftSync { remove(): void } -export function usePageDraftSync(opts: PageDraftSyncOptions): PageDraftSync { +export function usePageDraftSync(opts: PageDraftSyncOptions): PageDraftSync { // One handle, re-keyed on (workspace, path); `''` path releases it. // `canBeDisabled` because these editors carry the "Enable auto-save" toggle. const handle = UserDraft.useReactive(() => ({ itemKind: opts.itemKind, path: opts.path(), workspace: opts.workspace(), - canBeDisabled: true + canBeDisabled: true, + discardIf: opts.discardIf })) // Live-editor-draft registry: lets the home-page "edit draft" link resolve diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 75bef6901b..ec80865b2b 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -186,11 +186,24 @@ function snapshotDraftValue(value: V | undefined): V | undefined { * run-as directives, not draft content, and the editor round-trips them * asymmetrically (`preserve_…` rebuilt as `!!cfg.permissioned_as` on load * but `|| undefined` on build) — keeping them produces a phantom banner. + * + * The rest are server-managed read-time metadata that ride along on the + * loaded deployed payload but never appear in the editor's draft content, so + * comparing them would mask a true baseline match: + * `draft_saved_at` (the draft's own save time), `edited_at` (deploy time), + * `edited_by` (deploy author), `workspace_id`, `version_id` (deployed version), + * and `is_draft` (backend presence flag). */ const DRAFT_COMPARE_IGNORED_FIELDS = [ 'permissioned_as', 'preserve_permissioned_as', - 'extra_perms' + 'extra_perms', + 'draft_saved_at', + 'edited_at', + 'edited_by', + 'workspace_id', + 'version_id', + 'is_draft' ] as const /** @@ -453,6 +466,8 @@ export const UserDraft = { opts?: UserDraftOptions & { /** See the `useMany` spec field. Default `false`. */ canBeDisabled?: boolean + /** See the `useMany` spec field. Captured once on first acquire. */ + discardIf?: (val: V) => boolean } ): UserDraftHandle { // Single-spec wrapper around `useMany`. `untrack` captures reactive @@ -460,7 +475,13 @@ export const UserDraft = { // workspace until unmount. For reactive `(kind, path)` use `useReactive`. const handles = UserDraft.useMany(() => untrack(() => [ - { itemKind, path, workspace: opts?.workspace, canBeDisabled: opts?.canBeDisabled } + { + itemKind, + path, + workspace: opts?.workspace, + canBeDisabled: opts?.canBeDisabled, + discardIf: opts?.discardIf + } ]) ) return handles[0] @@ -479,6 +500,8 @@ export const UserDraft = { path: string workspace?: string canBeDisabled?: boolean + /** See the `useMany` spec field. Captured per re-keyed acquire. */ + discardIf?: (val: V) => boolean } ): UserDraftHandle { const handles = UserDraft.useMany(() => [getSpec()]) diff --git a/frontend/src/lib/userDraftDbMigration.test.ts b/frontend/src/lib/userDraftDbMigration.test.ts new file mode 100644 index 0000000000..622401c2b5 --- /dev/null +++ b/frontend/src/lib/userDraftDbMigration.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Service layer is mocked: the migration must dedup against the deployed value +// without making real network calls. +const updateDraft = vi.fn(async (..._args: any[]) => ({ status: 'created' as const })) +const getScriptByPath = vi.fn() +const getFlowByPath = vi.fn() +const getAppByPath = vi.fn() + +vi.mock('./gen', () => ({ + DraftService: { updateDraft: (...a: unknown[]) => updateDraft(...(a as [])) }, + ScriptService: { getScriptByPath: (...a: unknown[]) => getScriptByPath(...(a as [])) }, + FlowService: { getFlowByPath: (...a: unknown[]) => getFlowByPath(...(a as [])) }, + AppService: { getAppByPath: (...a: unknown[]) => getAppByPath(...(a as [])) } +})) + +// `migrateApp` mutates an App in place; the deployed fixtures below are already +// in migrated shape, so a no-op keeps the dedup comparison exact. +vi.mock('./components/apps/migrateApp', () => ({ migrateApp: vi.fn() })) +vi.mock('./toast', () => ({ sendUserToast: vi.fn() })) +vi.mock('./userNamespace', () => ({ getUsernameForNamespace: () => 'me' })) +vi.mock('./utils/uuid', () => ({ randomUUID: () => 'fixed-uuid' })) + +import { migrateUserDraftsToDb } from './userDraftDbMigration' + +function lsKey(kind: string, path: string): string { + return `userdraft/w/main/${kind}/${path}` +} + +function setDraft(kind: string, path: string, value: unknown): string { + const key = lsKey(kind, path) + localStorage.setItem(key, JSON.stringify({ value, lastWrittenAt: 123 })) + return key +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + updateDraft.mockResolvedValue({ status: 'created' }) +}) + +describe('migrateUserDraftsToDb dedup', () => { + it('drops a draft deep-equal to the deployed script without uploading it', async () => { + const deployed = { path: 'u/me/s', summary: 'hi', content: 'x', language: 'bun' } + getScriptByPath.mockResolvedValue(deployed) + const key = setDraft('script', 'u/me/s', { ...deployed }) + + await migrateUserDraftsToDb() + + expect(getScriptByPath).toHaveBeenCalledWith({ + workspace: 'main', + path: 'u/me/s', + getDraft: false + }) + expect(updateDraft).not.toHaveBeenCalled() + expect(localStorage.getItem(key)).toBeNull() + }) + + it('uploads a draft that differs from the deployed script', async () => { + getScriptByPath.mockResolvedValue({ + path: 'u/me/s', + summary: 'hi', + content: 'x', + language: 'bun' + }) + const key = setDraft('script', 'u/me/s', { + path: 'u/me/s', + summary: 'hi', + content: 'EDITED', + language: 'bun' + }) + + await migrateUserDraftsToDb() + + expect(updateDraft).toHaveBeenCalledTimes(1) + expect(localStorage.getItem(key)).toBeNull() + }) + + it('treats `{ field: undefined }` and an absent field as equal (json normalization)', async () => { + // The draft table stores JSON, which strips `undefined` keys — the + // comparison must too, or a draft that only differs by an undefined key + // would never dedup. + getFlowByPath.mockResolvedValue({ summary: 'f', value: { modules: [] } }) + const key = setDraft('flow', 'u/me/f', { + summary: 'f', + value: { modules: [] }, + labels: undefined + }) + + await migrateUserDraftsToDb() + + expect(updateDraft).not.toHaveBeenCalled() + expect(localStorage.getItem(key)).toBeNull() + }) + + it('ignores server-managed metadata fields on the deployed flow payload', async () => { + // The deployed flow carries read-time metadata (workspace_id, edited_by, + // version_id, is_draft, timestamps) that the editor's draft content never + // holds — they must not block the dedup. + getFlowByPath.mockResolvedValue({ + workspace_id: 'admins', + path: 'u/me/f', + summary: 'f', + value: { modules: [] }, + edited_by: 'admin@windmill.dev', + edited_at: '2026-01-01T00:00:00Z', + archived: false, + schema: {}, + extra_perms: {}, + version_id: 2, + is_draft: false, + draft_saved_at: '2026-01-01T00:00:01Z' + }) + const key = setDraft('flow', 'u/me/f', { + path: 'u/me/f', + summary: 'f', + value: { modules: [] }, + archived: false, + schema: {} + }) + + await migrateUserDraftsToDb() + + expect(updateDraft).not.toHaveBeenCalled() + expect(localStorage.getItem(key)).toBeNull() + }) + + it('compares app drafts against the deployed `.value`', async () => { + const appValue = { + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + } + getAppByPath.mockResolvedValue({ value: { ...appValue } }) + const key = setDraft('app', 'u/me/a', { ...appValue }) + + await migrateUserDraftsToDb() + + expect(getAppByPath).toHaveBeenCalledWith({ + workspace: 'main', + path: 'u/me/a', + getDraft: false + }) + expect(updateDraft).not.toHaveBeenCalled() + expect(localStorage.getItem(key)).toBeNull() + }) + + it('uploads when there is no deployed item (fetch rejects)', async () => { + getScriptByPath.mockRejectedValue(new Error('404')) + const key = setDraft('script', 'u/me/new', { path: 'u/me/new', content: 'x' }) + + await migrateUserDraftsToDb() + + expect(updateDraft).toHaveBeenCalledTimes(1) + expect(localStorage.getItem(key)).toBeNull() + }) + + it('skips the deployed fetch for a pathless /add draft and uploads at a minted path', async () => { + // A legacy `/add` autosave has an empty path; there is no deployed item to + // dedup against, so it uploads to a freshly minted `u/{user}/draft_{uuid}`. + setDraft('script', '', { path: '', content: 'x' }) + + await migrateUserDraftsToDb() + + expect(getScriptByPath).not.toHaveBeenCalled() + expect(updateDraft).toHaveBeenCalledTimes(1) + expect(updateDraft.mock.calls[0][0]).toMatchObject({ + kind: 'script', + // `mintDraftAddPath` dashes→underscores (path segments are word chars). + path: 'u/me/draft_fixed_uuid' + }) + }) + + it('does not dedup unsupported kinds (e.g. variable) — uploads as before', async () => { + const key = setDraft('variable', 'u/me/v', { value: 'secret' }) + + await migrateUserDraftsToDb() + + expect(getScriptByPath).not.toHaveBeenCalled() + expect(getAppByPath).not.toHaveBeenCalled() + expect(updateDraft).toHaveBeenCalledTimes(1) + expect(localStorage.getItem(key)).toBeNull() + }) +}) diff --git a/frontend/src/lib/userDraftDbMigration.ts b/frontend/src/lib/userDraftDbMigration.ts index e6bca477a7..8859f588e0 100644 --- a/frontend/src/lib/userDraftDbMigration.ts +++ b/frontend/src/lib/userDraftDbMigration.ts @@ -6,13 +6,18 @@ * success — so it's idempotent without a sentinel; failed entries retry next * mount. Not workspace-gated: keys embed their own workspace and the token * covers all of them, so gating would orphan other-workspace entries. - * Deliberately self-contained (no `userDraft.svelte.ts` import) so the - * runtime module stays free of legacy decoders. + * + * Before uploading, each draft is compared against its deployed version + * (script / flow / app); a draft that's deep-equal to what's deployed carries + * no changes, so it's dropped instead of migrated (no error). */ -import { DraftService } from './gen' +import { AppService, DraftService, FlowService, ScriptService } from './gen' import type { UserDraftItemKind } from './gen' +import type { App } from './components/apps/types' +import { migrateApp } from './components/apps/migrateApp' import { sendUserToast } from './toast' +import { draftValuesEqual } from './userDraft.svelte' import { openDraftMigrationErrorModal, reportDraftMigrationError @@ -121,6 +126,48 @@ function readPayload(key: string): { value: unknown; lastWrittenAt?: number } | } } +/** + * Fetch the deployed value for a draft so the migration can drop a draft that + * carries no changes (deep-equal to what's already deployed) instead of + * uploading a no-op that would light up the "unsaved" badge. Returns the + * comparable deployed payload, or `undefined` when there's nothing to compare + * against: an unsupported kind, a pathless (minted `/add`) draft, or a fetch + * miss (404 — the path is draft-only, so the draft is genuinely new). `getDraft` + * is forced off so we compare against the deployed baseline, not our own draft. + */ +async function fetchDeployedValue( + workspace: string, + kind: UserDraftItemKind, + path: string +): Promise { + if (!path) return undefined + try { + switch (kind) { + case 'script': + return await ScriptService.getScriptByPath({ workspace, path, getDraft: false }) + case 'flow': + return await FlowService.getFlowByPath({ workspace, path, getDraft: false }) + case 'app': { + // The app autosave stores the inner `App`, not the `AppWithLastVersion` + // wrapper getAppByPath returns — compare against `.value`. Run + // `migrateApp` so the deployed value matches the editor-migrated draft + // (AppEditor `migrateApp`s `stateApp` on mount); without this an app + // whose deployed row predates those field migrations never dedups. + const app = await AppService.getAppByPath({ workspace, path, getDraft: false }) + const value = (app as { value?: App }).value + if (value) migrateApp(value) + return value + } + default: + return undefined + } + } catch { + // No deployed item at this path (or the fetch failed) — nothing to dedup + // against, so the caller proceeds to upload the draft. + return undefined + } +} + function collectKeys(): string[] { const keys: string[] = [] for (let i = 0; i < localStorage.length; i++) { @@ -188,6 +235,19 @@ export async function migrateUserDraftsToDb(): Promise { for (const { key, parsed, path, value, lastWrittenAt } of toMigrate) { try { + // Dedup: if the draft is deep-equal to the deployed version it carries + // no changes — drop it (no error) instead of uploading a no-op draft. + // Fetches against `parsed.path` (the real item path); minted `/add` + // drafts have `parsed.path === ''` and so are never deduped. + const deployed = await fetchDeployedValue(parsed.workspace, parsed.itemKind, parsed.path) + if (deployed !== undefined && draftValuesEqual(value, deployed)) { + try { + localStorage.removeItem(key) + } catch { + // Best-effort; a stale LS entry is harmless — it re-dedups next mount. + } + continue + } const res = await DraftService.updateDraft({ workspace: parsed.workspace, kind: parsed.itemKind, diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index cf81fe26e1..760b0cd65d 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -36,6 +36,10 @@ /** No deployed app at the URL path. Drives the editor's deploy: * `createApp` vs `updateApp`. Flips false once a deploy lands here. */ let isNewApp = $state(false) + /** Deployed app value this load, the baseline AppEditor's autosave + * `discardIf` compares against. `undefined` for draft-only paths so they + * never self-destruct by matching a non-existent baseline. */ + let deployedBaseline = $state(undefined) let otherDraftsUsers = $state([]) let loadedFromDraft = $state(false) let othersModalOpen = $state(false) @@ -65,6 +69,8 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + // Brand-new app: no deployed baseline, so never discard-on-equal. + deployedBaseline = undefined // Capture every seeding param BEFORE stripping the URL flag. const templatePath = page.url.searchParams.get('template') const templateId = page.url.searchParams.get('template_id') @@ -185,6 +191,12 @@ getDraft }) if (tok !== loadAppToken) return + // Deployed App value for AppEditor's autosave `discardIf`, captured BEFORE + // the draft swap below replaces `backendApp.value`. `undefined` when + // there's no deployed row (draft-only path). + deployedBaseline = backendApp.no_deployed + ? undefined + : (structuredClone(stateSnapshot(backendApp.value)) as App) // `other_drafts_users` only computed when `getDraft`; don't clobber the // known list on a `getDraft:false` reload. See /scripts/edit's loader. if (getDraft) { @@ -343,6 +355,7 @@ on:restore={onRestore} summary={app.summary} app={app.value} + {deployedBaseline} newPath={app.value?.draft_path ?? app.path} path={page.params.path ?? ''} policy={app.policy} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index a1b789b0df..2fe1434d96 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -13,7 +13,7 @@ import { page } from '$app/state' import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { importStore } from '$lib/components/apps/store' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte' import { armRestartOnFirstInteraction, runResetToDeployed } from '$lib/userDraftToast' import DraftEditorModals from '$lib/components/common/confirmationModal/DraftEditorModals.svelte' @@ -72,13 +72,21 @@ * React/Svelte + data config + optional AI prompt before the editor goes live. */ let templatePicker = $state(false) + /** Deployed raw-app bundle this load, the baseline the autosave `discardIf` + * compares against. `undefined` for draft-only paths so they never + * self-destruct by matching a non-existent baseline. */ + let deployedBaseline = $state(undefined) + // Page-level draft orchestration. `path` is a mount-scoped plain `let` (the // editor remounts per path), so this re-keys only on workspace change. // effectivePath omitted: the live-editor-draft entry is owned by RawAppEditor. const draftSync = usePageDraftSync({ itemKind: 'raw_app', path: () => path, - workspace: () => $workspaceStore + workspace: () => $workspaceStore, + // Autosaves landing back on the deployed raw app become deletes, so + // reverting edits clears the draft instead of leaving a no-op behind. + discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline) }) // Persist the bundle whenever any of the four pieces of state changes. @@ -98,30 +106,42 @@ summary, policy, custom_path: savedApp?.custom_path, - // Only persist when set, so the field disappears from the saved JSON - // once the typed path matches the baseline again (or on deploy). - ...(pendingDraftPath ? { draft_path: pendingDraftPath } : {}) + // Persist the typed path as `draft_path` only when it actually differs + // from the current path — a `draft_path` equal to the baseline is a + // no-op that would block the draft from deduping against the deployed + // app (which carries none). Drops back out on a revert or deploy. + ...(pendingDraftPath && pendingDraftPath !== (savedApp?.path ?? '') + ? { draft_path: pendingDraftPath } + : {}) } as RawAppDraft }) - function extractRawApp(app: any) { - runnables = app.value.runnables - // Support old formats and new format - if (app.value.data) { - const d = app.value.data + /** Normalize a raw-app `value` into the editor's `data` config, supporting + * the old nested `creation` / `datatables` shapes. `undefined` when the + * value carries no data config (caller keeps the current/default `data`). */ + function extractDataConfig(value: any): RawAppData | undefined { + if (value?.data) { + const d = value.data // Handle old nested creation format if (d.creation) { - data = { + return { tables: d.tables ?? [], datatable: d.creation.datatable, schema: d.creation.schema } - } else { - data = d } - } else if (app.value.datatables) { - data = { ...DEFAULT_DATA, tables: app.value.datatables } + return d + } else if (value?.datatables) { + return { ...DEFAULT_DATA, tables: value.datatables } } + return undefined + } + + function extractRawApp(app: any) { + runnables = app.value.runnables + // Support old formats and new format + const extractedData = extractDataConfig(app.value) + if (extractedData) data = extractedData files = app.value.files summary = app.summary // lastVersion = app.version @@ -158,6 +178,8 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + // Brand-new raw app: no deployed baseline, so never discard-on-equal. + deployedBaseline = undefined // Suspend autosave across the bootstrap: the seed template and the // picker's `onStart` are programmatic writes that must not POST as the // first edit. Resume on first interaction (the template-card click or @@ -259,6 +281,22 @@ // See /apps/edit's loader. draftSavedAt = backendApp.draft_saved_at as string | undefined deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined) + // Deployed baseline for the autosave `discardIf`, captured BEFORE the swap + // below mutates `backendApp`. Mirrors the bundle `$effect`'s shape (minus + // the edit-only `draft_path`) so an unedited draft compares equal. + // `undefined` when there's no deployed row. + deployedBaseline = backendApp.no_deployed + ? undefined + : (structuredClone( + stateSnapshot({ + files: backendApp.value?.files ?? {}, + runnables: backendApp.value?.runnables ?? {}, + data: extractDataConfig(backendApp.value) ?? { ...DEFAULT_DATA }, + summary: backendApp.summary ?? '', + policy: backendApp.policy, + custom_path: backendApp.custom_path + }) + ) as RawAppDraft) // The raw-app autosave stores a flat `RawAppDraft`, but this loader (and // `extractRawApp`) needs the deployed shape with `files`/`runnables`/`data` // under `.value` and the rest top-level. Re-wrap the saved draft (`.draft`): diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 1e1fcdccbc..2391e5c723 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -19,7 +19,7 @@ import { tick, untrack } from 'svelte' import type { stepState } from '$lib/components/stepHistoryLoader.svelte' import { page } from '$app/state' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import { armRestartOnFirstInteraction, discardDraftAfterDeploy, @@ -43,6 +43,10 @@ } let savedFlow: Flow | undefined = $state(undefined) + /** Deployed flow this load, the baseline the autosave `discardIf` compares + * against. `undefined` for draft-only paths (no deployed) so a draft-only + * item never self-destructs by "matching" a non-existent baseline. */ + let deployedBaseline = $state(undefined) let otherDraftsUsers = $state([]) let loadedFromDraft = $state(false) let othersModalOpen = $state(false) @@ -65,7 +69,10 @@ const draftSync = usePageDraftSync({ itemKind: 'flow', path: () => flowDraftPath, - workspace: () => $workspaceStore + workspace: () => $workspaceStore, + // Autosaves landing back on the deployed flow become deletes, so reverting + // edits clears the draft instead of leaving a no-op behind. + discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline) }) function emptyFlow(): Flow { @@ -136,6 +143,8 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + // Brand-new flow: no deployed baseline, so never discard-on-equal. + deployedBaseline = undefined // Suspend autosave around the bootstrap cascade: the Path widget's // `initPath → reset → bind:path` chain seeds a friendly auto-name that // FlowBuilder mirrors into `flow.draft_path` — a programmatic write that @@ -336,6 +345,11 @@ ? ({ ...deployedFlow, ...draftFromBackend } as Flow) : (deployedFlow as Flow) savedFlow = structuredClone($state.snapshot(effectiveFlow)) as Flow + // Baseline for the autosave `discardIf`: the deployed flow WITHOUT the + // draft overlay (matches the unedited seed when no draft exists). + deployedBaseline = backendFlow.no_deployed + ? undefined + : (structuredClone($state.snapshot(deployedFlow)) as Flow) // Surface the saved `draft_path` to the Path widget so the topbar shows the // pending name, not the `draft_{uuid}` URL. Else the widget seeds from the // URL, the first edit clobbers `draft_path`, and the friendly name is lost. diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index f04ef93188..5ed2c4f428 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -17,7 +17,7 @@ import { get } from 'svelte/store' import { untrack } from 'svelte' import { page } from '$app/state' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import { discardDraftAfterDeploy, runResetToDeployed } from '$lib/userDraftToast' import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte' import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' @@ -65,11 +65,19 @@ // Page-level draft orchestration: autosave handle (re-keyed on nav via // `draftPath`), live-editor-draft registry, `recordRemoteSync`, removal. // `draftSync.draft` stays a stable lvalue for `bind:script`. + /** Deployed script this load (with `parent_hash` grafted to match the + * unedited draft seed), the baseline the autosave `discardIf` compares + * against. `undefined` for draft-only paths so they never self-destruct. */ + let deployedBaseline = $state(undefined) + const draftSync = usePageDraftSync({ itemKind: 'script', path: () => draftPath, workspace: () => $workspaceStore, - effectivePath: () => draftSync.draft?.path ?? draftPath + effectivePath: () => draftSync.draft?.path ?? draftPath, + // Autosaves landing back on the deployed script become deletes, so + // reverting edits clears the draft instead of leaving a no-op behind. + discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline) }) // Seed from the URL so ScriptBuilder mounts with a populated `initialPath` @@ -129,6 +137,8 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + // Brand-new script: no deployed baseline, so never discard-on-equal. + deployedBaseline = undefined // Capture every seeding param BEFORE stripping the URL flag. const templatePath = page.url.searchParams.get('template') const hubPath = page.url.searchParams.get('hub') @@ -259,6 +269,9 @@ }) if (tok !== loadScriptToken) return savedScript = structuredClone($state.snapshot(scriptByHash)) + // Historical-hash view is read-only relative to drafts (`draftPath` is + // '' → detached handle), so no baseline is needed. + deployedBaseline = undefined draftSync.draft = { ...scriptByHash, parent_hash: hash, lock: undefined } } else { const backendScript = await ScriptService.getScriptByPath({ @@ -292,6 +305,17 @@ ? { ...deployedScript, ...draftFromBackend } : (deployedScript as EditableScript) savedScript = structuredClone($state.snapshot(effectiveScript)) + // Baseline for the autosave `discardIf`: the deployed script with the + // same `parent_hash` graft the seed below applies, so the unedited + // draft compares equal. `undefined` when there's no deployed row. + deployedBaseline = backendScript.no_deployed + ? undefined + : structuredClone( + $state.snapshot({ + ...deployedScript, + parent_hash: topHash ?? backendScript.hash + }) + ) // `parent_hash` is grafted on so the editor's compile reuses the // deployed lock. The first cell write after `acquireEntry` is swallowed // by the syncer's seed guard, so this load doesn't POST.