mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
fix: unify draft path on content path, drop redundant draft_path
Editors used to store a renamed draft's target path in a separate
`draft_path` field (flows/apps/raw-apps) while keeping the deployed path in
the content's own `path`. This was redundant — the original/deployed path is
the URL — and it broke the "path already used" guard: editors passed the
renamed `draft_path` as the Path widget's `initialPath`, so reverting a draft
back to the original path falsely tripped "path already used".
Now every editor binds the Path widget to the content's own `path` (like the
script editor already did), and `initialPath` is always the deployed/original
path. A never-deployed draft seeds the baseline from its own friendly path so
the synthetic `draft_{uuid}` storage key isn't flagged as a rename.
The computed `draft_path` field on list/draft API responses is kept (it's how
the home/Review/Drafts lists render the friendly name) but is now sourced
uniformly from `value->>'path'` for all kinds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5508f1da9c
commit
41b62efd6c
@@ -269,11 +269,11 @@ async fn list_flows(
|
||||
for row in draft_only_rows {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
|
||||
// The Path widget binds `$pathStore` one-way (`flow.path → $pathStore`),
|
||||
// so the editor writes a separate `draft_path` field only when the typed
|
||||
// path differs from the deployed one. `None` = unchanged.
|
||||
// The Path widget binds the flow's own `path`, so the draft JSON's
|
||||
// `path` IS the user-typed target. Surface it when it differs from
|
||||
// the storage path. `None` = unchanged.
|
||||
let draft_path = v
|
||||
.get("draft_path")
|
||||
.get("path")
|
||||
.and_then(|s| s.as_str())
|
||||
.filter(|s| !s.is_empty() && *s != row.path.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
@@ -12212,7 +12212,7 @@
|
||||
},
|
||||
"draft_path": {
|
||||
"type": "string",
|
||||
"description": "User-typed friendly path from the draft JSON's `draft_path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`)."
|
||||
"description": "User-typed friendly path from the draft JSON's own `path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`)."
|
||||
},
|
||||
"draft_only": {
|
||||
"type": "boolean",
|
||||
@@ -14994,7 +14994,7 @@
|
||||
},
|
||||
"draft_path": {
|
||||
"type": "string",
|
||||
"description": "User-typed path the editor has staged but not\nyet deployed. Sourced from the draft JSON's\n`draft_path` field (the editor only writes it\nwhen the typed path differs from the deployed\none). Lets the home list render the meaningful\nname instead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted when\nunchanged.\n"
|
||||
"description": "User-typed path the editor has staged but not\nyet deployed. Sourced from the draft JSON's own `path`\n(the editor only writes it\nwhen the typed path differs from the deployed\none). Lets the home list render the meaningful\nname instead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted when\nunchanged.\n"
|
||||
},
|
||||
"draft_users": {
|
||||
"description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n",
|
||||
@@ -43207,7 +43207,7 @@
|
||||
},
|
||||
"draft_path": {
|
||||
"type": "string",
|
||||
"description": "User-typed path the editor has staged but not yet deployed.\nSourced from the draft JSON's `draft_path` field (the editor\nonly writes it when the typed path differs from the deployed\none). Lets the home list render the meaningful name instead of\nthe autogenerated `u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n"
|
||||
"description": "User-typed path the editor has staged but not yet deployed.\nSourced from the draft JSON's own `path` (the editor\nonly writes it when the typed path differs from the deployed\none). Lets the home list render the meaningful name instead of\nthe autogenerated `u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n"
|
||||
},
|
||||
"draft_users": {
|
||||
"description": "Workspace users (including the authed user, and the legacy\nNULL-email row if any) who have a per-user draft at this\npath. Drives the home page's user-avatar circles inside the\nDraft badge. Omitted when no drafts exist.\n",
|
||||
|
||||
@@ -12761,8 +12761,8 @@ paths:
|
||||
draft_path:
|
||||
type: string
|
||||
description: >-
|
||||
User-typed friendly path from the draft JSON's
|
||||
`draft_path`, when set and different from the storage
|
||||
User-typed friendly path from the draft JSON's own
|
||||
`path`, when set and different from the storage
|
||||
path (e.g. a never-deployed item parked at
|
||||
`u/{user}/draft_{uuid}`).
|
||||
draft_only:
|
||||
@@ -15562,8 +15562,8 @@ paths:
|
||||
type: string
|
||||
description: |
|
||||
User-typed path the editor has staged but not
|
||||
yet deployed. Sourced from the draft JSON's
|
||||
`draft_path` field (the editor only writes it
|
||||
yet deployed. Sourced from the draft JSON's own
|
||||
`path` (the editor only writes it
|
||||
when the typed path differs from the deployed
|
||||
one). Lets the home list render the meaningful
|
||||
name instead of the autogenerated
|
||||
@@ -16775,7 +16775,7 @@ paths:
|
||||
User-typed path the editor has staged but not yet
|
||||
deployed.
|
||||
|
||||
Sourced from the draft JSON's `draft_path` field (the
|
||||
Sourced from the draft JSON's own `path` (the
|
||||
editor
|
||||
|
||||
only writes it when the typed path differs from the
|
||||
|
||||
@@ -7934,7 +7934,7 @@ paths:
|
||||
description: Best-effort, read from the draft JSON's `summary` field when the editor shape carries one.
|
||||
draft_path:
|
||||
type: string
|
||||
description: User-typed friendly path from the draft JSON's `draft_path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`).
|
||||
description: User-typed friendly path from the draft JSON's own `path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`).
|
||||
draft_only:
|
||||
type: boolean
|
||||
description: No deployed counterpart exists at this path — the draft is the whole item.
|
||||
@@ -9702,13 +9702,12 @@ paths:
|
||||
type: string
|
||||
description: |
|
||||
User-typed path the editor has staged but not
|
||||
yet deployed. Sourced from the draft JSON's
|
||||
`draft_path` field (the editor only writes it
|
||||
when the typed path differs from the deployed
|
||||
one). Lets the home list render the meaningful
|
||||
name instead of the autogenerated
|
||||
`u/{user}/draft_{uuid}` URL path. Omitted when
|
||||
unchanged.
|
||||
yet deployed. Sourced from the draft JSON's own
|
||||
`path` (the editor only writes it when the typed
|
||||
path differs from the deployed one). Lets the home
|
||||
list render the meaningful name instead of the
|
||||
autogenerated `u/{user}/draft_{uuid}` URL path.
|
||||
Omitted when unchanged.
|
||||
draft_users:
|
||||
description: |
|
||||
Workspace users (including the authed user, and
|
||||
@@ -27400,10 +27399,10 @@ components:
|
||||
type: string
|
||||
description: |
|
||||
User-typed path the editor has staged but not yet deployed.
|
||||
Sourced from the draft JSON's `draft_path` field (the editor
|
||||
only writes it when the typed path differs from the deployed
|
||||
one). Lets the home list render the meaningful name instead of
|
||||
the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted
|
||||
Sourced from the draft JSON's own `path` (the editor only writes
|
||||
it when the typed path differs from the deployed one). Lets the
|
||||
home list render the meaningful name instead of the autogenerated
|
||||
`u/{user}/draft_{uuid}` URL path. Omitted
|
||||
when unchanged.
|
||||
draft_users:
|
||||
description: |
|
||||
|
||||
@@ -171,7 +171,7 @@ pub struct ListableApp {
|
||||
/// over the deployed row). See ListableScript in windmill-types/src/scripts.rs.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub is_draft: bool,
|
||||
/// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged.
|
||||
/// User-typed staged path from the draft JSON's own `path`; `None` = unchanged.
|
||||
/// See ListableScript in windmill-types/src/scripts.rs.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -505,10 +505,10 @@ async fn list_apps(
|
||||
for row in draft_only_rows {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
|
||||
// App/raw-app drafts are the bare editor value with no `path`, so the editor
|
||||
// writes a separate `draft_path` only when it differs from deployed; see flows.rs.
|
||||
// App/raw-app drafts carry the user-typed path in the value's own
|
||||
// `path`; surface it when it differs from the storage path. See flows.rs.
|
||||
let draft_path = v
|
||||
.get("draft_path")
|
||||
.get("path")
|
||||
.and_then(|s| s.as_str())
|
||||
.filter(|s| !s.is_empty() && *s != row.path.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
@@ -36,11 +36,11 @@ pub struct DraftListItem {
|
||||
/// Best-effort, read from the draft JSON's `summary` field when present.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
/// User-typed friendly path read from the draft JSON's `draft_path` (set by
|
||||
/// the editors when it differs from the storage path, e.g. a never-deployed
|
||||
/// item parked at `u/{user}/draft_{uuid}`). `None` when absent. Lets the
|
||||
/// review page show the friendly name instead of the storage path, like the
|
||||
/// home-page list endpoints.
|
||||
/// User-typed friendly path read from the draft JSON's own `path`, when it
|
||||
/// differs from the storage path (e.g. a never-deployed item parked at
|
||||
/// `u/{user}/draft_{uuid}`). `None` when absent. Lets the review page show
|
||||
/// the friendly name instead of the storage path, like the home-page list
|
||||
/// endpoints.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_path: Option<String>,
|
||||
/// No deployed counterpart exists at this path — the draft is the whole
|
||||
@@ -195,19 +195,12 @@ fn list_drafts_query(all_users: bool) -> String {
|
||||
d.created_at,
|
||||
d.value ->> 'summary' AS summary,
|
||||
{draft_users} AS draft_users,
|
||||
-- Friendly typed path, by kind (mirrors the home-page list
|
||||
-- endpoints): scripts bind the Path widget to `script.path`,
|
||||
-- so it round-trips through the draft JSON's own `path`;
|
||||
-- flows/apps/raw-apps carry a separate `draft_path`. NULLIF
|
||||
-- drops it when empty or equal to the storage path.
|
||||
NULLIF(
|
||||
NULLIF(
|
||||
CASE WHEN d.typ::text = 'script'
|
||||
THEN d.value ->> 'path'
|
||||
ELSE d.value ->> 'draft_path' END,
|
||||
''),
|
||||
d.path
|
||||
) AS draft_path,
|
||||
-- Friendly typed path: every editor binds the Path widget to
|
||||
-- the content's own `path`, so the draft JSON's `path` IS the
|
||||
-- user-typed target (a never-deployed item is parked at a
|
||||
-- synthetic draft storage key). NULLIF drops it when empty or
|
||||
-- equal to the storage path.
|
||||
NULLIF(NULLIF(d.value ->> 'path', ''), d.path) AS draft_path,
|
||||
(d.email IS NULL) AS legacy_draft,
|
||||
(d.email = $2 OR d.email IS NULL) AS mine,
|
||||
{case} AS draft_only
|
||||
|
||||
@@ -97,7 +97,7 @@ pub struct ListableFlow {
|
||||
/// over the deployed row). See ListableScript in scripts.rs.
|
||||
#[serde(default)]
|
||||
pub is_draft: bool,
|
||||
/// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged.
|
||||
/// User-typed staged path from the draft JSON's own `path`; `None` = unchanged.
|
||||
/// See ListableScript in scripts.rs.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -470,9 +470,9 @@ pub struct ListableScript {
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub is_draft: bool,
|
||||
/// User-typed staged path, so the home list shows a meaningful name over the
|
||||
/// autogenerated `u/{user}/draft_{uuid}`. Sourced from the draft JSON: scripts use
|
||||
/// `value.path` (the Path widget binds `script.path`); flows/apps/raw apps use an
|
||||
/// explicit `value.draft_path` written only when it differs from deployed. `None` = unchanged.
|
||||
/// autogenerated `u/{user}/draft_{uuid}`. Every editor binds the Path widget to
|
||||
/// the content's own `path`, so it's sourced from the draft JSON's `value.path`
|
||||
/// when it differs from the storage path. `None` = unchanged.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_path: Option<String>,
|
||||
|
||||
@@ -938,25 +938,21 @@
|
||||
if (p) untrack(() => ($pathStore = p))
|
||||
})
|
||||
|
||||
// Persist the user-typed path into the draft JSON as `draft_path`
|
||||
// when it differs from the deployed/seeded `flow.path`. The Path
|
||||
// widget binds `$pathStore` one-way to the popover input — without
|
||||
// this, the friendly auto-name on `/flows/add` and any in-place
|
||||
// rename never reach the autosaved Flow, so the home-list draft row
|
||||
// kept showing the autogenerated `u/{user}/draft_{uuid}` slot. Drop
|
||||
// the field once it matches the baseline again so it doesn't
|
||||
// linger after a revert; deploy clears the whole draft, so the
|
||||
// field naturally disappears post-deploy too.
|
||||
// Mirror the user-typed path into the autosaved Flow's own `path`. The Path
|
||||
// widget binds `$pathStore` (the popover input); the effect above seeds it
|
||||
// one-way from `flow.path`, and this one writes edits back — so `flow.path`
|
||||
// IS the typed target (like the script editor's `script.path`). Without it,
|
||||
// the friendly auto-name on `/flows/add` and any in-place rename never reach
|
||||
// the autosaved Flow, so the home-list draft row keeps showing the
|
||||
// autogenerated `u/{user}/draft_{uuid}` slot. The deployed/original path is
|
||||
// `initialPath` (the URL), not `flow.path`. Deploy clears the whole draft.
|
||||
$effect(() => {
|
||||
const typed = $pathStore
|
||||
const baseline = (flowStore.val as Flow | undefined)?.path ?? ''
|
||||
const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined
|
||||
const flow = flowStore.val as Flow | undefined
|
||||
if (!flow) return
|
||||
untrack(() => {
|
||||
if (typed && typed !== baseline) {
|
||||
flow.draft_path = typed
|
||||
} else if (flow.draft_path !== undefined) {
|
||||
delete (flow as any).draft_path
|
||||
if (typed != undefined && typed !== '' && flow.path !== typed) {
|
||||
flow.path = typed
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
}
|
||||
|
||||
// Stable per-user draft storage key. Captured once so editing the flow's path
|
||||
// (which lives in `draft_path`, not the storage key) can't re-key the autosave
|
||||
// handle and orphan the draft. Mirrors the full-page editor keying on the URL
|
||||
// path; falls back through the SDK's path inputs.
|
||||
// (the typed name lives in the flow value's own `path`, not the storage key)
|
||||
// can't re-key the autosave handle and orphan the draft. Mirrors the full-page
|
||||
// editor keying on the URL path; falls back through the SDK's path inputs.
|
||||
const draftStoragePath = untrack(
|
||||
() =>
|
||||
props.initialPath ||
|
||||
|
||||
@@ -176,19 +176,23 @@
|
||||
darkMode
|
||||
} = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
// Mirror the user-typed path into the draft as `draft_path` when it differs
|
||||
// from the baseline, so the home row shows the friendly name instead of
|
||||
// `draft_{uuid}`. Drop the field once it matches the baseline again.
|
||||
// Mirror the user-typed path into the autosaved App's own `path` when it
|
||||
// differs from the deployed baseline, so the home row shows the friendly name
|
||||
// instead of the `draft_{uuid}` storage key (and the backend reads it from
|
||||
// `value->>'path'`). Only-when-different keeps an unedited draft byte-equal to
|
||||
// the deployed value (which carries no `path`) so it still dedups/discards;
|
||||
// drop the field once it matches the baseline again. The deployed/original
|
||||
// path is the URL, not this field.
|
||||
$effect(() => {
|
||||
const typed = newEditedPath
|
||||
const baseline = savedApp?.path ?? ''
|
||||
const a = $app
|
||||
const a = $app as (App & { path?: string }) | undefined
|
||||
if (!a) return
|
||||
untrack(() => {
|
||||
if (typed && typed !== baseline) {
|
||||
a.draft_path = typed
|
||||
} else if (a.draft_path !== undefined) {
|
||||
delete a.draft_path
|
||||
a.path = typed
|
||||
} else if (a.path !== undefined) {
|
||||
delete a.path
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -197,7 +201,7 @@
|
||||
// autosave stores the bare App value, which has no summary of its own — it
|
||||
// lives in the `app` table column, set only on deploy). Without this the
|
||||
// summary is lost when reopening a draft or deploying it from the Review &
|
||||
// Deploy page. Parallels `draft_path`.
|
||||
// Deploy page. Parallels the path mirror above.
|
||||
$effect(() => {
|
||||
const s = $summary
|
||||
const a = $app
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
bind:dirty={dirtyPath}
|
||||
bind:error={pathError}
|
||||
bind:path={newEditedPath}
|
||||
initialPath={newPath}
|
||||
initialPath={newApp ? newPath : (savedApp?.path ?? newPath)}
|
||||
namePlaceholder="app"
|
||||
kind="app"
|
||||
autofocus={false}
|
||||
|
||||
@@ -200,18 +200,19 @@ export type App = {
|
||||
version?: number
|
||||
/**
|
||||
* User-typed path persisted on the autosaved App when it differs from
|
||||
* the deployed/seeded baseline. The home list renders it so a friendly
|
||||
* name shows up instead of the autogenerated `u/{user}/draft_{uuid}`
|
||||
* URL slot for renames-in-progress and brand-new drafts. Dropped from
|
||||
* the JSON once the typed path matches the baseline again, and deploy
|
||||
* clears the whole draft.
|
||||
* the deployed/seeded baseline. The home/review/Drafts lists render it (read
|
||||
* from `value->>'path'`) so a friendly name shows up instead of the
|
||||
* autogenerated `u/{user}/draft_{uuid}` storage key for renames-in-progress
|
||||
* and brand-new drafts. Dropped from the JSON once the typed path matches the
|
||||
* baseline again, and deploy clears the whole draft. The deployed/original
|
||||
* path is the URL, not this field.
|
||||
*/
|
||||
draft_path?: string
|
||||
path?: string
|
||||
/**
|
||||
* App summary persisted on the autosaved App so a draft round-trips it — the
|
||||
* autosave stores the bare App value, which otherwise drops the summary (it
|
||||
* normally lives in the `app` table column, set only on deploy). Mirrors
|
||||
* `draft_path`: draft-only metadata; the deployed summary column is
|
||||
* normally lives in the `app` table column, set only on deploy). Mirrors the
|
||||
* `path` field above: draft-only metadata; the deployed summary column is
|
||||
* authoritative, and the Review & Deploy page reads it from the draft and
|
||||
* sends it as the summary on deploy.
|
||||
*/
|
||||
|
||||
@@ -103,8 +103,8 @@
|
||||
* still toggle the mode after mount; this prop only seeds the
|
||||
* initial state. */
|
||||
defaultSplitWithPreview?: boolean
|
||||
/** User-typed path when it differs from `savedApp.path`. The route injects
|
||||
* it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */
|
||||
/** User-typed path when it differs from `savedApp.path`. The route persists
|
||||
* it as the draft's own `path` so the home row shows the friendly name. */
|
||||
pendingDraftPath?: string | undefined
|
||||
// Threaded to the AutosaveIndicator's "Reset to deployed" button.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
/** 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
|
||||
* draft as `draft_path` so the home-page row can render the
|
||||
* draft as its own `path` so the home-page row can render the
|
||||
* friendly name instead of the URL's autogenerated draft slot. */
|
||||
pendingDraftPath?: string | undefined
|
||||
// Threaded to the `AutosaveIndicator` popover so its "Reset to
|
||||
|
||||
@@ -62,8 +62,9 @@
|
||||
const kind = DEPLOY_KIND_BY_DRAFT_KIND[baseKind] ?? baseKind
|
||||
donly[`${kind}/${it.path}`] = it.draft_only
|
||||
// A never-deployed app/raw_app is parked at a synthetic `…/draft_<uuid>`
|
||||
// storage path with the user's typed name in `draft_path`; show that
|
||||
// (matches the home list) while `path` stays the storage key for loading.
|
||||
// storage path; the backend surfaces the user's typed name as the
|
||||
// computed `draft_path` field — show that (matches the home list) while
|
||||
// `path` stays the storage key for loading.
|
||||
// `summary` comes straight from the draft row, so it shows for every kind
|
||||
// up front instead of only after the diff value loads.
|
||||
return {
|
||||
|
||||
@@ -79,14 +79,19 @@
|
||||
newFlow: a draft-only flow has a synthesized `savedFlow` (no_deployed=true)
|
||||
but no deployed row, so it must deploy via createFlow — treating it as
|
||||
!newFlow would updateFlow the draft_<uuid> path and 404 "Flow not found".
|
||||
initialPath: a brand-new flow is stored under a `draft_<uuid>` path with
|
||||
its intended name in `draft_path`; seed the builder from `draft_path`
|
||||
(as the full-page editor does) so the Path widget and deploy use the
|
||||
friendly name rather than creating a flow literally named draft_<uuid>. -->
|
||||
initialPath: the Path widget's rename baseline. For a deployed flow
|
||||
it's the URL/storage path (deployed/original); the draft's intended
|
||||
name rides in the flow value's own `path`, which FlowBuilder seeds into
|
||||
the Path widget — so the topbar shows the friendly name while
|
||||
`initialPath` stays the original. A never-deployed flow (parked at a
|
||||
`draft_<uuid>` key) has no original, so seed the baseline from its own
|
||||
friendly path instead of flagging the storage key as a rename. -->
|
||||
<FlowBuilder
|
||||
flowStore={runtime.flowStore}
|
||||
flowStateStore={runtime.flowStateStore}
|
||||
initialPath={(runtime.savedFlow.val as any)?.draft_path ?? path}
|
||||
initialPath={runtime.savedFlow.val?.no_deployed
|
||||
? (runtime.flowStore.val?.path ?? path)
|
||||
: path}
|
||||
autosaveWorkspace={workspaceId}
|
||||
autosavePath={path}
|
||||
newFlow={!runtime.savedFlow.val || runtime.savedFlow.val.no_deployed === true}
|
||||
|
||||
@@ -31,16 +31,16 @@
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
|
||||
// Path typed in the editor header, surfaced when it differs from the stored
|
||||
// path. Mirror it into the runtime draft as `draft_path` so the rename
|
||||
// mutates runtime.rawApp.val → the autosave sig changes → the draft is saved
|
||||
// (and the home/review/Drafts lists show the friendly name). Mirrors the
|
||||
// full-page /apps_raw/edit route.
|
||||
// path. Mirror it into the runtime draft as `typedPath` (persisted as the
|
||||
// draft's own `path`) so the rename mutates runtime.rawApp.val → the autosave
|
||||
// sig changes → the draft is saved (and the home/review/Drafts lists show the
|
||||
// friendly name). Mirrors the full-page /apps_raw/edit route.
|
||||
let pendingDraftPath = $state<string | undefined>(undefined)
|
||||
// The header collapses both "not yet bound" and "reverted to baseline" to
|
||||
// `undefined`. `surfacedDraftPath` tells them apart: the initial undefined
|
||||
// (before the header binds) must not clobber the `draft_path` seeded by
|
||||
// (before the header binds) must not clobber the `typedPath` seeded by
|
||||
// loadRawApp, but a revert/clear after a real typed path must drop the stale
|
||||
// friendly name — mirroring the script codec's `else delete draft_path`.
|
||||
// friendly name.
|
||||
let surfacedDraftPath = false
|
||||
$effect(() => {
|
||||
const dp = pendingDraftPath
|
||||
@@ -49,9 +49,9 @@
|
||||
if (!val) return
|
||||
if (dp !== undefined) {
|
||||
surfacedDraftPath = true
|
||||
if (val.draft_path !== dp) val.draft_path = dp
|
||||
} else if (surfacedDraftPath && val.draft_path !== undefined) {
|
||||
val.draft_path = undefined
|
||||
if (val.typedPath !== dp) val.typedPath = dp
|
||||
} else if (surfacedDraftPath && val.typedPath !== undefined) {
|
||||
val.typedPath = undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -104,7 +104,7 @@
|
||||
bind:data={runtime.rawApp.val.data}
|
||||
bind:summary={runtime.rawApp.val.summary}
|
||||
bind:pendingDraftPath
|
||||
newPath={runtime.rawApp.val.draft_path ?? runtime.rawApp.val.path}
|
||||
newPath={runtime.rawApp.val.typedPath ?? runtime.rawApp.val.path}
|
||||
{path}
|
||||
autosaveWorkspace={workspaceId}
|
||||
autosavePath={path}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
function buildCodec(): DraftSyncCodec<any> {
|
||||
if (kind === 'flow') return makeFlowCodec(runtime)
|
||||
if (kind === 'script') return makeScriptCodec(runtime, () => path)
|
||||
if (kind === 'script') return makeScriptCodec(runtime)
|
||||
return makeRawAppCodec(runtime)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,21 +60,26 @@ describe('appDraftCodec — custom_path round-trip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('appDraftCodec — draft_path round-trip', () => {
|
||||
it('serializes draft_path so a path edit changes the draft (and its sig)', () => {
|
||||
const draft = runtimeRawAppToDraft(runtime({ draft_path: 'u/admin/friendly' }))
|
||||
expect(draft.draft_path).toBe('u/admin/friendly')
|
||||
// The autosave keys on JSON.stringify(draft); without draft_path a rename
|
||||
describe('appDraftCodec — typed path round-trip', () => {
|
||||
it('serializes the typed path as the draft `path` so an edit changes the draft (and its sig)', () => {
|
||||
const draft = runtimeRawAppToDraft(runtime({ typedPath: 'u/admin/friendly' }))
|
||||
expect(draft.path).toBe('u/admin/friendly')
|
||||
// The autosave keys on JSON.stringify(draft); without the path a rename
|
||||
// would be invisible and never persist.
|
||||
expect(JSON.stringify(draft)).toContain('u/admin/friendly')
|
||||
})
|
||||
|
||||
it('omits the draft `path` when there is no rename', () => {
|
||||
const draft = runtimeRawAppToDraft(runtime({ typedPath: undefined }))
|
||||
expect(draft.path).toBeUndefined()
|
||||
})
|
||||
|
||||
it('survives a full runtime → draft → runtime round-trip', () => {
|
||||
const original = runtime({ draft_path: 'u/admin/renamed' })
|
||||
const original = runtime({ typedPath: 'u/admin/renamed' })
|
||||
const back = applyDraftToRuntimeRawApp(
|
||||
runtime({ draft_path: undefined }),
|
||||
runtime({ typedPath: undefined }),
|
||||
runtimeRawAppToDraft(original)
|
||||
)
|
||||
expect(back.draft_path).toBe('u/admin/renamed')
|
||||
expect(back.typedPath).toBe('u/admin/renamed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,16 +11,18 @@ export type RawAppDraft = {
|
||||
summary: string
|
||||
policy?: any
|
||||
custom_path?: string
|
||||
// User-typed path while the app is parked at a `…/draft_<uuid>` storage path.
|
||||
// Must round-trip through the draft so the home/review/Drafts lists render the
|
||||
// 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
|
||||
// User-typed path while the app is parked at a `…/draft_<uuid>` storage key.
|
||||
// Round-trips through the draft as its own `path` so the home/review/Drafts
|
||||
// lists render the friendly name (they read `value->>'path'`) — and so editing
|
||||
// the path in the editor changes the persisted draft and triggers an autosave.
|
||||
// Present only on a rename; the storage key is the URL, not this field.
|
||||
path?: string
|
||||
}
|
||||
|
||||
// The shape `runtime.rawApp.val` actually holds (see SessionRuntime in
|
||||
// sessionRuntime.svelte.ts). Adds `path` (a key, not a draft field) and
|
||||
// makes `policy` required for the editor's live binding.
|
||||
// sessionRuntime.svelte.ts). `path` is the storage key (a runtime-only field,
|
||||
// the session editor target); `typedPath` is the user-typed rename, set only
|
||||
// when it differs from the deployed path and persisted as the draft's own `path`.
|
||||
export type RuntimeRawApp = {
|
||||
summary: string
|
||||
path: string
|
||||
@@ -29,12 +31,13 @@ export type RuntimeRawApp = {
|
||||
data: RawAppData
|
||||
policy: any
|
||||
custom_path?: string
|
||||
draft_path?: string
|
||||
typedPath?: string
|
||||
}
|
||||
|
||||
// Strip runtime-only metadata (just `path`, the storage key) when persisting
|
||||
// to UserDraft. `custom_path` is a real draft field and must round-trip — else
|
||||
// session sync erases a draft's custom URL.
|
||||
// Strip runtime-only metadata (the storage-key `path`) when persisting to
|
||||
// UserDraft, and surface the user-typed rename under the draft's own `path` so
|
||||
// the backend lists read it from `value->>'path'`. `custom_path` is a real draft
|
||||
// field and must round-trip — else session sync erases a draft's custom URL.
|
||||
export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft {
|
||||
return {
|
||||
summary: raw.summary,
|
||||
@@ -43,12 +46,13 @@ export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft {
|
||||
data: raw.data,
|
||||
policy: raw.policy,
|
||||
custom_path: raw.custom_path,
|
||||
draft_path: raw.draft_path
|
||||
...(raw.typedPath ? { path: raw.typedPath } : {})
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay a UserDraft-stored raw-app draft onto an existing runtime raw app,
|
||||
// preserving the runtime-only `path` field.
|
||||
// preserving the runtime-only storage-key `path` and mapping the draft's `path`
|
||||
// (the typed rename) back onto `typedPath`.
|
||||
export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft): RuntimeRawApp {
|
||||
return {
|
||||
...raw,
|
||||
@@ -58,6 +62,6 @@ 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
|
||||
typedPath: dv.path ?? raw.typedPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,8 @@ describe('flowDraftSig', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('changes when only draft_path changes (path rename triggers a save)', () => {
|
||||
const base = { value: { modules: [] }, summary: 's', draft_path: 'u/admin/draft_abc' }
|
||||
const renamed = { ...base, draft_path: 'u/admin/friendly' }
|
||||
expect(flowDraftSig(base)).not.toBe(flowDraftSig(renamed))
|
||||
})
|
||||
|
||||
it('changes when only path changes', () => {
|
||||
const base = { value: { modules: [] }, summary: 's', path: 'u/admin/a' }
|
||||
expect(flowDraftSig(base)).not.toBe(flowDraftSig({ ...base, path: 'u/admin/b' }))
|
||||
it('changes when only path changes (a rename triggers a save)', () => {
|
||||
const base = { value: { modules: [] }, summary: 's', path: 'u/admin/draft_abc' }
|
||||
expect(flowDraftSig(base)).not.toBe(flowDraftSig({ ...base, path: 'u/admin/friendly' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,24 +4,22 @@
|
||||
// this signature to skip no-op work. It MUST include every top-level field the
|
||||
// editor can change on its own — `summary`, `description`, and the path —
|
||||
// otherwise a change to only that field produces an identical signature and
|
||||
// never propagates or persists. The Path widget writes a rename into
|
||||
// `draft_path` (FlowBuilder mirrors the typed path there while the flow is
|
||||
// parked at its `…/draft_<uuid>` storage `path`); without it here the rename
|
||||
// moves no signature and the draft is never saved.
|
||||
// never propagates or persists. FlowBuilder mirrors the Path widget's typed
|
||||
// path into the flow's own `path` (the flow is parked at its `…/draft_<uuid>`
|
||||
// storage key); without `path` here a rename moves no signature and the draft
|
||||
// is never saved.
|
||||
export function flowDraftSig(x: {
|
||||
value?: unknown
|
||||
schema?: unknown
|
||||
summary?: unknown
|
||||
description?: unknown
|
||||
path?: unknown
|
||||
draft_path?: unknown
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
value: x.value,
|
||||
schema: x.schema,
|
||||
summary: x.summary,
|
||||
description: x.description,
|
||||
path: x.path,
|
||||
draft_path: x.draft_path
|
||||
path: x.path
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,31 +16,18 @@ function runtimeWith(script: Partial<NewScript> & { path: string }): SessionRunt
|
||||
|
||||
const STORAGE = 'u/admin/draft_abc'
|
||||
|
||||
describe('makeScriptCodec — draft_path (path rename)', () => {
|
||||
it('writes draft_path when the typed path differs from the storage key', () => {
|
||||
describe('makeScriptCodec — path rename', () => {
|
||||
it('carries the typed path on the draft so the lists show the friendly name', () => {
|
||||
const codec = makeScriptCodec(
|
||||
runtimeWith({ path: 'u/admin/friendly', content: 'c', summary: 's' }),
|
||||
() => STORAGE
|
||||
runtimeWith({ path: 'u/admin/friendly', content: 'c', summary: 's' })
|
||||
)
|
||||
const draft = codec.storeToDraft(undefined) as (NewScript & { draft_path?: string }) | undefined
|
||||
expect(draft?.draft_path).toBe('u/admin/friendly')
|
||||
})
|
||||
|
||||
it('drops draft_path when the typed path equals the storage key', () => {
|
||||
const codec = makeScriptCodec(
|
||||
runtimeWith({ path: STORAGE, content: 'c', summary: 's' }),
|
||||
() => STORAGE
|
||||
)
|
||||
const draft = codec.storeToDraft(undefined) as (NewScript & { draft_path?: string }) | undefined
|
||||
expect(draft?.draft_path).toBeUndefined()
|
||||
const draft = codec.storeToDraft(undefined)
|
||||
expect(draft?.path).toBe('u/admin/friendly')
|
||||
})
|
||||
|
||||
it('signature changes on a rename, so the outbound sync persists it', () => {
|
||||
const before = makeScriptCodec(runtimeWith({ path: STORAGE, content: 'c' }), () => STORAGE)
|
||||
const after = makeScriptCodec(
|
||||
runtimeWith({ path: 'u/admin/renamed', content: 'c' }),
|
||||
() => STORAGE
|
||||
)
|
||||
const before = makeScriptCodec(runtimeWith({ path: STORAGE, content: 'c' }))
|
||||
const after = makeScriptCodec(runtimeWith({ path: 'u/admin/renamed', content: 'c' }))
|
||||
expect(before.sig(before.storeToDraft(undefined)!)).not.toBe(
|
||||
after.sig(after.storeToDraft(undefined)!)
|
||||
)
|
||||
|
||||
@@ -35,31 +35,25 @@ export function makeFlowCodec(runtime: SessionRuntime): DraftSyncCodec<Flow> {
|
||||
}
|
||||
}
|
||||
|
||||
// `NewScript` has no `draft_path` of its own; the session editor parks a rename
|
||||
// there so the home/Drafts lists (which read `draft_path`) show the typed name.
|
||||
type ScriptDraft = NewScript & { draft_path?: string }
|
||||
type ScriptDraft = NewScript
|
||||
|
||||
export function makeScriptCodec(
|
||||
runtime: SessionRuntime,
|
||||
// The draft's storage key (the URL path). A never-deployed script is parked
|
||||
// here at `…/draft_<uuid>` while the user's typed name lives in `script.path`.
|
||||
storagePath: () => string
|
||||
): DraftSyncCodec<ScriptDraft> {
|
||||
export function makeScriptCodec(runtime: SessionRuntime): DraftSyncCodec<ScriptDraft> {
|
||||
return {
|
||||
itemKind: 'script',
|
||||
// Must include every field write_script can set — not just content —
|
||||
// else a summary-only/language-only change yields an identical signature
|
||||
// and the inbound/outbound sync skips it (the chat's change is then
|
||||
// invisible in the open editor and clobbered by the next content save).
|
||||
// `draft_path` carries a rename: ScriptBuilder binds the Path widget
|
||||
// straight to `script.path` (no separate draft field like flow/raw_app),
|
||||
// so without it here a rename moves no signature and never autosaves.
|
||||
// `path` carries a rename: ScriptBuilder binds the Path widget straight to
|
||||
// `script.path`, which IS the draft's path (the backend lists read it from
|
||||
// `value->>'path'`), so without it here a rename moves no signature and
|
||||
// never autosaves.
|
||||
sig: (d) =>
|
||||
JSON.stringify({
|
||||
content: d.content ?? '',
|
||||
summary: d.summary,
|
||||
language: d.language,
|
||||
draft_path: d.draft_path
|
||||
path: d.path
|
||||
}),
|
||||
debounceMs: DEBOUNCE_MS,
|
||||
applyDraftToStore(incoming) {
|
||||
@@ -74,17 +68,10 @@ export function makeScriptCodec(
|
||||
const script = runtime.scriptStore.val
|
||||
if (!script) return undefined
|
||||
// Merge over the existing entry so fields the preview doesn't edit
|
||||
// (set by the chat) survive a content-only save.
|
||||
const merged: ScriptDraft = { ...(current ?? script), ...script }
|
||||
// Surface a rename to the home/Drafts lists, which read `draft_path`
|
||||
// (the typed `script.path` is the draft *value*'s path, not its storage
|
||||
// key). Mirror flow/raw_app: set it only when the typed path differs
|
||||
// from the storage key, and drop it once it matches again so a revert
|
||||
// doesn't leave a stale friendly name behind.
|
||||
const typed = script.path
|
||||
if (typed && typed !== storagePath()) merged.draft_path = typed
|
||||
else delete merged.draft_path
|
||||
return merged
|
||||
// (set by the chat) survive a content-only save. `script.path` (the
|
||||
// user-typed name) rides along, so a rename persists and the home/Drafts
|
||||
// lists show the friendly name.
|
||||
return { ...(current ?? script), ...script }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export interface SessionRuntime {
|
||||
summary: string
|
||||
path: string
|
||||
custom_path?: string
|
||||
draft_path?: string
|
||||
typedPath?: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
@@ -451,15 +451,11 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
)
|
||||
) as NewScript)
|
||||
: {
|
||||
// Seed from the draft's own path (a rename lives in `draft_path`,
|
||||
// else `path`), not the storage key. Otherwise re-seeding a renamed
|
||||
// Seed from the draft's own `path` (the Path widget binds it
|
||||
// directly), not the storage key. Otherwise re-seeding a renamed
|
||||
// never-deployed draft (e.g. a script→script switch re-runs loadScript
|
||||
// with the draft still in memory) resets the path to `draft_<uuid>`,
|
||||
// and the next autosave drops `draft_path` — clobbering the rename.
|
||||
path:
|
||||
(aiDraft as NewScript & { draft_path?: string }).draft_path ??
|
||||
aiDraft.path ??
|
||||
path,
|
||||
// with the draft still in memory) resets the path to the storage key.
|
||||
path: aiDraft.path ?? path,
|
||||
summary: aiDraft.summary ?? '',
|
||||
content: '',
|
||||
description: '',
|
||||
@@ -610,7 +606,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
summary: draftValue?.summary ?? result.summary ?? '',
|
||||
path: result.path,
|
||||
custom_path: draftValue?.custom_path ?? result.custom_path,
|
||||
draft_path: draftValue?.draft_path
|
||||
typedPath: draftValue?.path
|
||||
}
|
||||
// Seed the per-tab last_sync from the server draft's timestamp so
|
||||
// later saves attach a matching last_sync and the server can reject
|
||||
|
||||
@@ -34,10 +34,10 @@ export async function deployRawAppDraft(
|
||||
const app = await AppService.getAppByPath({ workspace, path, getDraft: true, rawApp: true })
|
||||
const draft = (app as any).draft
|
||||
// Deploy at the draft's intended path. A raw-app draft carries the user-typed
|
||||
// path in `draft_path` (a never-deployed app is parked at a synthetic
|
||||
// `u/{user}/draft_{uuid}` storage key); the URL `path` below stays that storage
|
||||
// path in its own `path` (a never-deployed app is parked at a synthetic
|
||||
// `u/{user}/draft_{uuid}` storage key); the URL `path` arg stays that storage
|
||||
// key. Falls back to `path` for an unrenamed draft on a deployed app.
|
||||
const targetPath = draft?.draft_path ?? draft?.path ?? path
|
||||
const targetPath = draft?.path ?? path
|
||||
const value = appSourceToDraftValue(draft ?? app, app)
|
||||
|
||||
const policy = (await updateRawAppPolicy(
|
||||
|
||||
@@ -305,11 +305,10 @@ export async function deployDraft(
|
||||
const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any
|
||||
const d = r.draft ?? r
|
||||
const requestBody = {
|
||||
// Deploy at the draft's intended path: flow/app/raw-app drafts keep the
|
||||
// user-typed path in `draft_path` (a never-deployed item is parked at a
|
||||
// synthetic `u/{user}/draft_{uuid}` storage key). The URL `path` stays
|
||||
// that storage key.
|
||||
path: d.draft_path ?? d.path ?? path,
|
||||
// Deploy at the draft's intended path: every draft keeps the user-typed
|
||||
// path in its own `path` (a never-deployed item is parked at a synthetic
|
||||
// `u/{user}/draft_{uuid}` storage key, the URL `path` arg).
|
||||
path: d.path ?? path,
|
||||
summary: d.summary ?? '',
|
||||
description: d.description ?? '',
|
||||
value: d.value,
|
||||
@@ -330,24 +329,19 @@ export async function deployDraft(
|
||||
await FlowService.updateFlow({ workspace, path, requestBody })
|
||||
}
|
||||
// Then deploy any draft trigger edits, so they aren't dropped with the draft.
|
||||
await deployDraftTriggers(
|
||||
d.draft_triggers,
|
||||
workspace,
|
||||
d.draft_path ?? d.path ?? path,
|
||||
draftOnly
|
||||
)
|
||||
await deployDraftTriggers(d.draft_triggers, workspace, d.path ?? path, draftOnly)
|
||||
} else if (kind === 'app') {
|
||||
// `raw_app` is handled above; only visual apps reach here.
|
||||
const r = (await AppService.getAppByPath({ workspace, path, getDraft: true })) as any
|
||||
// A visual-app draft is stored as the *bare* app value (grid/theme/...,
|
||||
// plus a `draft_path` when the path was renamed) — NOT wrapped in
|
||||
// { value, summary, policy } like script/flow drafts. So the deploy value
|
||||
// is the draft object itself; fall back to the deployed value when there's
|
||||
// no draft. `draft_path` and `summary` are draft-only fields mirrored onto
|
||||
// the App value (the editor drops them on deploy), so strip them from the
|
||||
// value and apply them as the deploy path / summary column.
|
||||
// plus its own `path` when renamed) — NOT wrapped in { value, summary,
|
||||
// policy } like script/flow drafts. So the deploy value is the draft object
|
||||
// itself; fall back to the deployed value when there's no draft. `path` and
|
||||
// `summary` are draft-only fields mirrored onto the App value (the editor
|
||||
// drops them on deploy), so strip them from the value and apply them as the
|
||||
// deploy path / summary column.
|
||||
const draft = r.draft as Record<string, any> | undefined
|
||||
const { draft_path: draftPath, summary: draftSummary, ...appValue } = draft ?? r.value ?? {}
|
||||
const { path: draftValuePath, summary: draftSummary, ...appValue } = draft ?? r.value ?? {}
|
||||
// Policy isn't carried in the app draft, so it comes from the deployed app
|
||||
// (or a default). custom_path requires admin on update; non-admins send
|
||||
// undefined so the backend preserves the existing route. The draft has no
|
||||
@@ -357,9 +351,9 @@ export async function deployDraft(
|
||||
value: appValue,
|
||||
summary: draftSummary ?? r.summary ?? '',
|
||||
policy: r.policy ?? { execution_mode: 'publisher' },
|
||||
// Honor the draft's intended path; `draft_path` holds the user-typed path
|
||||
// for a never-deployed app parked at a `u/{user}/draft_{uuid}` storage key.
|
||||
path: draftPath ?? r.path ?? path,
|
||||
// Honor the draft's intended path; the draft's own `path` holds the
|
||||
// user-typed path for a never-deployed app parked at a synthetic storage key.
|
||||
path: draftValuePath ?? r.path ?? path,
|
||||
custom_path: isAdmin ? (r.custom_path ?? '') : undefined
|
||||
}
|
||||
// Same as flows: draft-only apps have no app row → create;
|
||||
|
||||
@@ -23,9 +23,9 @@ export interface DraftItem {
|
||||
kind: DraftKind
|
||||
path: string
|
||||
summary?: string
|
||||
/** User-typed friendly path (from the draft JSON's `draft_path`) when it
|
||||
* differs from the storage `path` — e.g. a never-deployed item parked at
|
||||
* `u/{user}/draft_{uuid}`. Display this instead of `path` when present. */
|
||||
/** User-typed friendly path (computed by the backend from the draft JSON's
|
||||
* own `path`) when it differs from the storage `path` — e.g. a never-deployed
|
||||
* item parked at `u/{user}/draft_{uuid}`. Display this instead of `path`. */
|
||||
draft_path?: string
|
||||
/** Never deployed — exists only as a draft. */
|
||||
draft_only: boolean
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
summary={app.summary}
|
||||
app={app.value}
|
||||
{deployedBaseline}
|
||||
newPath={app.value?.draft_path ?? app.path}
|
||||
newPath={app.value?.path ?? app.path}
|
||||
path={page.params.path ?? ''}
|
||||
policy={app.policy}
|
||||
bind:savedApp
|
||||
|
||||
@@ -42,9 +42,9 @@
|
||||
summary: string
|
||||
policy?: any
|
||||
custom_path?: string
|
||||
/** User-typed path the home list renders, set only when it differs
|
||||
* from the deployed/seeded `savedApp.path`. */
|
||||
draft_path?: string
|
||||
/** User-typed path the home list renders (read from `value->>'path'`), set
|
||||
* only when it differs from the deployed/seeded `savedApp.path`. */
|
||||
path?: string
|
||||
}
|
||||
|
||||
let files: Record<string, string> | undefined = $state(undefined)
|
||||
@@ -56,7 +56,7 @@
|
||||
let policy: any = $state({})
|
||||
let summary = $state('')
|
||||
/** User-typed path from `RawAppEditorHeader` when it differs from
|
||||
* `savedApp.path`; mirrored into the draft below as `draft_path` for the
|
||||
* `savedApp.path`; mirrored into the draft below as its own `path` for the
|
||||
* home list's friendly name. */
|
||||
let pendingDraftPath = $state<string | undefined>(undefined)
|
||||
|
||||
@@ -113,12 +113,12 @@
|
||||
summary,
|
||||
policy,
|
||||
custom_path: savedApp?.custom_path,
|
||||
// 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
|
||||
// Persist the typed path as the draft's own `path` only when it actually
|
||||
// differs from the current path — a `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 }
|
||||
? { path: pendingDraftPath }
|
||||
: {})
|
||||
} as RawAppDraft
|
||||
})
|
||||
@@ -135,9 +135,9 @@
|
||||
summary = app.summary
|
||||
// lastVersion = app.version
|
||||
policy = app.policy
|
||||
// Prefer the saved `draft_path` so the topbar shows the pending name, not
|
||||
// the `draft_{uuid}` URL. See /flows/edit's loader.
|
||||
newPath = (app as any).draft_path ?? app.path
|
||||
// Deployed/URL path; the loader overrides this with the draft's own typed
|
||||
// `path` afterwards so the topbar shows the pending name when renamed.
|
||||
newPath = app.path
|
||||
}
|
||||
|
||||
/** Increments per `loadApp` call. Stale loads (e.g. when picker
|
||||
@@ -273,7 +273,7 @@
|
||||
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.
|
||||
// the edit-only `path`) so an unedited draft compares equal.
|
||||
// `undefined` when there's no deployed row.
|
||||
deployedBaseline = backendApp.no_deployed
|
||||
? undefined
|
||||
@@ -300,14 +300,9 @@
|
||||
summary?: string
|
||||
policy?: any
|
||||
custom_path?: string
|
||||
draft_path?: string
|
||||
path?: string
|
||||
}
|
||||
| undefined
|
||||
// Surface the saved `draft_path` on `backendApp` so `extractRawApp` seeds
|
||||
// `newPath` with the friendly name, not the `draft_{uuid}` URL.
|
||||
if (savedRawAppDraft?.draft_path) {
|
||||
;(backendApp as any).draft_path = savedRawAppDraft.draft_path
|
||||
}
|
||||
if (backendApp.no_deployed) {
|
||||
backendApp.value = {
|
||||
files: savedRawAppDraft?.files ?? {},
|
||||
@@ -344,6 +339,10 @@
|
||||
// $effect re-mirrors them into `draftSync.draft`; the first write is
|
||||
// swallowed by `acquireEntry`'s seed guard, so no POST.
|
||||
extractRawApp(backendApp)
|
||||
// The draft's own typed `path` (set only on a rename) is the friendly name
|
||||
// the topbar shows; `extractRawApp` seeded `newPath` from the deployed/URL
|
||||
// path, so override it here. The deployed/original path stays the URL.
|
||||
if (savedRawAppDraft?.path) newPath = savedRawAppDraft.path
|
||||
// "Load another user's draft" handoff: their value is a flat RawAppDraft
|
||||
// bundle. Override the local pieces with it; overlay mode (we have our own
|
||||
// draft) hard-locks saves until the user confirms overwriting. The bundle
|
||||
@@ -363,7 +362,7 @@
|
||||
data = v.data ?? { ...DEFAULT_DATA }
|
||||
summary = v.summary ?? ''
|
||||
policy = v.policy ?? {}
|
||||
newPath = v.draft_path ?? savedApp?.path ?? path
|
||||
newPath = v.path ?? savedApp?.path ?? path
|
||||
if (hasOwnDraft) {
|
||||
OtherUserDraftLoad.beginOverlay({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -379,7 +378,7 @@
|
||||
summary,
|
||||
policy,
|
||||
custom_path: savedApp?.custom_path,
|
||||
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
|
||||
...(pendingDraftPath ? { path: pendingDraftPath } : {})
|
||||
} as RawAppDraft,
|
||||
onResetToOwnDraft: () => loadApp({ getDraft: true })
|
||||
})
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
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
|
||||
// FlowBuilder mirrors into `flow.path` — a programmatic write that
|
||||
// must not post as the user's first save. Resume on first interaction
|
||||
// (with a 5s fallback) rather than guessing the cascade's length.
|
||||
if ($workspaceStore) {
|
||||
@@ -349,16 +349,26 @@
|
||||
? ({ ...deployedFlow, ...draftFromBackend } as Flow)
|
||||
: (deployedFlow as Flow)
|
||||
savedFlow = structuredClone($state.snapshot(effectiveFlow)) as Flow
|
||||
// `savedFlow` is the deployed baseline (diff "deployed" side + restore
|
||||
// target), so its path must stay the deployed path even when the draft
|
||||
// overlay renamed `effectiveFlow.path`.
|
||||
if (!backendFlow.no_deployed) savedFlow.path = (deployedFlow as Flow).path
|
||||
// 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.
|
||||
const renderedDraftPath = (effectiveFlow as any).draft_path as string | undefined
|
||||
if (renderedDraftPath) flowInitialPath = renderedDraftPath
|
||||
// `flowInitialPath` is the Path widget's rename baseline and the
|
||||
// `updateFlow` target on deploy. For a deployed flow it stays the URL
|
||||
// (deployed/original) path, while the draft's renamed path rides in
|
||||
// `effectiveFlow.path` (seeded into the Path widget via FlowBuilder's
|
||||
// `$pathStore`) — so the topbar shows the pending name without conflating
|
||||
// it with the original. A never-deployed draft (parked at a `draft_{uuid}`
|
||||
// storage key) has no original, so seed the baseline from its own friendly
|
||||
// path: else the storage key would be flagged as a rename target.
|
||||
if (backendFlow.no_deployed) {
|
||||
flowInitialPath = (effectiveFlow.path as string) || flowInitialPath
|
||||
}
|
||||
|
||||
// "Load another user's draft" handoff: render their value over the deployed
|
||||
// metadata. Overlay mode (we have our own draft) never saves until the user
|
||||
|
||||
Reference in New Issue
Block a user