feat(drafts): collaboration banner, cross-tab conflict detection, raw app template picker

- Other-users-drafts banner (Modal2): the deployed-overlay response now
  carries `other_drafts_users` (workspace usernames only, never emails);
  each row offers View JSON + Fork. Drops the standalone
  `listUsersWithDraftOnPath` endpoint; `getDraftForUser` now takes a
  workspace `username` query param (resolved to email server-side).
- Cross-tab/browser save conflict detection: the syncer attaches
  `last_sync` to every save (defaults to non-force); on a `conflict`
  response it parks a snapshot in a reactive map. Each route mounts a
  `DraftSyncConflictModal` and seeds the per-tab `last_sync` via
  `recordRemoteSync(query, draft_saved_at)` on every `get_draft` load.
  Keepalive flush also respects optimistic concurrency.
- Raw app template picker re-added after the /add ⇒ /edit refactor:
  framework (React 19 / 18 / Svelte 5), data table + schema config, and
  optional AI prompt — extracted into `RawAppTemplatePicker.svelte` and
  driven by `new_draft=true` on the edit route.
This commit is contained in:
Diego Imbert
2026-06-07 11:29:50 +02:00
parent f4a232baa4
commit 038b3da834
15 changed files with 1073 additions and 318 deletions
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "id",
"name": "id!",
"type_info": "Uuid"
}
],
@@ -16,7 +16,7 @@
]
},
"nullable": [
false
null
]
},
"hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319"
@@ -1,17 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, created_at\n FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n ORDER BY email NULLS LAST",
"query": "SELECT u.username as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"name": "username?",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
@@ -50,13 +45,13 @@
]
}
}
}
},
"Text"
]
},
"nullable": [
true,
false
]
},
"hash": "ca70f58d3c301c5b31749ea753dd1f63989c2fdae78e7e33053986862853dda5"
"hash": "4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c"
}
@@ -13,4 +13,4 @@
"nullable": []
},
"hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270"
}
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM usr WHERE workspace_id = $1 AND username = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9"
}
+3 -35
View File
@@ -7827,41 +7827,9 @@ paths:
items:
type: string
/w/{workspace}/drafts/users_with_draft/{kind}/{path}:
get:
summary: list users with a saved draft on a path
operationId: listUsersWithDraftOnPath
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: kind
in: path
required: true
schema:
$ref: "#/components/schemas/UserDraftItemKind"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: list of users (by email) with a draft; `null` represents a legacy workspace-level draft
content:
application/json:
schema:
type: array
items:
type: object
properties:
email:
type: string
nullable: true
created_at:
type: string
format: date-time
required: [created_at]
/w/{workspace}/drafts/get/{kind}/{path}:
get:
summary: fetch a single draft's content (own draft, another user's, or the legacy workspace-level row)
summary: fetch a single draft's content by workspace username (or the legacy workspace-level row)
operationId: getDraftForUser
tags:
- draft
@@ -7873,10 +7841,10 @@ paths:
schema:
$ref: "#/components/schemas/UserDraftItemKind"
- $ref: "#/components/parameters/ScriptPath"
- name: email
- name: username
in: query
required: false
description: Owner of the draft to fetch. Omit to fetch the legacy workspace-level (NULL email) row.
description: Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row.
schema:
type: string
responses:
+34 -46
View File
@@ -22,10 +22,6 @@ use windmill_common::{
pub fn workspaced_service() -> Router {
Router::new()
.route(
"/users_with_draft/{kind}/{*path}",
get(list_users_with_draft_on_path),
)
.route("/get/{kind}/{*path}", get(get_draft_for_user))
.route("/save_draft/{kind}/{*path}", post(save_draft))
.route("/list_drafts", get(list_drafts))
@@ -229,45 +225,13 @@ async fn get_draft(
.ok_or_else(|| Error::NotFound(format!("no draft for current user at {path}")))
}
#[derive(Serialize, Debug)]
pub struct UserWithDraft {
/// `None` represents a legacy workspace-level draft (no owner).
pub email: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
async fn list_users_with_draft_on_path(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
) -> Result<Json<Vec<UserWithDraft>>> {
let path = path.to_path();
require_can_read_path(&authed, &user_db, &w_id, kind, path).await?;
let rows = sqlx::query_as!(
UserWithDraft,
r#"SELECT email, created_at
FROM draft
WHERE workspace_id = $1
AND path = $2
AND typ = $3
ORDER BY email NULLS LAST"#,
&w_id,
path,
kind as UserDraftItemKind,
)
.fetch_all(&db)
.await?;
Ok(Json(rows))
}
#[derive(Deserialize, Debug)]
pub struct GetDraftQuery {
/// Owner of the draft to fetch. Omit to fetch the legacy
/// workspace-level (NULL email) row, if any.
pub email: Option<String>,
/// Workspace username of the draft owner to fetch. Omit to fetch the
/// legacy workspace-level (NULL email) row, if any. Emails are not
/// part of the public draft API — the username is resolved to an
/// email server-side.
pub username: Option<String>,
}
#[derive(Serialize, Debug)]
@@ -277,9 +241,10 @@ pub struct DraftForUser {
}
/// Fetch a specific user's (or the legacy NULL row's) draft content at a
/// path. Used by the "other users' drafts" modal in editors after the list
/// endpoint has surfaced who has a draft. Same path-permission check as
/// the list endpoint.
/// path. Used by the "other users' drafts" banner in editors after the
/// list of other owners has been surfaced on the deployed-overlay
/// response. The caller identifies the owner by workspace username so
/// emails never reach the client.
async fn get_draft_for_user(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -290,6 +255,29 @@ async fn get_draft_for_user(
let path = path.to_path();
require_can_read_path(&authed, &user_db, &w_id, kind, path).await?;
// Username -> email lookup, scoped to the workspace. None signals
// "fetch the legacy NULL-email row" (kept distinct from a username
// that simply has no draft, which falls through to 404 below).
let owner_email: Option<String> = if let Some(username) = &query.username {
let email = sqlx::query_scalar!(
r#"SELECT email FROM usr WHERE workspace_id = $1 AND username = $2"#,
&w_id,
username,
)
.fetch_optional(&db)
.await?;
match email {
Some(e) => Some(e),
None => {
return Err(Error::NotFound(format!(
"no user with username {username} in workspace"
)))
}
}
} else {
None
};
let row = sqlx::query_as!(
DraftForUser,
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>", created_at
@@ -301,7 +289,7 @@ async fn get_draft_for_user(
&w_id,
path,
kind as UserDraftItemKind,
query.email,
owner_email,
)
.fetch_optional(&db)
.await?;
@@ -309,7 +297,7 @@ async fn get_draft_for_user(
row.map(Json).ok_or_else(|| {
Error::NotFound(format!(
"no draft for {} at {path}",
query.email.as_deref().unwrap_or("<legacy>")
query.username.as_deref().unwrap_or("<legacy>")
))
})
}
+61 -1
View File
@@ -83,6 +83,16 @@ pub struct WithDraftQuery {
/// `Serialize` on its response type — most read-only response shapes
/// (e.g. `ScriptWithStarred`) only derive `Serialize`, and requiring
/// `DeserializeOwned` would force derive cascades through many crates.
/// One row of `other_drafts_users` — represents a draft on the same path
/// owned by someone other than the authed user. `username` is `None` for
/// the legacy NULL-email row (workspace-scoped pre-migration draft), which
/// the frontend surfaces as a "Legacy draft" entry with an info tooltip.
#[derive(Debug, Serialize)]
pub struct OtherDraftUser {
/// `None` represents a legacy workspace-level draft (no owner).
pub username: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WithDraftOverlay {
#[serde(flatten)]
@@ -102,13 +112,56 @@ pub struct WithDraftOverlay {
/// deployed (the rest of the response) for diff/restore UI.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<serde_json::Value>,
/// Other users with a draft on the same path (excludes the authed
/// user). Frontend surfaces this list in a banner so the user can
/// view another's JSON or fork it. Empty list is omitted to keep
/// the common-case response lean.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub other_drafts_users: Vec<OtherDraftUser>,
}
/// List every other user (and the legacy NULL-email row, if any) that
/// has a draft at `(workspace, kind, path)`. Returns usernames only —
/// emails never leave the server. LEFT JOIN against `usr` so an
/// orphaned draft (user removed from the workspace) still surfaces, with
/// its `username` falling back to `None` rather than dropping the row.
/// The authed user is excluded via `email <> authed_email`; the legacy
/// row matches because `email IS NULL` fails that comparison.
async fn fetch_other_drafts_users(
db: &DB,
w_id: &str,
authed_email: &str,
kind: UserDraftItemKind,
path: &str,
) -> Result<Vec<OtherDraftUser>> {
let rows = sqlx::query_as!(
OtherDraftUser,
r#"SELECT u.username as "username?"
FROM draft d
LEFT JOIN usr u
ON u.workspace_id = d.workspace_id
AND u.email = d.email
WHERE d.workspace_id = $1
AND d.path = $2
AND d.typ = $3
AND (d.email IS NULL OR d.email <> $4)
ORDER BY d.email NULLS LAST"#,
w_id,
path,
kind as UserDraftItemKind,
authed_email,
)
.fetch_all(db)
.await?;
Ok(rows)
}
/// If `get_draft` is true AND the authed user has a draft saved for
/// `(workspace, kind, path)`, attach it as `draft` on the response.
/// The deployed payload (`deployed`) is always serialized into `inner`
/// untouched — the wire response is `<deployed fields...> + is_draft +
/// draft? + draft_saved_at?` regardless of whether a draft exists.
/// draft? + draft_saved_at? + other_drafts_users?` regardless of whether
/// the authed user has a draft.
pub async fn maybe_overlay_draft<T>(
db: &DB,
w_id: &str,
@@ -130,6 +183,7 @@ where
draft_saved_at: None,
no_deployed: false,
draft: None,
other_drafts_users: Vec::new(),
});
}
@@ -149,6 +203,8 @@ where
.fetch_optional(db)
.await?;
let other_drafts_users = fetch_other_drafts_users(db, w_id, email, kind, path).await?;
let Some(row) = row else {
return Ok(WithDraftOverlay {
inner,
@@ -156,6 +212,7 @@ where
draft_saved_at: None,
no_deployed: false,
draft: None,
other_drafts_users,
});
};
@@ -167,6 +224,7 @@ where
draft_saved_at: Some(row.created_at),
no_deployed: false,
draft: Some(draft_json),
other_drafts_users,
})
}
@@ -242,6 +300,7 @@ pub async fn fetch_draft_only(
};
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
let other_drafts_users = fetch_other_drafts_users(db, w_id, email, kind, path).await?;
Ok(Some(WithDraftOverlay {
// Best-effort stand-in for the missing deployed — same JSON as
// `draft`. Frontend should read `.draft` for the editor state
@@ -251,5 +310,6 @@ pub async fn fetch_draft_only(
draft_saved_at: Some(row.created_at),
no_deployed: true,
draft: Some(draft_json),
other_drafts_users,
}))
}
@@ -0,0 +1,92 @@
<script lang="ts">
/**
* Surfaces the conflict snapshot left by `UserDraftDbSyncer.postSave`
* when the server rejects a save because the row's `created_at` has
* advanced past our `last_sync` (another tab/browser/user pushed an
* intervening write). The route mounts one of these per editor: it
* reads the reactive conflict handle and offers two resolutions —
* pull the remote (discards local edits) or push over it.
*/
import { UserDraftDbSyncer, type UserDraftLastSyncQuery } from '$lib/userDraftDbSyncer.svelte'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { AlertTriangle } from 'lucide-svelte'
type Props = {
query: UserDraftLastSyncQuery
/** Editor-side reload — re-fetches the deployed-overlay response,
* resets in-memory state, and (implicitly via the loader calling
* `recordRemoteSync`) updates the local `last_sync` to the
* server's clock. The modal awaits this before closing. */
onLoadFromServer: () => Promise<void> | void
/** Current local draft value to overwrite the server with. The
* modal passes it through `UserDraftDbSyncer.overwrite`. */
getLocalDraft: () => unknown
}
let { query, onLoadFromServer, getLocalDraft }: Props = $props()
const conflictHandle = $derived(UserDraftDbSyncer.getConflict(query))
let isOpen = $derived(conflictHandle.conflict !== undefined)
let busy = $state(false)
async function loadFromServer() {
busy = true
try {
await onLoadFromServer()
UserDraftDbSyncer.clearConflict(query)
} finally {
busy = false
}
}
async function overwriteServer() {
busy = true
try {
await UserDraftDbSyncer.overwrite({
workspace: query.workspace,
itemKind: query.itemKind,
path: query.path,
value: getLocalDraft()
})
} finally {
busy = false
}
}
</script>
<Modal2 bind:isOpen title="Draft out of sync" fixedWidth="sm" fixedHeight="xs">
<div class="flex flex-col w-full gap-4">
<div class="flex gap-3 items-start">
<AlertTriangle size={20} class="text-yellow-500 shrink-0 mt-0.5" />
<div class="text-sm text-secondary flex flex-col gap-1">
<p>
Someone else (another tab, browser, or teammate) saved a newer version of this draft. Your
autosave was rejected to avoid overwriting their work.
</p>
{#if conflictHandle.conflict}
<p class="text-xs text-tertiary">
Server timestamp: {new Date(conflictHandle.conflict.serverTimestamp).toLocaleString()}
</p>
{/if}
</div>
</div>
<div class="flex justify-end gap-2">
<Button
variant="default"
size="sm"
disabled={busy}
on:click={() => UserDraftDbSyncer.clearConflict(query)}
>
Dismiss
</Button>
<Button variant="default" size="sm" disabled={busy} on:click={overwriteServer}>
Overwrite the remote
</Button>
<Button variant="accent" size="sm" loading={busy} on:click={loadFromServer}>
Load from server
</Button>
</div>
</div>
</Modal2>
@@ -1,198 +1,190 @@
<script lang="ts">
/**
* On editor mount, queries `/drafts/users_with_draft/...`, filters out
* the requesting user, and surfaces every other user (and the legacy
* NULL-email row, if any) who has a saved draft at the path. Each
* entry has a "Load" button that opens the parent's `DiffDrawer` with
* a simple diff against the editor's current value, plus a Fork
* action that calls back into the parent to overwrite the local draft
* with the other user's content.
* Banner-style modal shown on editor mount when the deployed-overlay
* response carries `other_drafts_users` — i.e. someone other than the
* authed user (or the legacy NULL-email row) also has a saved draft at
* this path.
*
* The modal is self-fetching and self-opening — drop it into a route
* page with the right props and it'll only render UI when there's
* actually something to show.
* The list of owners is part of the get-by-path payload (so we don't
* fan out a second request just to populate the banner); individual
* drafts are fetched on-demand for the "View JSON" / "Fork" actions so
* the deploy-overlay response stays lean when many users are working
* on the same item.
*/
import { classNames } from '$lib/utils'
import { fade } from 'svelte/transition'
import Button from '../button/Button.svelte'
import { Users, X } from 'lucide-svelte'
import { DraftService, type UserDraftItemKind } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { onMount } from 'svelte'
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
import { goto } from '$lib/navigation'
import { Users, GitFork, Braces } from 'lucide-svelte'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
type DraftOwner = { email?: string | null; created_at: string }
export type OtherDraftUser = { username?: string | null }
type Props = {
workspace: string
/** UserDraft item kind — passed verbatim to the backend; matches the
* `draft.typ` column. */
itemKind: UserDraftItemKind
path: string
/** Current local value (post-restore-from-localstorage) shown on the
* right side of the diff. */
currentValue: unknown
/** Email of the requesting user — filtered out of the list so the
* user doesn't see themselves. */
currentUserEmail: string | undefined
diffDrawer: DiffDrawer | undefined
/** Whether the user currently has unsaved local changes. Toggles the
* diff drawer button label between `Fork` and
* `Discard current and fork`. */
userHasLocalDraft: boolean
/** Called with the chosen other-user value when the user confirms
* the fork. The route page wires this to its `UserDraft.save` so
* the value lands in the live handle (and gets synced). */
onFork: (otherUserValue: unknown) => void
/** Workspace username of the authed user — used to namespace the
* fork path (`u/{currentUserUsername}/...`). */
currentUserUsername: string | undefined
/** Owners list from the deployed-overlay response. Each entry has a
* workspace `username` (or `null` for the legacy workspace-level
* row). The authed user is already filtered out server-side. */
otherDraftsUsers: OtherDraftUser[]
/** Route hook: build the per-editor edit URL for a forked draft path.
* Different editors live under different roots (`/scripts/edit/`,
* `/flows/edit/`, ...) so the route owns the URL shape. */
editPathFor: (forkedPath: string) => string
}
let {
workspace,
itemKind,
path,
currentValue,
currentUserEmail,
diffDrawer,
userHasLocalDraft,
onFork
}: Props = $props()
let { workspace, itemKind, path, currentUserUsername, otherDraftsUsers, editPathFor }: Props =
$props()
let others = $state<DraftOwner[]>([])
let open = $state(false)
let loadingFor = $state<string | null>(null)
let isOpen = $state(otherDraftsUsers.length > 0)
let busyFor = $state<string | null>(null)
let jsonOpen = $state(false)
let jsonOwnerLabel = $state('')
let jsonValue = $state<unknown>(undefined)
onMount(async () => {
if (!path) return
try {
const list = await DraftService.listUsersWithDraftOnPath({
workspace,
kind: itemKind,
path
})
others = list.filter((u) => u.email !== currentUserEmail)
if (others.length > 0) open = true
} catch (e) {
// Permission errors / 404 are expected for paths the user can't see
// other users on. The modal just stays closed.
console.debug('[OtherUsersDraftsModal] list failed:', e)
}
})
function ownerLabel(o: DraftOwner): string {
return o.email ?? 'Legacy workspace-level draft'
function ownerLabel(owner: OtherDraftUser): string {
return owner.username ?? 'Legacy draft'
}
function ownerKey(o: DraftOwner): string {
return o.email ?? '__legacy__'
function ownerKey(owner: OtherDraftUser): string {
return owner.username ?? '__legacy__'
}
async function loadDraft(owner: DraftOwner) {
if (!diffDrawer) {
sendUserToast('Diff drawer not ready', true)
return
}
loadingFor = ownerKey(owner)
try {
const draft = await DraftService.getDraftForUser({
/** Derive the fork target path. `u/{currentUser}/{leaf}_{owner}_fork`
* where leaf = the last segment of the source path. For the legacy
* row we use `_legacy_fork` instead of an owner username. */
function forkPath(owner: OtherDraftUser): string {
const leaf = path.split('/').pop() ?? path
const ownerSuffix = owner.username ?? 'legacy'
return `u/${currentUserUsername ?? 'me'}/${leaf}_${ownerSuffix}_fork`
}
async function fetchDraft(owner: OtherDraftUser): Promise<unknown> {
return (
await DraftService.getDraftForUser({
workspace,
kind: itemKind,
path,
email: owner.email ?? undefined
})
open = false
diffDrawer.openDrawer()
diffDrawer.setDiff({
mode: 'simple',
original: draft.value as any,
current: currentValue as any,
title: `${ownerLabel(owner)} <> your current`,
button: {
text: userHasLocalDraft ? 'Discard current and fork' : 'Fork',
onClick: () => onFork(draft.value)
}
username: owner.username ?? undefined
})
).value
}
async function viewJson(owner: OtherDraftUser) {
busyFor = ownerKey(owner)
try {
jsonValue = await fetchDraft(owner)
jsonOwnerLabel = ownerLabel(owner)
jsonOpen = true
} catch (e) {
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
} finally {
loadingFor = null
busyFor = null
}
}
function fadeFast(node: HTMLElement) {
return fade(node, { duration: 100 })
async function fork(owner: OtherDraftUser) {
busyFor = ownerKey(owner)
try {
const value = await fetchDraft(owner)
const target = forkPath(owner)
UserDraft.save(itemKind, target, value, { workspace })
isOpen = false
goto(editPathFor(target))
} catch (e) {
sendUserToast(`Could not fork draft: ${e.body ?? e.message}`, true)
} finally {
busyFor = null
}
}
</script>
{#if open}
<div transition:fadeFast|local class="fixed top-0 bottom-0 left-0 right-0 z-[9999]" role="dialog">
<div
class={classNames(
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
'ease-out duration-300 opacity-100'
)}
></div>
<Modal2
bind:isOpen
title="Other users are currently working on {path}"
fixedWidth="sm"
fixedHeight="sm"
>
<div class="flex flex-col w-full gap-4">
<div class="flex gap-3 items-start">
<Users size={20} class="text-blue-500 shrink-0 mt-0.5" />
<p class="text-sm text-secondary">
Their drafts are independent of yours. Open one as JSON to inspect it, or fork it into your
own namespace to continue editing.
</p>
</div>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div
class="relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl sm:my-8 sm:w-full sm:max-w-lg sm:p-6"
>
<div class="flex">
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800/50"
>
<Users class="text-blue-600 dark:text-blue-300" />
</div>
<div class="ml-4 flex-1 text-left min-w-0">
<h3 class="text-lg font-medium text-primary">
Other users have drafts on <code class="break-all">{path}</code>
</h3>
<p class="mt-2 text-sm text-secondary">
Click "Load" to preview their draft side-by-side with yours.
</p>
</div>
<button
type="button"
class="text-tertiary hover:text-primary"
aria-label="Dismiss"
onclick={() => (open = false)}
>
<X size={18} />
</button>
<ul class="divide-y border-t border-b">
{#each otherDraftsUsers as owner (ownerKey(owner))}
<li class="flex items-center gap-3 py-2">
<div class="flex-1 min-w-0 flex items-center gap-2">
<span class="text-sm font-medium text-primary truncate" class:italic={!owner.username}>
{ownerLabel(owner)}
</span>
{#if !owner.username}
<Tooltip>
Pre-migration workspace-scoped draft (no owner). Saved before drafts became per-user
— kept around so you can recover the content, but no current user owns it.
</Tooltip>
{/if}
</div>
<Button
variant="default"
size="xs"
startIcon={{ icon: Braces }}
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
loading={busyFor === ownerKey(owner)}
on:click={() => viewJson(owner)}
>
View JSON
</Button>
<Button
variant="default"
size="xs"
startIcon={{ icon: GitFork }}
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
loading={busyFor === ownerKey(owner)}
on:click={() => fork(owner)}
>
Fork
</Button>
</li>
{/each}
</ul>
<ul class="mt-4 divide-y border-t border-b">
{#each others as owner (ownerKey(owner))}
<li class="flex items-center gap-3 py-2">
<div class="flex-1 min-w-0">
<div
class="text-sm font-medium text-primary truncate"
class:italic={!owner.email}
>
{ownerLabel(owner)}
</div>
<div class="text-xs text-tertiary">
saved {new Date(owner.created_at).toLocaleString()}
</div>
</div>
<Button
variant="default"
size="xs"
disabled={loadingFor !== null && loadingFor !== ownerKey(owner)}
loading={loadingFor === ownerKey(owner)}
on:click={() => loadDraft(owner)}
>
Load
</Button>
</li>
{/each}
</ul>
<div class="flex justify-end mt-4">
<Button variant="default" size="sm" on:click={() => (open = false)}>Dismiss</Button>
</div>
</div>
</div>
<div class="flex justify-end">
<Button variant="default" size="sm" on:click={() => (isOpen = false)}>Continue</Button>
</div>
</div>
{/if}
</Modal2>
<Modal2
bind:isOpen={jsonOpen}
title="Draft JSON {jsonOwnerLabel}"
fixedWidth="lg"
fixedHeight="lg"
>
{#snippet headerRight()}
<Button
variant="default"
size="xs"
on:click={() => {
navigator.clipboard?.writeText(JSON.stringify(jsonValue, null, 2))
sendUserToast('Copied to clipboard')
}}
>
Copy
</Button>
{/snippet}
<div class="w-full overflow-auto">
<pre class="text-xs whitespace-pre font-mono bg-surface-secondary rounded p-3"
>{JSON.stringify(jsonValue ?? {}, null, 2)}</pre
>
</div>
</Modal2>
@@ -0,0 +1,394 @@
<script lang="ts">
import { Sparkles, Plus, List, Ban, ExternalLinkIcon } from 'lucide-svelte'
import type { Policy } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import Modal from '$lib/components/common/modal/Modal.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Select from '$lib/components/select/Select.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { Alert } from '$lib/components/common'
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
import { copilotInfo } from '$lib/aiStore'
import { react18Template, react19Template, svelte5Template } from './templates'
import type { Runnable } from './rawAppPolicy'
import { type DataTableRef, type RawAppData, formatDataTableRef } from './dataTableRefUtils'
import {
createDatatablesResource,
createSchemasResource,
toDatatableItems,
toSchemaItems
} from './datatableUtils.svelte'
import RawAppDataTableList from './RawAppDataTableList.svelte'
import RawAppDataTableDrawer from './RawAppDataTableDrawer.svelte'
import FileEditorIcon from './FileEditorIcon.svelte'
export type RawAppTemplatePickerResult = {
files: Record<string, string>
runnables: Record<string, Runnable>
data: RawAppData
summary: string
policy: Policy
prompt?: string
}
let {
open = $bindable(false),
onStart
}: {
open?: boolean
onStart: (result: RawAppTemplatePickerResult, withPrompt: boolean) => void
} = $props()
const templates = [
{ name: 'React 19', icon: 'tsx', files: react19Template },
{ name: 'React 18', icon: 'tsx', files: react18Template },
{ name: 'Svelte 5', icon: 'svelte', files: svelte5Template }
]
let selectedTemplateIndex = $state(0)
let tableCreationEnabled = $state(true)
let selectedDatatable = $state<string | undefined>(undefined)
let schemaMode = $state<'none' | 'new' | 'existing'>('new')
let selectedSchema = $state<string | undefined>(undefined)
let newSchemaName = $state('')
let appSummary = $state('')
let initialPrompt = $state('')
let preWhitelistedTables = $state<DataTableRef[]>([])
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
const datatables = createDatatablesResource(() => $workspaceStore)
const schemas = createSchemasResource(() => selectedDatatable)
const availableDatatables = $derived(datatables.current)
const availableSchemas = $derived(schemas.current)
let hasAutoSelected = false
$effect(() => {
if (availableDatatables?.length > 0 && !hasAutoSelected) {
hasAutoSelected = true
selectedDatatable = availableDatatables.includes('main') ? 'main' : availableDatatables[0]
}
})
function generateUniqueSchemaName(existingSchemas: string[]): string {
let num = 1
while (existingSchemas.includes(`app${num}`)) {
num++
}
return `app${num}`
}
const newSchemaAlreadyExists = $derived(
schemaMode === 'new' &&
newSchemaName.trim() !== '' &&
(availableSchemas ?? []).includes(newSchemaName.trim())
)
let userEditedSchemaName = $state(false)
$effect(() => {
const schemas = availableSchemas ?? []
if (schemaMode === 'new') {
if (!newSchemaName) {
newSchemaName = generateUniqueSchemaName(schemas)
userEditedSchemaName = false
} else if (!userEditedSchemaName && schemas.includes(newSchemaName)) {
newSchemaName = generateUniqueSchemaName(schemas)
}
}
})
const datatableItems = $derived(toDatatableItems(availableDatatables))
const schemaItems = $derived(toSchemaItems(availableSchemas))
const effectiveSchema = $derived(
schemaMode === 'new' ? newSchemaName : schemaMode === 'existing' ? selectedSchema : undefined
)
const hasNoDatatables = $derived(availableDatatables?.length === 0)
const isAiEnabled = $derived($copilotInfo.enabled)
async function start(withPrompt: boolean) {
const template = templates[selectedTemplateIndex]
if (schemaMode === 'new' && newSchemaName && selectedDatatable && $workspaceStore) {
try {
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
const dbOps = dbSchemaOpsWithPreviewScripts({
workspace: $workspaceStore,
input: {
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${selectedDatatable}`
}
})
await dbOps.onCreateSchema({ schema: newSchemaName })
} catch (e) {
console.error('Failed to create schema:', e)
sendUserToast(`Failed to create schema: ${e}`, true)
}
}
const formattedTables = preWhitelistedTables.map(formatDataTableRef)
const data: RawAppData =
tableCreationEnabled && selectedDatatable
? {
tables: formattedTables,
datatable: selectedDatatable,
schema: effectiveSchema
}
: { tables: formattedTables, datatable: undefined, schema: undefined }
const policy: Policy = {
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
: `u/${$userStore?.username}`,
on_behalf_of_email: $userStore?.email,
execution_mode: 'publisher'
}
open = false
onStart(
{
files: template.files,
runnables: {},
data,
summary: appSummary.trim(),
policy,
prompt: withPrompt ? initialPrompt.trim() : undefined
},
withPrompt
)
}
</script>
{#if open}
<Modal kind="X" open title="New App setup">
<div class="flex flex-col gap-6 min-w-sm">
<div>
<h2 class="text-xs font-semibold text-emphasis mb-1">Summary</h2>
<TextInput
bind:value={appSummary}
inputProps={{
placeholder: "Brief description of the app (e.g., 'Todo list with authentication')"
}}
/>
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Framework</h2>
<div class="flex flex-wrap gap-3">
{#each templates as t, i}
<button
onclick={() => (selectedTemplateIndex = i)}
class="w-24 h-24 flex justify-between py-5 flex-col {selectedTemplateIndex === i
? 'bg-surface-accent-selected border border-accent'
: ''} hover:bg-surface-hover border rounded-lg transition-all"
>
<div class="w-full flex items-center justify-center">
<FileEditorIcon file={'.' + t.icon} size={32} />
</div>
<div class="center-center w-full text-sm text-secondary">{t.name}</div>
</button>
{/each}
</div>
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Data configuration</h2>
{#if hasNoDatatables}
<Alert type="warning" title="No datatables configured.">
You can still create an app, but for data storage you won't be able to use data tables
which are <b>highly recommended</b>.
<br />
{#if $userStore?.is_admin}
Configure datatables in
<a
href="/workspace_settings?tab=windmill_data_tables"
target="_blank"
class="inline-flex items-center gap-1"
>workspace settings <ExternalLinkIcon size={16} />
</a> to enable this feature.
{:else}
Ask your workspace admin to configure datatables in workspace settings to enable this
feature.
{/if}
</Alert>
{:else}
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary mb-1 block">Default settings for new tables</span>
<div class="flex flex-col gap-4 rounded-md p-4 border">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-xs text-emphasis font-semibold" for="datatable"
>Datatable</label
>
<Select
id="datatable"
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
size="sm"
class="w-40"
/>
</div>
<div>
<span class="text-xs text-emphasis font-semibold">Schema</span>
<div class="flex flex-row gap-1 w-full items-center">
<div>
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
{#snippet children({ item })}
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
<ToggleButton value="new" label="New" icon={Plus} {item} size="sm" />
<ToggleButton
value="existing"
label="Existing"
icon={List}
{item}
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{#if schemaMode === 'new'}
<TextInput
bind:value={newSchemaName}
inputProps={{
placeholder: 'Schema name',
oninput: () => (userEditedSchemaName = true)
}}
class="flex-1"
error={newSchemaAlreadyExists}
size="sm"
/>
{:else if schemaMode === 'existing'}
<div class="flex-1">
<Select
disablePortal
items={schemaItems}
bind:value={selectedSchema}
placeholder="Schema"
size="sm"
/>
</div>
{/if}
</div>
{#if newSchemaAlreadyExists}
<span class="text-xs text-red-500"
>Schema "{newSchemaName}" already exists</span
>
{/if}
</div>
</div>
</div>
</div>
<div class="flex items-center">
<Toggle
size="sm"
bind:checked={tableCreationEnabled}
options={{ right: 'Allow AI to create new tables' }}
/>
</div>
<div class="pt-6">
<RawAppDataTableList
dataTableRefs={preWhitelistedTables}
defaultDatatable={selectedDatatable}
defaultSchema={effectiveSchema}
standalone
hideDefaultSelector
onAdd={() => dataTableDrawer?.openDrawer()}
onRemove={(index) => {
preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index)
}}
/>
</div>
</div>
{/if}
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1 flex items-center gap-2">
<Sparkles size={16} class="text-ai" />
Start with AI
<span class="text-xs font-normal text-tertiary">(optional)</span>
</h2>
{#if !isAiEnabled}
<Alert type="info" title="AI is not configured for this workspace.">
You can still create an app manually but using AI is highly recommended.
<br />
{#if $userStore?.is_admin}
Configure AI in
<a
href="/workspace_settings?tab=ai"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>workspace settings <ExternalLinkIcon size={16} />
</a> to enable this feature.
{:else}
Ask your workspace admin to configure AI in workspace settings to enable this feature.
{/if}
</Alert>
{:else}
<div class="flex flex-col gap-2">
<TextInput
underlyingInputEl="textarea"
bind:value={initialPrompt}
inputProps={{
rows: 3,
placeholder:
"Describe what you want to build... (e.g., 'Create a todo list app with user authentication')"
}}
/>
<p class="text-xs text-tertiary">
Leave empty to start with a blank template, or describe your app to get AI assistance
right away.
</p>
</div>
{/if}
</div>
<div class="pt-6 flex justify-end gap-3">
<Button
variant="default"
size="sm"
on:click={() => start(false)}
disabled={!templates[selectedTemplateIndex] || newSchemaAlreadyExists}
>
Start without AI
</Button>
{#if isAiEnabled}
<Button
variant="accent"
on:click={() => start(true)}
disabled={!templates[selectedTemplateIndex] ||
!initialPrompt.trim() ||
newSchemaAlreadyExists}
startIcon={{ icon: Sparkles }}
btnClasses={AIBtnClasses('accent')}
>
Start with AI
</Button>
{/if}
</div>
</div>
</Modal>
{/if}
<RawAppDataTableDrawer
bind:this={dataTableDrawer}
offset={10000}
existingRefs={preWhitelistedTables}
onAdd={(ref) => {
preWhitelistedTables = [...preWhitelistedTables, ref]
}}
/>
+124 -5
View File
@@ -1,3 +1,4 @@
import { SvelteMap } from 'svelte/reactivity'
import { DraftService, type UserDraftItemKind } from './gen'
import { OpenAPI } from './gen/core/OpenAPI'
import { createCoalescingKeyedRunner } from './coalescingRunner.svelte'
@@ -84,6 +85,23 @@ export type UserDraftDbSyncerSaveOpts = {
* flows (table-row delete, etc.) where a fire-and-forget save would
* race the next read. */
immediate?: boolean
/** Skip the optimistic-concurrency check on this save and overwrite
* the server row unconditionally. Used by the conflict-resolution UI
* ("Overwrite the remote") and by callers that have already resolved
* the conflict locally. Default `false`: regular autosaves attach
* `last_sync` and respect the server's reject response. */
force?: boolean
}
/**
* Snapshot of a rejected save. `localLastSync` is what we sent the
* server (or `null` if we'd never synced this key); `serverTimestamp` is
* the row's current `created_at`, surfaced so the resolution UI can show
* how recent the conflicting write was.
*/
export type DraftConflictInfo = {
serverTimestamp: string
localLastSync: string | null
}
export type UserDraftLastSyncQuery = {
@@ -145,8 +163,18 @@ const runner = createCoalescingKeyedRunner()
*/
const pendingSaveOpts = new Map<string, UserDraftDbSyncerSaveOpts>()
/**
* Reactive map of conflict snapshots — populated when the server rejects
* a save because the row's `created_at` is newer than the `last_sync` we
* sent. Read via `getConflict(query)` from the UI to drive the
* resolution modal. SvelteMap so consumer `$derived` re-runs when an
* entry is added/removed.
*/
const conflicts = new SvelteMap<string, DraftConflictInfo>()
async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
const key = draftKey(opts.workspace, opts.itemKind, opts.path)
const lastSync = readLastSyncMap()[key]?.lastSync
try {
const resp = await DraftService.saveDraft({
workspace: opts.workspace,
@@ -154,17 +182,37 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
path: opts.path,
requestBody: {
value: opts.value as any,
force: true
// Force-saves skip the conflict check (callers that
// explicitly opted in via `overwrite(...)` already showed
// the user the conflict). Regular autosaves attach
// `last_sync` (when we have one) so the server can reject
// stale writes — defining behaviour for a first-ever save
// is "no last_sync field at all", matching the backend's
// "treat as fresh" branch.
last_sync: opts.force ? undefined : lastSync,
force: opts.force ?? false
}
})
// On a successful delete, drop the recorded last-sync so the
// next save starts fresh. On an upsert, remember the server's
// timestamp as our baseline for future conflict checks.
if (resp.status === 'conflict') {
// Server rejected the write because someone (another tab /
// browser / user) advanced the row past our `last_sync`. Park
// the snapshot in the conflict map for the UI to pick up; do
// NOT touch the local `lastSync` (the next save retries from
// the same baseline so the conflict persists until resolved).
conflicts.set(key, {
serverTimestamp: resp.current_timestamp,
localLastSync: lastSync ?? null
})
return
}
// resp.status === 'saved' — clear any prior conflict and bring
// the local lastSync forward (or drop it entirely on a delete).
if (opts.value === null) {
clearLastSync(opts.workspace, opts.itemKind, opts.path)
} else {
setLastSync(opts.workspace, opts.itemKind, opts.path, resp.current_timestamp)
}
conflicts.delete(key)
// Clear pending only if it's still the opts we just saved — a
// newer `save()` that arrived during the POST replaces the entry
// and must survive for the next flush / debouncer round.
@@ -207,11 +255,23 @@ function flushOnUnload(): void {
`/w/${encodeURI(opts.workspace)}` +
`/drafts/save_draft/${encodeURI(opts.itemKind)}` +
`/${encodeURI(opts.path)}`
const lastSync =
readLastSyncMap()[draftKey(opts.workspace, opts.itemKind, opts.path)]?.lastSync
// Unload flush respects optimistic concurrency: if the
// server moved on while the user was editing, dropping the
// keepalive write is the safer default (their colleague's
// edits stay intact). Callers that need force-overwrite
// would have called `overwrite(...)` explicitly before
// closing the tab.
void fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: opts.value, force: true }),
body: JSON.stringify({
value: opts.value,
last_sync: opts.force ? undefined : lastSync,
force: opts.force ?? false
}),
keepalive: true
}).catch((e) => {
// `keepalive` size cap or network error — log so the loss
@@ -303,5 +363,64 @@ export const UserDraftDbSyncer = {
debouncer.schedule(key, () => {
runner.submit(key, () => postSave(opts))
})
},
/**
* Seed the per-tab `last_sync` map after an editor reads a draft
* from the server (via `?get_draft=true`). Calling this with the
* `draft_saved_at` from the response makes the next save send a
* matching `last_sync`, so the server accepts it unless someone
* pushed a newer write in between. Pass `undefined` (or omit the
* draftSavedAt) when no draft existed — that flips the next save
* back to the "no last_sync" branch, which the backend treats as a
* first-time push.
*/
recordRemoteSync(query: UserDraftLastSyncQuery, draftSavedAt: string | undefined): void {
const key = draftKey(query.workspace, query.itemKind, query.path)
if (draftSavedAt) {
setLastSync(query.workspace, query.itemKind, query.path, draftSavedAt)
} else {
clearLastSync(query.workspace, query.itemKind, query.path)
}
// Reading the server's authoritative timestamp resets the
// conflict state — by definition we're back in sync.
conflicts.delete(key)
},
/**
* Reactive snapshot of the conflict (if any) for a draft. The
* returned handle reads `conflicts` via a SvelteMap getter so a
* `$derived` re-runs when the entry appears or clears.
*/
getConflict(query: UserDraftLastSyncQuery): {
readonly conflict: DraftConflictInfo | undefined
} {
const key = draftKey(query.workspace, query.itemKind, query.path)
return {
get conflict() {
return conflicts.get(key)
}
}
},
/**
* Clear the conflict snapshot for a draft. Call after the
* resolution UI lands a fresh read (the editor reloaded from
* server) — `recordRemoteSync` does this implicitly, so the only
* standalone use is the "dismiss without resolving" path.
*/
clearConflict(query: UserDraftLastSyncQuery): void {
conflicts.delete(draftKey(query.workspace, query.itemKind, query.path))
},
/**
* Force-save: bypass the `last_sync` check and overwrite the
* server row. Used by the conflict-resolution modal's "Overwrite
* the remote" action. Goes through the same coalescing runner
* (and resolves only after the POST lands) so the caller can `await`
* it before navigating away or refetching.
*/
async overwrite(opts: Omit<UserDraftDbSyncerSaveOpts, 'force'>): Promise<void> {
await this.save({ ...opts, immediate: true, force: true })
}
}
@@ -9,7 +9,11 @@
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { App } from '$lib/components/apps/types'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import OtherUsersDraftsModal from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import DraftSyncConflictModal from '$lib/components/common/confirmationModal/DraftSyncConflictModal.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import OtherUsersDraftsModal, {
type OtherDraftUser
} from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { emptyApp } from '$lib/components/apps/editor/appUtils'
import { untrack } from 'svelte'
@@ -36,6 +40,7 @@
* (e.g. workspace switch on the same draft path). Flips back to
* false once a deployed row exists at this path. */
let isNewApp = $state(false)
let otherDraftsUsers = $state<OtherDraftUser[]>([])
// Local-draft staleness modal: opened when the remote has moved on since
// the local autosave was written.
@@ -142,6 +147,13 @@
getDraft
})
if (tok !== loadAppToken) return
otherDraftsUsers = ((backendApp as any).other_drafts_users ?? []) as OtherDraftUser[]
if ($workspaceStore && path) {
UserDraftDbSyncer.recordRemoteSync(
{ workspace: $workspaceStore, itemKind: 'app', path },
(backendApp as any).draft_saved_at as string | undefined
)
}
// Apply the user's saved draft to `.value`. The autosave for apps
// writes the raw `App` (just the editor's working value) — when a
// draft exists the backend sends it back in `.draft`, and the
@@ -301,20 +313,24 @@
onKeepDraft={onStaleKeepDraft}
/>
{#if $workspaceStore && path}
<OtherUsersDraftsModal
workspace={$workspaceStore}
itemKind="app"
{path}
currentValue={app?.value}
currentUserEmail={$userStore?.email}
{diffDrawer}
userHasLocalDraft={UserDraft.has('app', path)}
onFork={(otherValue) => {
UserDraft.save('app', path, otherValue, { workspace: $workspaceStore })
diffDrawer?.closeDrawer()
}}
<DraftSyncConflictModal
query={{ workspace: $workspaceStore, itemKind: 'app', path }}
onLoadFromServer={() => loadApp()}
getLocalDraft={() => app?.value}
/>
{/if}
{#if $workspaceStore && path && otherDraftsUsers.length > 0}
{#key path}
<OtherUsersDraftsModal
workspace={$workspaceStore}
itemKind="app"
{path}
currentUserUsername={$userStore?.username}
{otherDraftsUsers}
editPathFor={(forkedPath) => `/apps/edit/${forkedPath}`}
/>
{/key}
{/if}
{#key redraw}
{#if app}
@@ -21,7 +21,20 @@
} from '$lib/userDraft.svelte'
import { notifyDraftLoaded, notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import OtherUsersDraftsModal from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import DraftSyncConflictModal from '$lib/components/common/confirmationModal/DraftSyncConflictModal.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import OtherUsersDraftsModal, {
type OtherDraftUser
} from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import RawAppTemplatePicker, {
type RawAppTemplatePickerResult
} from '$lib/components/raw_apps/RawAppTemplatePicker.svelte'
import {
react19Template,
STARTER_RUNNABLE,
STARTER_RUNNABLE_KEY
} from '$lib/components/raw_apps/templates'
import { aiChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
type RawAppDraft = {
files: Record<string, string>
@@ -55,6 +68,10 @@
| undefined = $state(undefined)
let redraw = $state(0)
let path = page.params.path ?? ''
/** Open the framework picker when the user lands on a brand-new draft
* (route flag `new_draft=true`). Lets them pick React/Svelte + data
* config + optional AI prompt before the editor goes live. */
let templatePicker = $state(false)
const draftHandle = UserDraft.use<RawAppDraft>('raw_app', path)
@@ -166,6 +183,7 @@
* exists at the URL path. Flips RawAppEditor's deploy from
* `updateApp` to `createApp` so a user-typed path is used. */
let isNewApp = $state(false)
let otherDraftsUsers = $state<OtherDraftUser[]>([])
async function loadApp(opts: { getDraft?: boolean } = {}): Promise<void> {
const getDraft = opts.getDraft ?? true
const tok = ++loadAppToken
@@ -184,20 +202,32 @@
window.history.replaceState(window.history.state, '', url.toString())
// Backend's `Policy` requires `execution_mode` — an empty
// object fails to deserialize on deploy.
const emptyPolicy = { execution_mode: 'publisher' } as any
const defaultPolicy = {
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
: `u/${$userStore?.username}`,
on_behalf_of_email: $userStore?.email,
execution_mode: 'publisher'
} as any
// Seed the React 19 template up-front so the editor mounts with
// a usable starting state even if the user dismisses the picker
// modal without an explicit selection (matches main's /add).
const seedFiles = { ...react19Template }
const seedRunnables = { [STARTER_RUNNABLE_KEY]: STARTER_RUNNABLE }
savedApp = {
summary: '',
value: { files: {}, runnables: {} },
value: { files: seedFiles as any, runnables: seedRunnables as any },
path: '',
policy: emptyPolicy,
policy: defaultPolicy,
custom_path: undefined
}
files = {}
runnables = {}
files = seedFiles
runnables = seedRunnables
data = { ...DEFAULT_DATA }
summary = ''
policy = emptyPolicy
policy = defaultPolicy
newPath = ''
templatePicker = true
return
}
const backendApp = (await AppService.getAppByPath({
@@ -207,6 +237,13 @@
rawApp: true
})) as any
if (tok !== loadAppToken) return
otherDraftsUsers = (backendApp.other_drafts_users ?? []) as OtherDraftUser[]
if ($workspaceStore && path) {
UserDraftDbSyncer.recordRemoteSync(
{ workspace: $workspaceStore, itemKind: 'raw_app', path },
backendApp.draft_saved_at as string | undefined
)
}
isNewApp = !!backendApp.no_deployed
if (backendApp.is_draft) {
notifyDraftLoaded({
@@ -389,6 +426,31 @@
}
redraw++
}
function onTemplatePickerStart(result: RawAppTemplatePickerResult, withPrompt: boolean) {
files = { ...result.files }
runnables = { ...result.runnables, [STARTER_RUNNABLE_KEY]: STARTER_RUNNABLE }
data = result.data
summary = result.summary
policy = result.policy
// Remount RawAppEditor so the UI builder iframe picks up the
// new files instead of leaving the React 19 seed on screen.
redraw++
// Sync to aiChatManager so its prompts respect the picked data config.
aiChatManager.datatableCreationPolicy = {
enabled: !!result.data.datatable,
datatable: result.data.datatable,
schema: result.data.schema
}
if (withPrompt && result.prompt) {
setTimeout(() => {
aiChatManager.changeMode(AIMode.APP)
if (!aiChatManager.open) aiChatManager.toggleOpen()
aiChatManager.instructions = result.prompt!
aiChatManager.sendRequest()
}, 500)
}
}
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
@@ -399,20 +461,26 @@
onKeepDraft={onStaleKeepDraft}
/>
{#if $workspaceStore && path}
<OtherUsersDraftsModal
workspace={$workspaceStore}
itemKind="raw_app"
{path}
currentValue={draftHandle.draft}
currentUserEmail={$userStore?.email}
{diffDrawer}
userHasLocalDraft={UserDraft.has('raw_app', path)}
onFork={(otherValue) => {
UserDraft.save('raw_app', path, otherValue, { workspace: $workspaceStore })
diffDrawer?.closeDrawer()
}}
<DraftSyncConflictModal
query={{ workspace: $workspaceStore, itemKind: 'raw_app', path }}
onLoadFromServer={() => loadApp()}
getLocalDraft={() => draftHandle.draft}
/>
{/if}
{#if $workspaceStore && path && otherDraftsUsers.length > 0}
{#key path}
<OtherUsersDraftsModal
workspace={$workspaceStore}
itemKind="raw_app"
{path}
currentUserUsername={$userStore?.username}
{otherDraftsUsers}
editPathFor={(forkedPath) => `/apps_raw/edit/${forkedPath}`}
/>
{/key}
{/if}
<RawAppTemplatePicker bind:open={templatePicker} onStart={onTemplatePickerStart} />
{#if files}
{#key redraw}
@@ -17,7 +17,11 @@
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import OtherUsersDraftsModal from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import DraftSyncConflictModal from '$lib/components/common/confirmationModal/DraftSyncConflictModal.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import OtherUsersDraftsModal, {
type OtherDraftUser
} from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
import { tick, untrack } from 'svelte'
@@ -48,6 +52,7 @@
}
let savedFlow: Flow | undefined = $state(undefined)
let otherDraftsUsers = $state<OtherDraftUser[]>([])
// Derived so client-side nav (breadcrumb) re-keys the handle to the new path.
let flowDraftPath = $derived(page.params.path ?? '')
@@ -229,6 +234,13 @@
getDraft
})
if (tok !== loadFlowToken) return
otherDraftsUsers = ((backendFlow as any).other_drafts_users ?? []) as OtherDraftUser[]
if ($workspaceStore && flowDraftPath) {
UserDraftDbSyncer.recordRemoteSync(
{ workspace: $workspaceStore, itemKind: 'flow', path: flowDraftPath },
(backendFlow as any).draft_saved_at as string | undefined
)
}
// Re-evaluate the "new flow" signal on each load — flips to
// true for draft-only paths and back to false once a deploy
// lands at this URL path.
@@ -365,20 +377,24 @@
onKeepDraft={onStaleKeepDraft}
/>
{#if $workspaceStore && flowDraftPath}
<OtherUsersDraftsModal
workspace={$workspaceStore}
itemKind="flow"
path={flowDraftPath}
currentValue={flowHandle.draft}
currentUserEmail={$userStore?.email}
{diffDrawer}
userHasLocalDraft={UserDraft.has('flow', flowDraftPath)}
onFork={(otherValue) => {
UserDraft.save('flow', flowDraftPath, otherValue, { workspace: $workspaceStore })
diffDrawer?.closeDrawer()
}}
<DraftSyncConflictModal
query={{ workspace: $workspaceStore, itemKind: 'flow', path: flowDraftPath }}
onLoadFromServer={() => loadFlow()}
getLocalDraft={() => flowHandle.draft}
/>
{/if}
{#if $workspaceStore && flowDraftPath && otherDraftsUsers.length > 0}
{#key flowDraftPath}
<OtherUsersDraftsModal
workspace={$workspaceStore}
itemKind="flow"
path={flowDraftPath}
currentUserUsername={$userStore?.username}
{otherDraftsUsers}
editPathFor={(forkedPath) => `/flows/edit/${forkedPath}`}
/>
{/key}
{/if}
{#if notFound}
<div class="flex flex-col items-center justify-center h-full">
<h1 class="text-2xl font-bold">Flow not found at path {page.params.path}</h1>
@@ -9,7 +9,9 @@
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import OtherUsersDraftsModal from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import OtherUsersDraftsModal, {
type OtherDraftUser
} from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
import { get } from 'svelte/store'
@@ -22,6 +24,8 @@
type UserDraftHandle
} from '$lib/userDraft.svelte'
import { notifyDraftLoaded, notifyRestoredFromLocal } from '$lib/userDraftToast'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import DraftSyncConflictModal from '$lib/components/common/confirmationModal/DraftSyncConflictModal.svelte'
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
@@ -86,6 +90,10 @@
let savedScript: Script | NewScript | undefined = $state(undefined)
let fullyLoaded = $state(false)
/** Other workspace users (and the legacy NULL-email row, if any) with
* a draft on this path. Populated from the deployed-overlay response
* on each `loadScript`; the banner opens when the list is non-empty. */
let otherDraftsUsers = $state<OtherDraftUser[]>([])
// 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
@@ -197,6 +205,18 @@
getDraft
})
if (tok !== loadScriptToken) return
otherDraftsUsers = ((backendScript as any).other_drafts_users ?? []) as OtherDraftUser[]
// Seed the per-tab `last_sync` map with the server's draft
// timestamp so the next autosave attaches a matching
// `last_sync` and the backend can reject stale writes.
// `undefined` (no draft existed) clears the entry — the
// next save then takes the "first push" branch on the server.
if ($workspaceStore && page.params.path) {
UserDraftDbSyncer.recordRemoteSync(
{ workspace: $workspaceStore, itemKind: 'script', path: page.params.path },
(backendScript as any).draft_saved_at as string | undefined
)
}
if (backendScript.is_draft) {
notifyDraftLoaded({
workspace: $workspaceStore!,
@@ -340,20 +360,24 @@
onKeepDraft={onStaleKeepDraft}
/>
{#if !hash && $workspaceStore && page.params.path}
<OtherUsersDraftsModal
workspace={$workspaceStore}
itemKind="script"
path={page.params.path}
currentValue={scriptHandle.draft}
currentUserEmail={$userStore?.email}
{diffDrawer}
userHasLocalDraft={UserDraft.has('script', draftPath)}
onFork={(otherValue) => {
UserDraft.save('script', draftPath, otherValue, { workspace: $workspaceStore })
diffDrawer?.closeDrawer()
}}
<DraftSyncConflictModal
query={{ workspace: $workspaceStore, itemKind: 'script', path: page.params.path }}
onLoadFromServer={() => loadScript()}
getLocalDraft={() => scriptHandle.draft}
/>
{/if}
{#if !hash && $workspaceStore && page.params.path && otherDraftsUsers.length > 0}
{#key page.params.path}
<OtherUsersDraftsModal
workspace={$workspaceStore}
itemKind="script"
path={page.params.path}
currentUserUsername={$userStore?.username}
{otherDraftsUsers}
editPathFor={(forkedPath) => `/scripts/edit/${forkedPath}`}
/>
{/key}
{/if}
{#if scriptHandle.draft && renderEditor}
<ScriptBuilder
bind:this={scriptBuilder}