From 47c45edd807bd88813e7d746ef775146235ae0f2 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 8 Jun 2026 00:54:11 +0200 Subject: [PATCH] feat(drafts): render friendly user-typed path on home list for all 4 kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/windmill-api-flows/src/flows.rs | 12 ++++++++ backend/windmill-api-scripts/src/scripts.rs | 9 ++++++ backend/windmill-api/openapi.yaml | 29 +++++++++++++++++++ backend/windmill-api/src/apps.rs | 20 +++++++++++++ backend/windmill-types/src/flows.rs | 9 ++++++ backend/windmill-types/src/scripts.rs | 11 +++++++ .../src/lib/components/FlowBuilder.svelte | 23 +++++++++++++++ .../apps/editor/AppEditorHeader.svelte | 23 +++++++++++++++ .../lib/components/common/table/AppRow.svelte | 2 +- .../components/common/table/FlowRow.svelte | 3 +- .../components/common/table/ScriptRow.svelte | 3 +- .../components/raw_apps/RawAppEditor.svelte | 15 ++++++++-- .../raw_apps/RawAppEditorHeader.svelte | 17 ++++++++++- .../apps_raw/edit/[...path]/+page.svelte | 20 +++++++++++-- .../flows/edit/[...path]/+page.svelte | 16 ++++++++++ 15 files changed, 203 insertions(+), 9 deletions(-) diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 329c2a4911..3d35201fcc 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -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, }); } } diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 7bbf703e37..a98f4c0420 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -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, }); } } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1facc6c754..f6822ef8be 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index d07660744c..0a7b8e2a1c 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -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, } 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, }); } } diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 37d0ffe247..b13e91e623 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -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, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 24503c703f..6d8d3e00f3 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -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, } fn is_false(x: &bool) -> bool { diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 828a60943a..97bc94395c 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -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)) }) diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index dad1ba97f9..bd3b1dce67 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -164,6 +164,29 @@ darkMode } = getContext('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') diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 0b7704d550..09ccab7f99 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -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} diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index 8427516c3e..2ccd445b20 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -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} diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 49836419d6..34dae77d03 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -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 ?? ''} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8121ca8b70..62f4a71856 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -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-['']" > -
{buildError}
+
{buildError}
{/if} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index cda171d0ab..6f28e3295b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -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 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 168ace5d1c..9c2609c9a7 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 @@ -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 | 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(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} 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 f97b8a4215..61ff0766e6 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -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