diff --git a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte index 5b3fcff72f..0a8f95d7bc 100644 --- a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte +++ b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte @@ -50,8 +50,13 @@ * fallback. */ draftBaseVersion?: string | undefined deployedHeadVersion?: string | undefined + /** Who deployed the head, named in the stale prompt. */ + deployedBy?: string | undefined /** Discard the draft and reload deployed (same as "Reset to deployed"). */ onLoadLatestDeploy?: () => void | Promise + /** Move the draft's base to the head and keep its content (see + * StaleDraftModal). Omit where the route cannot set the base. */ + onTakeLatest?: () => void | Promise /** Opens the editor's Deployed↔Current diff from the stale prompt, so the * choice between keeping and discarding is informed. Omit where the editor * has no diff drawer; the action is then not rendered. */ @@ -79,8 +84,10 @@ deployedAt = undefined, draftBaseVersion = undefined, deployedHeadVersion = undefined, + deployedBy = undefined, onLoadLatestDeploy, onViewDiff, + onTakeLatest, onBeforeRelocate, enabled = true }: Props = $props() @@ -170,10 +177,15 @@ {#if onLoadLatestDeploy} {/if} /** - * Modal opened on editor mount when the authed user's per-user draft - * is older than the latest deployed version at the same path — i.e. - * someone else deployed a new version while the draft was sitting - * around. The user is asked to either pick up the latest deploy - * (discards the stale draft) or keep editing what they had. - * - * The parent threads `draftSavedAt` and `deployedAt` raw and the - * modal computes staleness internally; this keeps each route from - * re-implementing the comparison and the threshold (we treat a - * draft as stale only when it's strictly older — a deploy at the - * exact same instant is treated as not stale). + * The prompt for a draft that is behind: someone deployed a newer version + * of the item after the draft forked from it. Opened on every load while + * that holds (the parent computes it; see DraftEditorModals), it names the + * two versions and offers the four ways out: look at the diff, keep going, + * take the latest as the new base while keeping the edits, or drop the + * draft for the latest deploy. * * Open-state is bindable so the parent can dismiss programmatically * (e.g. after the load-latest-deploy callback completes). @@ -19,13 +14,21 @@ import Button from '$lib/components/common/button/Button.svelte' import { AlertTriangle, GitCompare } from 'lucide-svelte' import { sendUserToast } from '$lib/toast' + import type { UserDraftItemKind } from '$lib/gen' type Props = { isOpen: boolean - /** ISO timestamp the authed user's draft was saved. */ + itemKind: UserDraftItemKind + /** ISO timestamp the authed user's draft was saved. Shown only when the + * versions below are unknown (a draft that predates the base). */ draftSavedAt: string | undefined /** ISO timestamp the latest deploy at this path landed. */ deployedAt: string | undefined + /** The version the draft forked from and the deployed head, as text. */ + draftBaseVersion?: string | undefined + deployedHeadVersion?: string | undefined + /** Who deployed the head. */ + deployedBy?: string | undefined /** Discards the draft and reloads the deployed payload — the route * already has this callback for the AutosaveIndicator's "Reset to * deployed" button; pass the same function in. */ @@ -35,17 +38,46 @@ * to see what actually differs — and after a rename the difference is * often only the path. Omitted where the editor has no diff drawer. */ onViewDiff?: () => void | Promise + /** Moves the draft's base to the head and keeps its content: the one way + * to acknowledge the newer version without discarding edits. The route + * owns it because the base lives in a per-kind field of the value. */ + onTakeLatest?: () => void | Promise } let { isOpen = $bindable(), + itemKind, draftSavedAt, deployedAt, + draftBaseVersion = undefined, + deployedHeadVersion = undefined, + deployedBy = undefined, onLoadLatestDeploy, - onViewDiff + onViewDiff, + onTakeLatest }: Props = $props() let loading = $state(false) + let takingLatest = $state(false) + + async function takeLatest() { + if (takingLatest || !onTakeLatest) return + takingLatest = true + try { + await onTakeLatest() + isOpen = false + } catch (e: any) { + sendUserToast(`Could not take the latest version: ${e?.body ?? e?.message ?? e}`, true) + } finally { + takingLatest = false + } + } + + // Scripts are versioned by hash, the other kinds by a numeric version id; + // the diff picker renders them the same way. + function formatVersion(v: string): string { + return itemKind === 'script' ? v.slice(0, 8) : `v${v}` + } async function loadLatestDeploy() { if (loading) return @@ -82,13 +114,18 @@
-

- A newer version was deployed after you started editing. Your draft is based on the older - deploy. -

-

- Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)} -

