mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +00:00
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:
+2
-2
@@ -5,7 +5,7 @@
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
@@ -16,7 +16,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319"
|
||||
|
||||
+5
-10
@@ -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"
|
||||
}
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270"
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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>")
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+152
-160
@@ -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]
|
||||
}}
|
||||
/>
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user