mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: the out-of-date prompt names both versions and can take the latest as the new base
The prompt now says which version the draft forked from and which is deployed (and by whom), instead of two timestamps, and gains "Take latest, keep my edits": the draft's base moves to the head and its content stays, so the user can acknowledge a newer version without discarding their work. Each route sets its kind's base field on the draft value and persists it; the raw-app bundle carries it already, so setting the state is enough there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ab296e59c1
commit
ceae36a85b
@@ -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<void>
|
||||
/** 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<void>
|
||||
/** 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}
|
||||
<StaleDraftModal
|
||||
bind:isOpen={staleModalOpen}
|
||||
{itemKind}
|
||||
{draftSavedAt}
|
||||
{deployedAt}
|
||||
{draftBaseVersion}
|
||||
{deployedHeadVersion}
|
||||
{deployedBy}
|
||||
{onLoadLatestDeploy}
|
||||
{onViewDiff}
|
||||
{onTakeLatest}
|
||||
/>
|
||||
{/if}
|
||||
<ConfirmationModal
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 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<void>
|
||||
/** 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<void>
|
||||
}
|
||||
|
||||
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 @@
|
||||
<div class="flex gap-3 items-start">
|
||||
<AlertTriangle size={20} class="text-amber-500 shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1 text-sm text-primary">
|
||||
<p>
|
||||
A newer version was deployed after you started editing. Your draft is based on the older
|
||||
deploy.
|
||||
</p>
|
||||
<p class="text-xs text-secondary">
|
||||
Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)}
|
||||
</p>
|
||||
<p>A newer version was deployed after you started editing.</p>
|
||||
{#if draftBaseVersion && deployedHeadVersion}
|
||||
<p class="text-xs text-secondary">
|
||||
Your draft is based on <span class="font-mono">{formatVersion(draftBaseVersion)}</span>
|
||||
· latest is <span class="font-mono">{formatVersion(deployedHeadVersion)}</span>
|
||||
{#if deployedBy}by {deployedBy}{/if}{#if deployedAt}, {formatTs(deployedAt)}{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary">
|
||||
Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,6 +146,11 @@
|
||||
<Button variant="default" unifiedSize="sm" on:click={() => (isOpen = false)}>
|
||||
Keep editing my draft
|
||||
</Button>
|
||||
{#if onTakeLatest}
|
||||
<Button variant="default" unifiedSize="sm" loading={takingLatest} on:click={takeLatest}>
|
||||
Take latest, keep my edits
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="accent" unifiedSize="sm" {loading} on:click={loadLatestDeploy}>
|
||||
Load latest deploy
|
||||
</Button>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
// the precise staleness check in DraftEditorModals (vs the drifting timestamp).
|
||||
let draftBaseVersion = $state<string | undefined>(undefined)
|
||||
let deployedHeadVersion = $state<string | undefined>(undefined)
|
||||
let deployedBy = $state<string | undefined>(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({
|
||||
|
||||
@@ -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<string, string> | undefined = $state(undefined)
|
||||
@@ -173,6 +176,7 @@
|
||||
let parentVersion = $state<number | undefined>(undefined)
|
||||
let draftBaseVersion = $state<string | undefined>(undefined)
|
||||
let deployedHeadVersion = $state<string | undefined>(undefined)
|
||||
let deployedBy = $state<string | undefined>(undefined)
|
||||
async function loadApp(opts: { getDraft?: boolean } = {}): Promise<void> {
|
||||
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.
|
||||
|
||||
@@ -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<string | undefined>(undefined)
|
||||
let deployedBy = $state<string | undefined>(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 () => {
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
* equivalent of the flow/app version pair. Behind ⇔ the two differ. */
|
||||
let draftBaseHash = $state<string | undefined>(undefined)
|
||||
let deployedHeadHash = $state<string | undefined>(undefined)
|
||||
let deployedBy = $state<string | undefined>(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 () => {
|
||||
|
||||
Reference in New Issue
Block a user