diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index ebacf5c862..7dfb3630a0 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -102,8 +102,9 @@ | undefined diffDrawer?: DiffDrawer | undefined onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void - /** Fired after a successful deploy; the session preview reloads on it. */ - onDeploy?: (e: { path: string }) => void + /** Fired after a successful deploy; the session preview reloads on it and + * the route re-pins the draft's fork base to the version just written. */ + onDeploy?: (e: { path: string; version?: number }) => void /** Initial collapsed state for the file/runnable sidebar. The user's * toggled preference is persisted under `sidebarStorageKey`; this prop * only seeds the very first open. */ @@ -131,6 +132,10 @@ pendingDraftPath?: string | undefined // Threaded to the AutosaveIndicator's "Reset to deployed" button. onResetToDeployed?: () => void | Promise + /** The app_version the draft forked from, for the deploy-time "new version + * deployed" guard: deploying is refused with a confirmation while it is not + * the head. Undefined for a draft-only app. */ + version?: number | undefined // See ScriptBuilderProps — same indicator semantics. loadedFromDraft?: boolean othersDraftsCount?: number @@ -202,9 +207,9 @@ onScreenshotRequester = undefined, onRestore, onSavedNewAppPath, - condensedHeader = false + condensedHeader = false, + version = undefined }: Props = $props() - export const version: number | undefined = undefined // Workspace this editor operates on: the session's acting workspace when // embedded in a session preview (autosaveWorkspace), else the navigation @@ -248,6 +253,12 @@ // so the preview just opens the app. setOpenInSessionHandoff({ source: () => sessionOpen }) + let header: RawAppEditorHeader | undefined = $state(undefined) + /** The Deployed↔Current diff, for the route's stale-draft prompt. */ + export function openDiffDrawer() { + return header?.openDiffDrawer() + } + /** Hand this app off to a fresh AI session, seeding `seedPrompt` and sending * it on arrival. Exposed for the template picker's "Start in AI session": the * route owns the prompt, but the draft persistence the preview depends on @@ -2285,11 +2296,13 @@ />
void + // Fired after a successful deploy; lets the session preview reload. Carries + // the version just written so the route can re-pin the draft's fork base. + onDeploy?: (e: { path: string; version?: number }) => void /** Surfaces the user-typed path (`newEditedPath`) up to the route * when (and only when) it differs from the deployed/seeded * `savedApp.path`. The route writes it into the autosaved raw-app @@ -431,7 +432,7 @@ } } - async function openDiffDrawer() { + export async function openDiffDrawer() { if (!savedApp) { return } @@ -513,7 +514,7 @@ if (appPath !== npath) { onSavedNewAppPath?.(npath) } - onDeploy?.({ path: npath }) + onDeploy?.({ path: npath, version }) } async function setPublishState(message?: string) { diff --git a/frontend/src/lib/components/sessions/appDraftCodec.ts b/frontend/src/lib/components/sessions/appDraftCodec.ts index 10752b8d8f..910378b260 100644 --- a/frontend/src/lib/components/sessions/appDraftCodec.ts +++ b/frontend/src/lib/components/sessions/appDraftCodec.ts @@ -16,6 +16,9 @@ export type RawAppDraft = { // friendly name (they read `value->>'draft_path'`) — and so editing the path // in the editor changes the persisted draft and triggers an autosave. draft_path?: string + // The app_version the draft forked from. The server derives `draft.base` from + // it, which is what tells a draft that is behind the deployed head. + parent_version?: number } // The shape a raw-app cell's store (`RawAppRuntimeValue` in @@ -30,6 +33,7 @@ export type RuntimeRawApp = { policy: any custom_path?: string draft_path?: string + parent_version?: number } // Strip runtime-only metadata (just `path`, the storage key) when persisting @@ -43,7 +47,8 @@ export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft { data: raw.data, policy: raw.policy, custom_path: raw.custom_path, - draft_path: raw.draft_path + draft_path: raw.draft_path, + parent_version: raw.parent_version } } @@ -58,6 +63,7 @@ export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft): data: dv.data, policy: dv.policy ?? raw.policy, custom_path: dv.custom_path ?? raw.custom_path, - draft_path: dv.draft_path ?? raw.draft_path + draft_path: dv.draft_path ?? raw.draft_path, + parent_version: dv.parent_version ?? raw.parent_version } } 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 78483c327a..ed1e86c508 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 @@ -122,6 +122,7 @@ summary, policy, custom_path: savedApp?.custom_path, + parent_version: parentVersion, // 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 @@ -165,6 +166,13 @@ let othersModalOpen = $state(false) let draftSavedAt = $state(undefined) let deployedAt = $state(undefined) + // The app_version the draft forked from, and the deployed head, for the exact + // staleness check (vs the drifting timestamps). `parentVersion` is what the + // bundle carries: an own draft keeps the version it forked from; a fresh + // checkout forks from the head. + let parentVersion = $state(undefined) + let draftBaseVersion = $state(undefined) + let deployedHeadVersion = $state(undefined) async function loadApp(opts: { getDraft?: boolean } = {}): Promise { const getDraft = opts.getDraft ?? true const tok = ++loadAppToken @@ -185,6 +193,9 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + parentVersion = undefined + draftBaseVersion = undefined + deployedHeadVersion = undefined // `labels` is route-level state; reset it too so a fresh draft doesn't // inherit (and then deploy) the previously-opened app's labels. The // import branch re-seeds it via extractRawApp below. @@ -301,6 +312,15 @@ // See /apps/edit's loader. draftSavedAt = backendApp.draft_saved_at as string | undefined deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined) + // Head = the last entry of the deployed `versions`. The base the bundle + // carries is the draft's own when it has one; a draft that predates the + // base (or a fresh checkout) forks from the head from here on. + const versions = backendApp.versions as number[] | undefined + const headVersion = + backendApp.no_deployed || !Array.isArray(versions) ? undefined : versions[versions.length - 1] + deployedHeadVersion = headVersion != null ? String(headVersion) : undefined + draftBaseVersion = backendApp.draft_base as string | undefined + parentVersion = hasOwnDraft && draftBaseVersion != null ? Number(draftBaseVersion) : headVersion // 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. @@ -314,7 +334,8 @@ data: extractDataConfig(backendApp.value) ?? { ...DEFAULT_DATA }, summary: backendApp.summary ?? '', policy: backendApp.policy, - custom_path: backendApp.custom_path + custom_path: backendApp.custom_path, + parent_version: parentVersion }) ) as RawAppDraft) // The raw-app autosave stores a flat `RawAppDraft`, but this loader (and @@ -549,6 +570,9 @@ bind:othersModalOpen {draftSavedAt} {deployedAt} + {draftBaseVersion} + {deployedHeadVersion} + onViewDiff={() => rawAppEditor?.openDiffDrawer()} onLoadLatestDeploy={async () => { // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. if (!$workspaceStore) return @@ -587,6 +611,15 @@ bind:savedApp {diffDrawer} newApp={isNewApp} + version={parentVersion} + onDeploy={({ version }) => { + // The version just written is the new head, and the base the next + // autosave should carry. + if (version != null) { + parentVersion = version + deployedHeadVersion = String(version) + } + }} onResetToDeployed={reloadDeployed} {loadedFromDraft} othersDraftsCount={otherDraftsUsers.length}