feat(drafts): render friendly user-typed path on home list for all 4 kinds

Reinstate `draft_path` on `Listable{Script,Flow,App}` so the home rows
prefer the user-typed name over the autogenerated `u/{user}/draft_{uuid}`
URL slot, with two source rules — one per how each editor wires the
Path widget:

- Scripts already work: `ScriptBuilder` binds the Path widget directly
  to `script.path`, so the typed path round-trips through the draft
  JSON's own `path` field. Backend extracts `v["path"]` when it differs
  from `row.path`.

- Flows / apps / raw apps don't write the typed path into the
  autosaved value (`Flow.path` is one-way-bound to `$pathStore`; the
  bare `App` / raw-app value has no `path` field at all). Introduce an
  explicit `draft_path` field on the draft JSON, written by the editor
  ONLY when the typed path differs from the deployed/seeded
  `savedX.path`:
  - FlowBuilder: $effect on `$pathStore` mutates `flow.draft_path`.
  - AppEditorHeader: $effect on `newEditedPath` mutates `$app.draft_path`.
  - RawAppEditorHeader: $effect surfaces `pendingDraftPath` up via the
    bind chain (RawAppEditor → route); the route's draftHandle.draft
    spread includes `draft_path` when set.
  Backend extracts `v["draft_path"]` and `None` when unchanged or after
  deploy (deploy clears the whole draft, so the field naturally
  disappears post-deploy without bookkeeping).