+

A newer version was deployed after you started editing.

+ {#if draftBaseVersion && deployedHeadVersion} +

+ Your draft is based on {formatVersion(draftBaseVersion)} + · latest is {formatVersion(deployedHeadVersion)} + {#if deployedBy}by {deployedBy}{/if}{#if deployedAt}, {formatTs(deployedAt)}{/if} +

+ {:else} +

+ Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)} +

+ {/if}
@@ -109,6 +146,11 @@ + {#if onTakeLatest} + + {/if} 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 998de1f657..1577cf1c21 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -54,6 +54,7 @@ // the precise staleness check in DraftEditorModals (vs the drifting timestamp). let draftBaseVersion = $state(undefined) let deployedHeadVersion = $state(undefined) + let deployedBy = $state(undefined) /** Increments per `loadApp` call. Stale loads (e.g. when picker * navigation races a draft-discard reload) bail at the next checkpoint @@ -91,6 +92,7 @@ // reused route and falsely trip the stale-draft modal. draftBaseVersion = undefined deployedHeadVersion = undefined + deployedBy = undefined // Brand-new app: no deployed baseline, so never discard-on-equal. deployedBaseline = undefined const templatePath = page.url.searchParams.get('template') @@ -290,6 +292,7 @@ // `no_deployed` — no baseline to be older than. draftSavedAt = backendApp.draft_saved_at as string | undefined deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined) + deployedBy = backendApp.no_deployed ? undefined : (backendApp.created_by as string | undefined) // The app_version the draft forked from; undefined for a draft never forked // from a deploy. Head = the last entry of the deployed `versions`. draftBaseVersion = backendApp.draft_base @@ -461,6 +464,14 @@ {deployedAt} {draftBaseVersion} {deployedHeadVersion} + {deployedBy} + onTakeLatest={async () => { + const head = deployedHeadVersion != null ? Number(deployedHeadVersion) : undefined + if (!app?.value || head == null || !$workspaceStore) return + ;(app.value as App).parent_version = head + draftBaseVersion = String(head) + await UserDraft.forcePersist('app', path, { workspace: $workspaceStore }) + }} onLoadLatestDeploy={async () => { if (!$workspaceStore) return await runResetToDeployed({ 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 fe9018d955..87717b405d 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 @@ -47,6 +47,9 @@ /** User-typed path the home list renders, set only when it differs * from the deployed/seeded `savedApp.path`. */ draft_path?: string + /** The app_version the draft forked from; the server derives `draft.base` + * from it. */ + parent_version?: number } let files: Record | undefined = $state(undefined) @@ -173,6 +176,7 @@ let parentVersion = $state(undefined) let draftBaseVersion = $state(undefined) let deployedHeadVersion = $state(undefined) + let deployedBy = $state(undefined) async function loadApp(opts: { getDraft?: boolean } = {}): Promise { const getDraft = opts.getDraft ?? true const tok = ++loadAppToken @@ -196,6 +200,7 @@ parentVersion = undefined draftBaseVersion = undefined deployedHeadVersion = undefined + deployedBy = 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. @@ -315,6 +320,7 @@ // See /apps/edit's loader. draftSavedAt = backendApp.draft_saved_at as string | undefined deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined) + deployedBy = backendApp.no_deployed ? undefined : (backendApp.created_by 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. @@ -575,6 +581,15 @@ {deployedAt} {draftBaseVersion} {deployedHeadVersion} + {deployedBy} + onTakeLatest={() => { + const head = deployedHeadVersion != null ? Number(deployedHeadVersion) : undefined + if (head == null) return + // The bundle carries `parentVersion`, so this alone re-persists the draft. + parentVersion = head + if (deployedBaseline) deployedBaseline = { ...deployedBaseline, parent_version: head } + draftBaseVersion = String(head) + }} onViewDiff={() => rawAppEditor?.openDiffDrawer()} onLoadLatestDeploy={async () => { // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. 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 de0b831453..8fc466af2f 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -59,6 +59,7 @@ // The flow_version the draft was forked from (pinned, doesn't drift), for the // precise staleness check in DraftEditorModals + FlowBuilder's deploy guard. let draftBaseVersion = $state(undefined) + let deployedBy = $state(undefined) // Editor-displayed path; defaults to the URL path. Cleared to '' in the // `new_draft` branch so the Path widget's `initPath` seeds the friendly name. let flowInitialPath = $state(page.params.path ?? '') @@ -163,6 +164,7 @@ // bleed across the reused route and falsely trip the stale-draft modal. version = undefined draftBaseVersion = undefined + deployedBy = undefined // Brand-new flow: no deployed baseline, so never discard-on-equal. deployedBaseline = undefined // Suspend autosave around the bootstrap cascade: the Path widget's @@ -372,6 +374,7 @@ // is the deploy time (from `flow_version.created_at`), `draft_saved_at` the draft's. draftSavedAt = backendFlow.draft_saved_at as string | undefined deployedAt = backendFlow.edited_at as string | undefined + deployedBy = backendFlow.edited_by as string | undefined // Layer the draft (`.draft`, if any) over the deployed payload at the field // level. See /scripts/edit's loader for the rationale. const { draft: draftFromBackend, ...deployedFlow } = backendFlow as any @@ -524,6 +527,14 @@ {deployedAt} {draftBaseVersion} deployedHeadVersion={version != null ? String(version) : undefined} + {deployedBy} + onTakeLatest={async () => { + const head = version + if (!draftSync.draft || head == null || !$workspaceStore) return + draftSync.draft = { ...draftSync.draft, version_id: head } + draftBaseVersion = String(head) + await UserDraft.forcePersist('flow', flowDraftPath, { workspace: $workspaceStore }) + }} onViewDiff={() => flowBuilder?.openDiffDrawer()} onBeforeRelocate={() => flowBuilder?.saveDraft()} onLoadLatestDeploy={async () => { 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 98fc9227c2..4708390cdf 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -111,6 +111,7 @@ * equivalent of the flow/app version pair. Behind ⇔ the two differ. */ let draftBaseHash = $state(undefined) let deployedHeadHash = $state(undefined) + let deployedBy = $state(undefined) // Remounts ScriptBuilder on nav: false while a reload runs, true once data is // ready. A synchronous `{#key}` swap instead races Monaco's init against the @@ -157,6 +158,7 @@ deployedAt = undefined draftBaseHash = undefined deployedHeadHash = undefined + deployedBy = undefined // Brand-new script: no deployed baseline, so never discard-on-equal. deployedBaseline = undefined const templatePath = page.url.searchParams.get('template') @@ -329,6 +331,7 @@ // `created_at` is the latest deploy, `draft_saved_at` the draft's save. draftSavedAt = backendScript.draft_saved_at as string | undefined deployedAt = backendScript.created_at as string | undefined + deployedBy = backendScript.created_by as string | undefined // Layer the draft (`.draft`, if any) over the deployed payload at the // field level: the draft supplies editor state (content, summary, …), // the deployed supplies metadata it lacks (hash, version markers). @@ -485,6 +488,17 @@ {deployedAt} draftBaseVersion={draftBaseHash} deployedHeadVersion={deployedHeadHash} + {deployedBy} + onTakeLatest={async () => { + const head = deployedHeadHash + if (!draftSync.draft || !head || !$workspaceStore) return + draftSync.draft = { ...draftSync.draft, parent_hash: head } + // The baseline mirrors the draft's base so an unedited draft still + // compares equal and the autosave can discard it. + if (deployedBaseline) deployedBaseline = { ...deployedBaseline, parent_hash: head } + draftBaseHash = head + await UserDraft.forcePersist('script', draftPath, { workspace: $workspaceStore }) + }} onViewDiff={() => scriptBuilder?.openDiffDrawer()} onBeforeRelocate={() => scriptBuilder?.saveDraft()} onLoadLatestDeploy={async () => {