Flow route's `new_draft` branch now stops sync around the Path widget
cascade, with a 700ms scheduled `restartSync` (mirrors the existing
scripts/apps/raw_apps stoppers) — the new draft_path mutation lands
inside that window so `/flows/add` no longer fires an autosave before
the user's first edit. openapi/sqlx regenerated.
This commit is contained in:
Diego Imbert
2026-06-08 00:54:11 +02:00
parent 0ffcb13080
commit 47c45edd80
15 changed files with 203 additions and 9 deletions
+12
View File
@@ -268,6 +268,17 @@ 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 Flow editor's autosave never updates `flow.path` from
// the Path widget — the widget binds `$pathStore` directly,
// which is one-way `flow.path → $pathStore`. So the editor
// writes a separate `draft_path` field into the draft JSON
// when (and only when) the typed path differs from the
// deployed one. `None` here = unchanged.
let draft_path = v
.get("draft_path")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty() && *s != row.path.as_str())
.map(|s| s.to_string());
rows.push(ListableFlow {
workspace_id: w_id.clone(),
path: row.path,
@@ -290,6 +301,7 @@ async fn list_flows(
deployment_msg: None,
labels: None,
is_draft: true,
draft_path,
});
}
}
@@ -391,6 +391,14 @@ async fn list_scripts(
.get("kind")
.and_then(|x| serde_json::from_value(x.clone()).ok())
.unwrap_or(ScriptKind::Script);
// Scripts bind the Path widget directly to `script.path`, so
// the user-typed path round-trips through the draft JSON's
// own `path` field — no separate `draft_path` field needed.
let draft_path = v
.get("path")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty() && *s != row.path.as_str())
.map(|s| s.to_string());
rows.push(ListableScript {
hash: ScriptHash(0),
path: row.path,
@@ -418,6 +426,7 @@ async fn list_scripts(
kind,
labels: None,
is_draft: true,
draft_path,
});
}
}
+29
View File
@@ -7799,6 +7799,15 @@ paths:
script — either no deployed row exists at this
path (draft-only) or the user saved a per-user
draft on top of the deployed row.
draft_path:
type: string
description: |
User-typed path the editor has staged but not
yet deployed. Surfaced for draft-only rows so
the home list can render the meaningful name
instead of the autogenerated
`u/{user}/draft_{uuid}` URL path. Omitted
when unchanged.
/w/{workspace}/scripts/list_paths:
get:
@@ -9566,6 +9575,17 @@ paths:
flow — either no deployed row exists at this
path (draft-only) or the user saved a per-user
draft on top of the deployed row.
draft_path:
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.
/w/{workspace}/flows/history/p/{path}:
get:
@@ -27083,6 +27103,15 @@ components:
True when the authed user has a draft for this app — either no
deployed row exists at this path (draft-only) or the user has
saved a per-user draft on top of the deployed row.
draft_path:
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.
required:
- id
- workspace_id
+20
View File
@@ -167,6 +167,15 @@ pub struct ListableApp {
/// saved a per-user draft on top of the deployed row.
#[serde(default, skip_serializing_if = "is_false")]
pub is_draft: bool,
/// User-typed path the editor has staged but not yet deployed —
/// sourced from the draft JSON's `draft_path` field, which the app
/// editor only writes 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. `None` when
/// unchanged.
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
}
fn is_false(b: &bool) -> bool {
@@ -481,6 +490,16 @@ 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 (`App` or
// `{files, runnables, …}`) — neither shape carries a `path`
// field. The editor writes a separate `draft_path` field
// into the draft JSON when (and only when) the typed path
// differs from the deployed one. `None` here = unchanged.
let draft_path = v
.get("draft_path")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty() && *s != row.path.as_str())
.map(|s| s.to_string());
rows.push(ListableApp {
id: 0,
workspace_id: w_id.clone(),
@@ -500,6 +519,7 @@ async fn list_apps(
raw_app: row.typ == "raw_app",
labels: None,
is_draft: true,
draft_path,
});
}
}
+9
View File
@@ -93,6 +93,15 @@ pub struct ListableFlow {
/// saved a per-user draft on top of the deployed row.
#[serde(default)]
pub is_draft: bool,
/// User-typed path the editor has staged but not yet deployed —
/// sourced from the draft JSON's `draft_path` field, which the flow
/// editor only writes 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. `None` when
/// unchanged (which is most rows).
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
+11
View File
@@ -465,6 +465,17 @@ pub struct ListableScript {
/// on top of it. Distinguishes user-mode-only state from team state.
#[serde(skip_serializing_if = "is_false")]
pub is_draft: bool,
/// User-typed path the editor has staged but not yet deployed.
/// Surfaced for draft-only rows so the home list can render the
/// meaningful name instead of the autogenerated
/// `u/{user}/draft_{uuid}` URL path. Sourced from the draft JSON
/// (scripts: `value.path` — the editor binds the Path widget
/// directly to `script.path`; flows/apps/raw apps: an explicit
/// `value.draft_path` field the editor only writes when the typed
/// path differs from the deployed one). `None` when unchanged.
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
}
fn is_false(x: &bool) -> bool {
@@ -895,6 +895,29 @@
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.
$effect(() => {
const typed = $pathStore
const baseline = (flowStore.val as Flow | undefined)?.path ?? ''
const flow = flowStore.val as (Flow & { draft_path?: string }) | 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
}
})
})
$effect.pre(() => {
selectedId && untrack(() => select(selectedId))
})
@@ -164,6 +164,29 @@
darkMode
} = getContext<AppViewerContext>('AppViewerContext')
// Persist the user-typed path into the bare App draft as
// `draft_path` when it differs from the deployed/seeded baseline.
// The Path widget binds `newEditedPath` (popover-local), and the
// App's own shape has no `path` field — without this, the friendly
// auto-name on `/apps/add` and any in-place rename never reach the
// autosaved value, so the home-list draft row kept showing the
// autogenerated `u/{user}/draft_{uuid}` slot. Drop it once it
// matches the baseline again; deploy clears the whole draft, so
// the field naturally disappears post-deploy.
$effect(() => {
const typed = newEditedPath
const baseline = savedApp?.path ?? ''
const a = $app as (typeof $app & { draft_path?: string }) | undefined
if (!a) return
untrack(() => {
if (typed && typed !== baseline) {
a.draft_path = typed
} else if (a.draft_path !== undefined) {
delete (a as any).draft_path
}
})
})
const { history, jobsDrawerOpen, refreshComponents } =
getContext<AppEditorContext>('AppEditorContext')
@@ -104,7 +104,7 @@
href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}"
kind="app"
{marked}
path={app.path}
path={(app as any).draft_path ?? app.path}
summary={app.summary}
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
canFavorite={!app.draft_only}
@@ -43,6 +43,7 @@
flow: Flow & {
draft_only?: boolean
is_draft?: boolean
draft_path?: string
canWrite: boolean
}
marked: string | undefined
@@ -127,7 +128,7 @@
kind="flow"
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
{marked}
path={flow.path}
path={flow.draft_path ?? flow.path}
summary={flow.summary}
{errorHandlerMuted}
canFavorite={!flow.draft_only}
@@ -56,6 +56,7 @@
canWrite: boolean
use_codebase: boolean
is_draft?: boolean
draft_path?: string
}
marked: string | undefined
shareModal: ShareModal
@@ -144,7 +145,7 @@
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
kind="script"
{marked}
path={script.path}
path={script.draft_path ?? script.path}
summary={script.summary}
{errorHandlerMuted}
workspaceId={$workspaceStore ?? ''}
@@ -86,6 +86,12 @@
* still toggle the mode after mount; this prop only seeds the
* initial state. */
defaultSplitWithPreview?: boolean
/** Set by `RawAppEditorHeader` whenever the user-typed path
* (`newEditedPath`) differs from the deployed/seeded `savedApp.path`.
* The route reads it back to inject `draft_path` into the autosaved
* raw-app value so the home-page row can render the friendly name
* instead of the URL's autogenerated `draft_{uuid}` slot. */
pendingDraftPath?: string | undefined
}
let {
@@ -104,7 +110,8 @@
defaultSidebarCollapsed = false,
sidebarStorageKey = 'raw-app-sidebar-collapsed',
liveEditorDraftStoragePath = undefined,
defaultSplitWithPreview = true
defaultSplitWithPreview = true,
pendingDraftPath = $bindable(undefined)
}: Props = $props()
export const version: number | undefined = undefined
@@ -1357,6 +1364,7 @@
bind:jobsById
bind:savedApp
bind:summary
bind:pendingDraftPath
on:restore
on:savedNewAppPath
{policy}
@@ -1637,8 +1645,9 @@
title="Build failed"
class="relative before:absolute before:inset-0 before:-z-10 before:rounded-md before:bg-surface before:content-['']"
>
<pre
class="overflow-auto whitespace-pre-wrap text-xs max-h-60">{buildError}</pre>
<pre class="overflow-auto whitespace-pre-wrap text-xs max-h-60"
>{buildError}</pre
>
</Alert>
</div>
{/if}
@@ -127,6 +127,12 @@
liveEditorDraftStoragePath?: string
// Fired after a successful deploy; lets the session preview reload.
onDeploy?: (e: { path: string }) => 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
* draft as `draft_path` so the home-page row can render the
* friendly name instead of the URL's autogenerated draft slot. */
pendingDraftPath?: string | undefined
}
let {
@@ -153,9 +159,18 @@
onToggleSidebar = undefined,
onNavigate = undefined,
liveEditorDraftStoragePath = undefined,
onDeploy = undefined
onDeploy = undefined,
pendingDraftPath = $bindable(undefined)
}: Props = $props()
$effect(() => {
const typed = newEditedPath
const baseline = savedApp?.path ?? ''
untrack(() => {
pendingDraftPath = typed && typed !== baseline ? typed : undefined
})
})
let newEditedPath = $state(
untrack(() =>
newApp
@@ -43,6 +43,10 @@
summary: string
policy?: any
custom_path?: string
/** User-typed path the home list renders when set — written by
* the editor when (and only when) the typed path differs from
* the deployed/seeded `savedApp.path`. */
draft_path?: string
}
let files: Record<string, string> | undefined = $state(undefined)
@@ -53,6 +57,11 @@
// let lastVersion = 0
let policy: any = $state({})
let summary = $state('')
/** User-typed path surfaced from `RawAppEditorHeader` whenever it
* differs from `savedApp.path`. Mirrored into the autosaved raw-app
* draft below as `draft_path` so the home list can show the friendly
* name instead of the URL's `draft_{uuid}` slot. */
let pendingDraftPath = $state<string | undefined>(undefined)
let savedApp:
| {
@@ -137,14 +146,20 @@
readFieldsRecursively(data)
readFieldsRecursively(policy)
void summary
void pendingDraftPath
draftHandle.draft = {
files: currentFiles,
runnables,
data,
summary,
policy,
custom_path: savedApp?.custom_path
}
custom_path: savedApp?.custom_path,
// Only persist when set — `RawAppEditorHeader` clears it back
// to undefined once the typed path matches the baseline again,
// and deploy clears the whole draft, so the field disappears
// from the saved JSON in both cases.
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
} as RawAppDraft
})
// Reflect an external UserDraft.save into the form. Idempotent; the
@@ -525,6 +540,7 @@
bind:runnables
bind:data
bind:summary
bind:pendingDraftPath
{newPath}
path={page.params.path ?? ''}
liveEditorDraftStoragePath={path}
@@ -189,6 +189,22 @@
// path — FlowBuilder's Deploy button must `createFlow` at
// the user-typed path, not `updateFlow` at the URL.
isNewFlow = true
// Suspend autosave around the bootstrap cascade: the Path
// widget's `$workspaceStore && $userStore`-gated `initPath →
// reset → onMetaChange → bind:path` chain seeds a friendly
// auto-name into `$pathStore`, which FlowBuilder's
// `draft_path` effect mirrors into `flow.draft_path` — that
// programmatic write would otherwise fire as the user's
// first save before they've touched anything. Resumed once
// the cascade has settled (mirrors the other editors;
// 700ms covers cold reload where `$userStore` loads over
// the network).
if ($workspaceStore) {
const ws = $workspaceStore
const restartPath = flowDraftPath
UserDraft.stopSync('flow', restartPath, { workspace: ws })
setTimeout(() => UserDraft.restartSync('flow', restartPath, { workspace: ws }), 700)
}
// Clear `initialPath` so the Path widget's `initPath` falls
// into the empty-path branch and calls `reset()` to seed a
// friendly auto-name (mirrors the script editor — without