mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-18 00:02:27 +00:00
Compare commits
107
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c55320f8d | ||
|
|
3d142eded7 | ||
|
|
f7d987a1be | ||
|
|
7196568e86 | ||
|
|
63cf688849 | ||
|
|
fb05c310bb | ||
|
|
d42279801e | ||
|
|
10d1fde74f | ||
|
|
3e5a6eb454 | ||
|
|
ef9e4fe239 | ||
|
|
e4ac9abc06 | ||
|
|
308666c019 | ||
|
|
9d90c91df4 | ||
|
|
43be4d8b2d | ||
|
|
110963748e | ||
|
|
c8418ece27 | ||
|
|
972c93a718 | ||
|
|
844ec86662 | ||
|
|
7612cd7605 | ||
|
|
7c03e1a3b1 | ||
|
|
3d6c4ba7da | ||
|
|
87ba390275 | ||
|
|
9d326358ae | ||
|
|
22060b708f | ||
|
|
033d14a69a | ||
|
|
f233832550 | ||
|
|
e16ed3335b | ||
|
|
36ce4c6d44 | ||
|
|
3ba62c0ee2 | ||
|
|
65730f346e | ||
|
|
6620ef1fbd | ||
|
|
118b0c4e90 | ||
|
|
066149c6fb | ||
|
|
24c09ee99b | ||
|
|
6406d4ad69 | ||
|
|
7d93afbbd8 | ||
|
|
202b3f69d8 | ||
|
|
fec207cc99 | ||
|
|
88e6050c33 | ||
|
|
5b6dc32ee7 | ||
|
|
7ccb2b7941 | ||
|
|
2d432c6308 | ||
|
|
65b5be8eff | ||
|
|
a5faec975e | ||
|
|
e649c2b89f | ||
|
|
d65db23787 | ||
|
|
5c2209dc93 | ||
|
|
1a27f56686 | ||
|
|
655827ffa3 | ||
|
|
397256af1e | ||
|
|
30a0eeda61 | ||
|
|
9884f061e2 | ||
|
|
664cf5598a | ||
|
|
2c62d9e1fb | ||
|
|
d1f102d750 | ||
|
|
a9939af90a | ||
|
|
3132a0207a | ||
|
|
b523427473 | ||
|
|
3c3bd54148 | ||
|
|
13c0237a81 | ||
|
|
33aecebd79 | ||
|
|
a70a378cd1 | ||
|
|
7acc03b250 | ||
|
|
70263b184f | ||
|
|
145c72c970 | ||
|
|
e7e586997c | ||
|
|
42e97bb05a | ||
|
|
3301d81ed6 | ||
|
|
06b01dfe36 | ||
|
|
6d73345c4b | ||
|
|
9ca787a18b | ||
|
|
a32f257686 | ||
|
|
946dfb12f4 | ||
|
|
d844566421 | ||
|
|
1df77a076b | ||
|
|
f50971a8d5 | ||
|
|
fd2e243795 | ||
|
|
03a5e98609 | ||
|
|
9adc272437 | ||
|
|
48fafcc76b | ||
|
|
a76bb51515 | ||
|
|
b5ba3f2abb | ||
|
|
dfc1651327 | ||
|
|
614180e53a | ||
|
|
d203a16f97 | ||
|
|
2d495ef714 | ||
|
|
807e6d88d3 | ||
|
|
2f0196af26 | ||
|
|
e15b008175 | ||
|
|
2dd5c6e19f | ||
|
|
655189e087 | ||
|
|
daeab3f09f | ||
|
|
bee09613b5 | ||
|
|
079ebef72b | ||
|
|
bbe6767f24 | ||
|
|
27d43da5b9 | ||
|
|
a88f5c633e | ||
|
|
b7ca257239 | ||
|
|
1e77e46624 | ||
|
|
46cfe43e60 | ||
|
|
da692257da | ||
|
|
b56a189713 | ||
|
|
6b7c1ea87a | ||
|
|
6e29fdd5db | ||
|
|
a98a339ee6 | ||
|
|
fb221cb861 | ||
|
|
79ba0e0deb |
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"Bool",
|
||||
"Timestamptz",
|
||||
"TextArray",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft\n (workspace_id, path, value, typ)\n VALUES ($1, $2, $3::text::json, $4)\n ON CONFLICT (workspace_id, path, typ)\n DO UPDATE SET value = EXCLUDED.value, created_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE variable SET labels = $1, edited_at = now(), edited_by = $4 WHERE path = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE variable
|
||||
DROP COLUMN IF EXISTS edited_by,
|
||||
DROP COLUMN IF EXISTS edited_at;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Add `edited_at` and `edited_by` so the UI can detect when a variable has
|
||||
-- been modified remotely while a local autosave was in flight (see the
|
||||
-- UserDraft staleness check). Mirrors what `resource` already has.
|
||||
--
|
||||
-- Backfill: existing rows get `edited_at = now()` via the column's DEFAULT.
|
||||
-- All pre-migration variables therefore appear to share a single edit
|
||||
-- timestamp (the migration time). The staleness check only consumes
|
||||
-- `edited_at` as an opaque rev string — it doesn't display or sort on it —
|
||||
-- and only after the user edits a variable forward at least once. So the
|
||||
-- collision is harmless: no UI flow looks at the pre-migration timestamp
|
||||
-- before it gets overwritten by a real edit.
|
||||
|
||||
ALTER TABLE variable
|
||||
ADD COLUMN edited_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
ADD COLUMN edited_by VARCHAR(50);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Symmetric to the up migration: Postgres's default `TIMESTAMPTZ -> TIMESTAMP`
|
||||
-- cast strips the timezone by representing the instant in the session's
|
||||
-- current timezone, mirroring how the original `now()` values were
|
||||
-- truncated on insert.
|
||||
ALTER TABLE draft ALTER COLUMN created_at TYPE TIMESTAMP;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- `draft.created_at` was originally created as `TIMESTAMP` (no timezone). The
|
||||
-- new `*WithDraft` API responses surface it as `chrono::DateTime<Utc>` for the
|
||||
-- frontend's staleness check, which requires `TIMESTAMPTZ`.
|
||||
--
|
||||
-- We rely on Postgres's default `TIMESTAMP -> TIMESTAMPTZ` cast (no explicit
|
||||
-- USING), which interprets each existing wall-clock value in the session's
|
||||
-- current timezone. That's the exact semantics under which the original
|
||||
-- `INSERT ... DEFAULT now()` values were truncated to TIMESTAMP — so the
|
||||
-- conversion is a no-op on UTC servers (the common case) and correctly
|
||||
-- recovers the original instant on non-UTC servers, instead of shifting all
|
||||
-- pre-migration timestamps by the server's tz offset.
|
||||
ALTER TABLE draft ALTER COLUMN created_at TYPE TIMESTAMPTZ;
|
||||
@@ -1480,6 +1480,9 @@ pub struct FlowWDraft {
|
||||
pub extra_perms: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
|
||||
/// Timestamp at which the most recent DB draft was created.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1516,6 +1519,7 @@ async fn get_flow_by_path_w_draft(
|
||||
flow.ws_error_handler_muted,
|
||||
flow.dedicated_worker,
|
||||
draft.value AS draft,
|
||||
draft.created_at AS draft_created_at,
|
||||
flow.tag,
|
||||
flow.visible_to_runner_only,
|
||||
flow.on_behalf_of_email,
|
||||
|
||||
@@ -93,6 +93,9 @@ pub struct ScriptWDraft<SR> {
|
||||
pub tag: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
/// Timestamp at which the most recent DB draft was created.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub schema: Option<Schema>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
@@ -170,6 +173,7 @@ impl ScriptWDraft<ScriptRunnableSettingsHandle> {
|
||||
kind: self.kind,
|
||||
tag: self.tag,
|
||||
draft: self.draft,
|
||||
draft_created_at: self.draft_created_at,
|
||||
schema: self.schema,
|
||||
draft_only: self.draft_only,
|
||||
envs: self.envs,
|
||||
@@ -1821,7 +1825,7 @@ async fn get_script_by_path_w_draft(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let script_o = sqlx::query_as::<_, ScriptWDraft<ScriptRunnableSettingsHandle>>(
|
||||
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON
|
||||
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON
|
||||
script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script'
|
||||
WHERE script.path = $1 AND script.workspace_id = $2
|
||||
ORDER BY script.created_at DESC LIMIT 1",
|
||||
|
||||
@@ -9588,6 +9588,10 @@ paths:
|
||||
properties:
|
||||
draft:
|
||||
$ref: "#/components/schemas/Flow"
|
||||
draft_created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
|
||||
|
||||
/w/{workspace}/flows/exists/{path}:
|
||||
get:
|
||||
@@ -21726,6 +21730,10 @@ components:
|
||||
properties:
|
||||
draft:
|
||||
$ref: "#/components/schemas/NewScript"
|
||||
draft_created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
|
||||
hash:
|
||||
type: string
|
||||
required:
|
||||
@@ -22793,6 +22801,11 @@ components:
|
||||
type: string
|
||||
ws_specific:
|
||||
type: boolean
|
||||
edited_at:
|
||||
type: string
|
||||
format: date-time
|
||||
edited_by:
|
||||
type: string
|
||||
required:
|
||||
- workspace_id
|
||||
- path
|
||||
@@ -26789,6 +26802,10 @@ components:
|
||||
draft_only:
|
||||
type: boolean
|
||||
draft: {}
|
||||
draft_created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
|
||||
|
||||
AppHistory:
|
||||
type: object
|
||||
|
||||
@@ -217,6 +217,9 @@ pub struct AppWithLastVersionAndDraft {
|
||||
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
/// Timestamp at which the most recent DB draft was created.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -642,29 +645,30 @@ async fn get_app_w_draft(
|
||||
|
||||
let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>(
|
||||
r#"
|
||||
SELECT
|
||||
app.id,
|
||||
app.path,
|
||||
app.summary,
|
||||
app.versions,
|
||||
app.policy,
|
||||
SELECT
|
||||
app.id,
|
||||
app.path,
|
||||
app.summary,
|
||||
app.versions,
|
||||
app.policy,
|
||||
app.custom_path,
|
||||
app.extra_perms,
|
||||
app.extra_perms,
|
||||
app_version.value,
|
||||
app_version.created_at,
|
||||
app_version.created_at,
|
||||
app_version.created_by,
|
||||
app.draft_only,
|
||||
draft.value AS "draft",
|
||||
draft.created_at AS "draft_created_at",
|
||||
app_version.raw_app,
|
||||
app.labels
|
||||
FROM app
|
||||
INNER JOIN app_version
|
||||
INNER JOIN app_version
|
||||
ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||||
LEFT JOIN draft
|
||||
ON app.path = draft.path
|
||||
AND draft.workspace_id = $2
|
||||
LEFT JOIN draft
|
||||
ON app.path = draft.path
|
||||
AND draft.workspace_id = $2
|
||||
AND draft.typ = 'app'
|
||||
WHERE app.path = $1
|
||||
WHERE app.path = $1
|
||||
AND app.workspace_id = $2
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -79,7 +79,8 @@ async fn create_draft(
|
||||
"INSERT INTO draft
|
||||
(workspace_id, path, value, typ)
|
||||
VALUES ($1, $2, $3::text::json, $4)
|
||||
ON CONFLICT (workspace_id, path, typ) DO UPDATE SET value = EXCLUDED.value",
|
||||
ON CONFLICT (workspace_id, path, typ)
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()",
|
||||
&w_id,
|
||||
draft.path,
|
||||
//to preserve key orders
|
||||
|
||||
@@ -50,6 +50,10 @@ pub struct ListableVariable {
|
||||
pub labels: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ws_specific: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub edited_at: Option<chrono::DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub edited_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, sqlx::FromRow)]
|
||||
|
||||
@@ -134,6 +134,8 @@ async fn list_variables(
|
||||
"variable.expires_at",
|
||||
"variable.labels",
|
||||
"ws_specific.path IS NOT NULL as ws_specific",
|
||||
"variable.edited_at",
|
||||
"variable.edited_by",
|
||||
])
|
||||
.left()
|
||||
.join("account")
|
||||
@@ -216,6 +218,7 @@ async fn get_variable(
|
||||
"SELECT variable.workspace_id, variable.path, variable.value, variable.is_secret,
|
||||
variable.description, variable.extra_perms, variable.account, variable.is_oauth,
|
||||
variable.expires_at, variable.labels,
|
||||
variable.edited_at, variable.edited_by,
|
||||
(now() > account.expires_at) as is_expired, account.refresh_error,
|
||||
resource.path IS NOT NULL as is_linked,
|
||||
account.refresh_token != '' as is_refreshed,
|
||||
@@ -441,8 +444,8 @@ async fn create_variable(
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO variable
|
||||
(workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
(workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
&w_id,
|
||||
variable.path,
|
||||
value,
|
||||
@@ -451,7 +454,8 @@ async fn create_variable(
|
||||
variable.account,
|
||||
variable.is_oauth.unwrap_or(false),
|
||||
variable.expires_at,
|
||||
variable.labels.as_deref() as Option<&[String]>
|
||||
variable.labels.as_deref() as Option<&[String]>,
|
||||
&authed.username
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1048,6 +1052,8 @@ async fn update_variable(
|
||||
}
|
||||
|
||||
let npath = if has_sql_updates {
|
||||
sqlb.set("edited_at", "now()");
|
||||
sqlb.set_str("edited_by", &authed.username);
|
||||
sqlb.returning("path");
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let npath_o: Option<String> = sqlx::query_scalar(&sql).fetch_optional(&mut *tx).await?;
|
||||
@@ -1078,10 +1084,11 @@ async fn update_variable(
|
||||
|
||||
if let Some(nlabels) = &ns.labels {
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET labels = $1 WHERE path = $2 AND workspace_id = $3",
|
||||
"UPDATE variable SET labels = $1, edited_at = now(), edited_by = $4 WHERE path = $2 AND workspace_id = $3",
|
||||
nlabels as &[String],
|
||||
&npath,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.username
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
Generated
+18
-18
@@ -19,18 +19,18 @@
|
||||
"open": "^10.0.0",
|
||||
"svelte": "^5.45.2",
|
||||
"tar-stream": "^3.1.7",
|
||||
"windmill-parser-wasm-csharp": "*",
|
||||
"windmill-parser-wasm-go": "*",
|
||||
"windmill-parser-wasm-java": "*",
|
||||
"windmill-parser-wasm-nu": "*",
|
||||
"windmill-parser-wasm-php": "*",
|
||||
"windmill-parser-wasm-py": "^1.693.1",
|
||||
"windmill-parser-wasm-py-imports": "^1.693.1",
|
||||
"windmill-parser-wasm-regex": "*",
|
||||
"windmill-parser-wasm-ruby": "*",
|
||||
"windmill-parser-wasm-rust": "*",
|
||||
"windmill-parser-wasm-ts": "^1.693.1",
|
||||
"windmill-parser-wasm-yaml": "*",
|
||||
"windmill-parser-wasm-csharp": "1.510.1",
|
||||
"windmill-parser-wasm-go": "1.510.1",
|
||||
"windmill-parser-wasm-java": "1.510.1",
|
||||
"windmill-parser-wasm-nu": "1.510.1",
|
||||
"windmill-parser-wasm-php": "1.647.1",
|
||||
"windmill-parser-wasm-py": "1.693.1",
|
||||
"windmill-parser-wasm-py-imports": "1.693.1",
|
||||
"windmill-parser-wasm-regex": "1.692.0",
|
||||
"windmill-parser-wasm-ruby": "1.526.1",
|
||||
"windmill-parser-wasm-rust": "1.647.1",
|
||||
"windmill-parser-wasm-ts": "1.695.0",
|
||||
"windmill-parser-wasm-yaml": "1.593.0",
|
||||
"windmill-yaml-validator": "1.1.1",
|
||||
"ws": "8.18.0",
|
||||
"yaml": "^2.7.0"
|
||||
@@ -1438,9 +1438,9 @@
|
||||
"integrity": "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-regex": {
|
||||
"version": "1.639.0",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz",
|
||||
"integrity": "sha512-qvYM4sYxB6M0xrqwBljS2fWqOMk6rp++60TRltJnzZDzVaWQrKjTGwNMmfepGAIWy1OGVKp0SCVERhe2P+O6tQ=="
|
||||
"version": "1.692.0",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.692.0.tgz",
|
||||
"integrity": "sha512-BHGTxrinZJ9ef6hFxbKiBqBEr5uqgG/QySOgMA5r1LswO9n/8fyGswr8JcPT2kGaoeoweV6/RQ+RHVaOhosnKw=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-ruby": {
|
||||
"version": "1.526.1",
|
||||
@@ -1453,9 +1453,9 @@
|
||||
"integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-ts": {
|
||||
"version": "1.693.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.693.1.tgz",
|
||||
"integrity": "sha512-xrPgVWwQbOWJKiz68wBDNMrKVOtCY/utyhzSx0kFYIWm/QH/6L8k6LLjo4DOn+PnlBNRXc7qum0sDyB8IuuJYQ=="
|
||||
"version": "1.695.0",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.695.0.tgz",
|
||||
"integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-yaml": {
|
||||
"version": "1.593.0",
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# Global Mode AI Chat and UserDraft Requirements
|
||||
|
||||
## Purpose
|
||||
|
||||
This document enumerates the hard requirements that global mode AI chat places
|
||||
on the frontend `UserDraft` service. It is intentionally limited to required
|
||||
behavior and does not prescribe API names, data structures, or implementation
|
||||
strategy.
|
||||
|
||||
Global mode AI chat must use the same local draft model as the user. A draft
|
||||
modified through global mode is still a user draft. Requirements must therefore
|
||||
avoid creating a separate "AI draft" ownership model.
|
||||
|
||||
The requirements are based on the current base branch state of `UserDraft`:
|
||||
|
||||
- drafts are scoped by workspace, item kind, and storage path;
|
||||
- persisted drafts are stored in localStorage under `userdraft/w/...`;
|
||||
- live editor handles may hold draft state that is not present in
|
||||
localStorage;
|
||||
- draft values are wrapped with freshness metadata used by editor staleness
|
||||
checks;
|
||||
- existing editor flows rely on `UserDraft` for autosave, restore, metadata,
|
||||
removal, discard, and live-handle behavior;
|
||||
- the service does not provide a public way to enumerate all draft entries;
|
||||
- live editor state can include current loaded values that are useful context
|
||||
but are not necessarily meaningful unsaved draft work.
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. Existing Editor Behavior Must Be Preserved
|
||||
|
||||
All existing editor behavior that depends on `UserDraft` must remain unchanged.
|
||||
|
||||
Opening an editor, hydrating current backend state, autosaving local edits,
|
||||
restoring local edits, checking staleness, removing local drafts, and discarding
|
||||
to a fallback value must keep the same observable behavior.
|
||||
|
||||
Global mode must not cause a clean editor baseline to become deployable draft
|
||||
work.
|
||||
|
||||
### R2. Global Mode Must Share The User Draft Model
|
||||
|
||||
Drafts written or modified through global mode must be represented as normal
|
||||
user drafts.
|
||||
|
||||
Global mode must be able to read, modify, deploy, and clear the same local draft
|
||||
work that the user can act on through the editor, subject to the same workspace,
|
||||
item-kind, freshness, and confirmation constraints.
|
||||
|
||||
The system must not require two parallel local draft concepts for the same
|
||||
workspace item.
|
||||
|
||||
### R3. Meaningful Draft Work Must Be Distinguishable From Clean Context
|
||||
|
||||
Global mode must be able to distinguish meaningful local draft work from clean
|
||||
frontend-visible context.
|
||||
|
||||
Clean editor baselines and current loaded backend values may be useful read
|
||||
context, but they must not be listed, deployed, or cleared as draft work unless
|
||||
they represent actual local draft changes.
|
||||
|
||||
This distinction must not depend on whether the last change was made by a human
|
||||
or through global mode.
|
||||
|
||||
### R4. Global Mode Writes Must Be Visible Immediately
|
||||
|
||||
After global mode writes to a frontend local draft, the written value must be
|
||||
visible immediately to:
|
||||
|
||||
- any live editor handle for the same workspace, item kind, and storage path;
|
||||
- any localStorage-based lookup for that draft;
|
||||
- any global mode read, list, deploy, or delete flow that runs after the write.
|
||||
|
||||
This must hold even when a live editor handle already exists before the write.
|
||||
|
||||
### R5. Freshness Metadata Must Not Be Lost
|
||||
|
||||
Global mode writes and any later metadata updates must preserve the freshness
|
||||
metadata needed by existing editor staleness checks.
|
||||
|
||||
Freshness metadata must continue to reflect the backend state that the local
|
||||
draft is based on.
|
||||
|
||||
### R6. Draft Enumeration Must Be Possible
|
||||
|
||||
Global mode must be able to enumerate frontend local drafts for a workspace and
|
||||
a bounded set of item kinds.
|
||||
|
||||
Enumeration must include persisted-only drafts.
|
||||
|
||||
Enumeration must include live-only draft values when they represent meaningful
|
||||
local draft work.
|
||||
|
||||
Enumeration must include draft values that exist both in localStorage and in a
|
||||
live editor handle.
|
||||
|
||||
Enumeration must not return duplicate logical entries for the same workspace,
|
||||
item kind, and storage path.
|
||||
|
||||
### R7. Enumeration Must Expose Storage Identity
|
||||
|
||||
For each enumerated draft, global mode must be able to identify the exact
|
||||
workspace, item kind, and storage path used by `UserDraft`.
|
||||
|
||||
This is required because global mode operates on workspace item paths, while
|
||||
`UserDraft` storage paths are not always equal to item paths.
|
||||
|
||||
New-item editor drafts with an empty storage path must remain representable.
|
||||
|
||||
### R8. Enumeration Must Expose Persistence State
|
||||
|
||||
For each enumerated draft, global mode must be able to determine whether the
|
||||
value exists in localStorage, in a live editor handle, or in both.
|
||||
|
||||
Global mode must not need to infer persistence state by reading private
|
||||
`UserDraft` internals.
|
||||
|
||||
### R9. Current Context And Draft Work Must Be Separatable
|
||||
|
||||
Global mode must be able to distinguish:
|
||||
|
||||
- current frontend-visible local state, which may include clean editor
|
||||
baselines, loaded backend values, and local draft work;
|
||||
- meaningful local draft work, which may be read, listed, deployed, or cleared
|
||||
as draft state.
|
||||
|
||||
This distinction must be available without inspecting private `UserDraft`
|
||||
internals.
|
||||
|
||||
### R10. Clearing A Draft Must Clear Persisted And Live State
|
||||
|
||||
When global mode clears a frontend local draft, the draft must be removed from
|
||||
localStorage and from any live editor handle for the same workspace, item kind,
|
||||
and storage path.
|
||||
|
||||
The cleared live state must not be re-persisted automatically as a side effect
|
||||
of clearing.
|
||||
|
||||
Clearing a draft must not clear unrelated editor drafts or unrelated workspace
|
||||
items.
|
||||
|
||||
### R11. Existing Discard Semantics Must Remain Available
|
||||
|
||||
Editor flows must retain the ability to discard local draft state while
|
||||
restoring a provided fallback value in memory.
|
||||
|
||||
Global mode clearing requirements must not remove or weaken that editor discard
|
||||
behavior.
|
||||
|
||||
### R12. Enumeration Must Be Robust To Editor Runtime Values
|
||||
|
||||
Draft enumeration must not fail because a live editor draft contains
|
||||
runtime-only values that cannot be serialized into localStorage.
|
||||
|
||||
Enumeration may omit runtime-only details, but it must preserve the draft data
|
||||
needed for global mode to identify, read, list, and clear the draft.
|
||||
|
||||
### R13. UserDraft Must Remain Domain-Agnostic
|
||||
|
||||
`UserDraft` must remain independent of global mode workspace item semantics.
|
||||
|
||||
It must not need to know about scripts, flows, apps, schedules, triggers,
|
||||
resources, variables, deployment behavior, or chat tools.
|
||||
|
||||
Global mode-specific interpretation of item kinds and draft values must remain
|
||||
outside `UserDraft`.
|
||||
|
||||
### R14. Backend Drafts Must Remain Separate From UserDraft
|
||||
|
||||
Backend draft-only scripts, flows, and apps are not frontend `UserDraft`
|
||||
entries.
|
||||
|
||||
Global mode must not require `UserDraft` to represent backend DB drafts.
|
||||
|
||||
Global mode must be able to combine backend draft-aware reads with frontend
|
||||
`UserDraft` state without treating one storage system as the other.
|
||||
|
||||
### R15. Cross-Workspace Isolation Must Be Preserved
|
||||
|
||||
Draft reads, writes, enumeration, and clearing must remain scoped to the target
|
||||
workspace.
|
||||
|
||||
Global mode must not see, modify, deploy, or clear drafts from another
|
||||
workspace.
|
||||
|
||||
### R16. Item-Kind Boundaries Must Be Preserved
|
||||
|
||||
Draft reads, writes, enumeration, and clearing must remain scoped to the target
|
||||
item kind.
|
||||
|
||||
Global mode must not treat a draft of one item kind as a draft of another item
|
||||
kind.
|
||||
|
||||
### R17. Regression Coverage Is Required
|
||||
|
||||
The UserDraft-only change must include tests covering:
|
||||
|
||||
- preservation of existing editor behavior;
|
||||
- global mode writes sharing the same draft state as editor writes;
|
||||
- clean editor baselines not being treated as draft work;
|
||||
- persisted-only draft enumeration;
|
||||
- live-only draft enumeration when it represents meaningful draft work;
|
||||
- combined persisted and live draft enumeration without duplicates;
|
||||
- storage path exposure when storage path differs from item path;
|
||||
- global mode writes becoming visible to live handles and localStorage;
|
||||
- clearing removing both persisted and live state;
|
||||
- freshness metadata preservation;
|
||||
- cross-workspace isolation;
|
||||
- item-kind isolation;
|
||||
- enumeration robustness when live draft values contain runtime-only data.
|
||||
@@ -1916,6 +1916,25 @@
|
||||
})
|
||||
})
|
||||
|
||||
// External `code` prop changes should flow into the Monaco editor. The
|
||||
// `untrack` block reads/writes Monaco without subscribing — only the
|
||||
// prop read above is tracked — so the editor's own change handler
|
||||
// (`updateCode`) re-running with the same value short-circuits and we
|
||||
// don't loop.
|
||||
$effect(() => {
|
||||
const next = code ?? ''
|
||||
const ed = editor
|
||||
if (!ed) return
|
||||
untrack(() => {
|
||||
if (ed.getValue() === next) return
|
||||
const model = ed.getModel()
|
||||
if (!model) return
|
||||
ed.pushUndoStop()
|
||||
ed.executeEdits('external', [{ range: model.getFullModelRange(), text: next }])
|
||||
ed.pushUndoStop()
|
||||
})
|
||||
})
|
||||
|
||||
let isTsWorkerInitialized = resource([() => lang, () => initialized], async () => {
|
||||
if (lang !== 'typescript' || !initialized) return false
|
||||
// Use the stable model URI (computed once at mount), not filePath which changes on rename
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
encodeState,
|
||||
generateRandomString,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively,
|
||||
@@ -291,12 +290,6 @@
|
||||
loadingDraft = true
|
||||
try {
|
||||
const flow = cleanFlow(flowStore.val)
|
||||
try {
|
||||
localStorage.removeItem('flow')
|
||||
localStorage.removeItem(`flow-${$pathStore}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
if (newFlow || savedFlow?.draft_only) {
|
||||
if (savedFlow?.draft_only) {
|
||||
await FlowService.deleteFlowByPath({
|
||||
@@ -480,12 +473,6 @@
|
||||
// return
|
||||
|
||||
if (newFlow) {
|
||||
try {
|
||||
localStorage.removeItem('flow')
|
||||
localStorage.removeItem(`flow-${$pathStore}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
await FlowService.createFlow({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
@@ -523,12 +510,6 @@
|
||||
)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
localStorage.removeItem(`flow-${initialPath}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
|
||||
if (triggersToDeploy) {
|
||||
await deployTriggers(
|
||||
triggersToDeploy,
|
||||
@@ -578,32 +559,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let timeout: number | undefined = undefined
|
||||
|
||||
function saveSessionDraft() {
|
||||
timeout && clearTimeout(timeout)
|
||||
timeout = window.setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
initialPath && initialPath != '' ? `flow-${initialPath}` : 'flow',
|
||||
encodeState({
|
||||
flow: flowStore.val,
|
||||
path: $pathStore,
|
||||
selectedId: selectedIdStore,
|
||||
draft_triggers: triggersState.getDraftTriggersSnapshot(),
|
||||
selected_trigger: triggersState.getSelectedTriggerSnapshot(),
|
||||
loadedFromHistory: {
|
||||
flowJobInitial: stepHistoryLoader.flowJobInitial,
|
||||
stepsState: stepHistoryLoader.stepStates
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const selectionManager = new SelectionManager()
|
||||
const selectedIdStore = $derived(selectionManager.getSelectedId())
|
||||
// Initialize with selected id if provided
|
||||
@@ -694,8 +649,7 @@
|
||||
{ type: 'default_email', path: '', isDraft: false },
|
||||
...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? [])
|
||||
],
|
||||
untrack(() => selectedTriggerIndexFromUrl),
|
||||
saveSessionDraft
|
||||
untrack(() => selectedTriggerIndexFromUrl)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1022,7 +976,6 @@
|
||||
$effect.pre(() => {
|
||||
if (flowStore.val || selectedIdStore) {
|
||||
readFieldsRecursively(flowStore.val)
|
||||
untrack(() => saveSessionDraft())
|
||||
}
|
||||
})
|
||||
// Sync `$pathStore` from `flowStore.val.path` (which `initFlow` populates
|
||||
@@ -1063,7 +1016,7 @@
|
||||
let stepHistoryLoader = new StepHistoryLoader(
|
||||
untrack(() => loadedFromHistoryFromUrl)?.stepsState ?? {},
|
||||
untrack(() => loadedFromHistoryFromUrl)?.flowJobInitial,
|
||||
saveSessionDraft,
|
||||
undefined,
|
||||
untrack(() => noInitial)
|
||||
)
|
||||
setStepHistoryLoaderContext(stepHistoryLoader)
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
|
||||
interface Props {
|
||||
canSave?: boolean
|
||||
@@ -48,11 +51,82 @@
|
||||
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
|
||||
let initialPath = path
|
||||
|
||||
let states: Record<string, ResourceState> = $state({})
|
||||
// Per-workspace handles are driven by `useMany`. We track the workspace
|
||||
// IDs (and their seeded defaults) in a parallel `$state` array; on every
|
||||
// mutation `useMany` reconciles, acquiring entries for new workspaces and
|
||||
// releasing them on component teardown. `states` indexes the resulting
|
||||
// handles by workspace ID for ergonomic lookup downstream.
|
||||
let workspaceSpecs = $state<Array<{ ws: string; defaultValue: ResourceState }>>([])
|
||||
let initialStates: Record<string, ResourceState> = $state({})
|
||||
let existedInitially: Record<string, boolean> = $state({})
|
||||
let fetchedResources: Record<string, Resource> = $state({})
|
||||
let perWsUser: Record<string, UserExt | undefined> = $state({})
|
||||
// Backend `edited_at` per workspace — the rev the staleness check
|
||||
// compares the local autosave's recorded rev against. Resources have
|
||||
// no DB-draft concept, so only `remoteRev` is ever populated.
|
||||
let fetchedRev: Record<string, string | undefined> = $state({})
|
||||
|
||||
// Local-draft staleness modal: opened when the backend resource moved
|
||||
// on (someone else edited it) since the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let pendingStale: { ws: string; backend: ResourceState } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingStale) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { ws, backend } = pendingStale
|
||||
// Drop the divergent autosave and reset the handle to the freshly
|
||||
// fetched backend state. A later edit re-creates the autosave and
|
||||
// the seeding effect records the new rev.
|
||||
UserDraft.discard('resource', initialPath ?? '', backend, { workspace: ws })
|
||||
initialStates[ws] = $state.snapshot(backend) as ResourceState
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingStale) {
|
||||
const { ws } = pendingStale
|
||||
// Ack the new backend rev so the modal doesn't fire again until
|
||||
// the backend moves once more. Keeps the local autosave intact.
|
||||
UserDraft.saveMeta(
|
||||
'resource',
|
||||
initialPath ?? '',
|
||||
{ remoteRev: fetchedRev[ws] },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
const handlesArray = UserDraft.useMany<ResourceState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
itemKind: 'resource' as const,
|
||||
path: initialPath ?? '',
|
||||
workspace: s.ws,
|
||||
defaultValue: s.defaultValue
|
||||
}))
|
||||
)
|
||||
const states = $derived.by(() => {
|
||||
const out: Record<string, UserDraftHandle<ResourceState>> = {}
|
||||
for (let i = 0; i < workspaceSpecs.length; i++) {
|
||||
const handle = handlesArray[i]
|
||||
if (handle) out[workspaceSpecs[i].ws] = handle
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** Register a workspace so `useMany` acquires (or reuses) its handle.
|
||||
* `defaultValue` is what the handle reports when no autosave is persisted;
|
||||
* an existing autosave always wins. The default itself never round-trips
|
||||
* to localStorage — only the user's first real edit triggers a write. */
|
||||
function ensureHandle(ws: string, defaultValue: ResourceState): void {
|
||||
if (workspaceSpecs.some((s) => s.ws === ws)) return
|
||||
workspaceSpecs.push({ ws, defaultValue })
|
||||
}
|
||||
|
||||
let isValid = $state(true)
|
||||
let jsonError = $state('')
|
||||
@@ -85,7 +159,7 @@
|
||||
})
|
||||
let loadingSchema = $derived(resourceTypeResource.loading)
|
||||
|
||||
let current = $derived(selected ? states[selected] : undefined)
|
||||
let current = $derived(selected ? states[selected]?.draft : undefined)
|
||||
let resourceToEdit: Resource | undefined = $derived(
|
||||
selected ? fetchedResources[selected] : undefined
|
||||
)
|
||||
@@ -107,7 +181,7 @@
|
||||
)
|
||||
|
||||
const dirtyWorkspaces = $derived(
|
||||
Object.keys(states).filter((ws) => !deepEqual(states[ws], initialStates[ws]))
|
||||
Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws]))
|
||||
)
|
||||
const anyDirty = $derived(dirtyWorkspaces.length > 0)
|
||||
const otherDirty = $derived(
|
||||
@@ -121,7 +195,11 @@
|
||||
const r = fetchedResources[ws]
|
||||
return (
|
||||
!r ||
|
||||
canWrite(states[ws]?.path ?? initialPath, r.extra_perms ?? {}, perWsUser[ws] ?? $userStore)
|
||||
canWrite(
|
||||
states[ws]?.draft?.path ?? initialPath,
|
||||
r.extra_perms ?? {},
|
||||
perWsUser[ws] ?? $userStore
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -143,7 +221,7 @@
|
||||
labels: undefined,
|
||||
wsSpecific: false
|
||||
}
|
||||
states[effectiveWorkspace] = s
|
||||
ensureHandle(effectiveWorkspace, s)
|
||||
initialStates[effectiveWorkspace] = structuredClone(s)
|
||||
existedInitially[effectiveWorkspace] = false
|
||||
}
|
||||
@@ -161,6 +239,7 @@
|
||||
getUserExt(ws)
|
||||
]).then(([r, user]) => {
|
||||
fetchedResources[ws] = r
|
||||
fetchedRev[ws] = r.edited_at
|
||||
const s: ResourceState = {
|
||||
path: r.path,
|
||||
description: r.description ?? '',
|
||||
@@ -168,7 +247,40 @@
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
}
|
||||
states[ws] = s
|
||||
// Reconcile the local autosave with the backend before the
|
||||
// handle is registered. If the backend moved on since the
|
||||
// autosave was written (recorded rev != current rev) surface
|
||||
// the staleness modal; otherwise the form is just showing the
|
||||
// user's unsaved work — a toast with a "Reset to deployed"
|
||||
// escape is enough.
|
||||
const persisted = UserDraft.get<ResourceState>('resource', initialPath ?? '', {
|
||||
workspace: ws
|
||||
})
|
||||
const previousMeta = UserDraft.getMeta('resource', initialPath ?? '', { workspace: ws })
|
||||
if (persisted !== undefined && !deepEqual(persisted, s)) {
|
||||
const cause = checkStaleness(previousMeta, r.edited_at)
|
||||
if (cause) {
|
||||
pendingStale = { ws, backend: s }
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy autosave (no rev recorded) — backfill so the
|
||||
// next backend change is detectable as drift.
|
||||
UserDraft.saveMeta(
|
||||
'resource',
|
||||
initialPath ?? '',
|
||||
{ remoteRev: r.edited_at },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToDeployed: () => {
|
||||
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
existedInitially[ws] = true
|
||||
perWsUser[ws] = user
|
||||
@@ -180,6 +292,25 @@
|
||||
})
|
||||
})
|
||||
|
||||
// Seed the staleness rev the moment a real autosave appears. Until the
|
||||
// user's first edit diverges the handle's draft from the backend
|
||||
// baseline there's no autosave to attach a rev to; once it does, record
|
||||
// the backend rev captured at fetch time so a later external edit is
|
||||
// detectable as drift on the next open. Self-limiting: after the write
|
||||
// `meta.remoteRev` is set so the guard fails on the re-run.
|
||||
$effect(() => {
|
||||
for (const ws of Object.keys(states)) {
|
||||
const h = states[ws]
|
||||
const rev = fetchedRev[ws]
|
||||
const baseline = initialStates[ws]
|
||||
if (!h || rev === undefined || baseline === undefined) continue
|
||||
const draft = h.draft
|
||||
if (draft === undefined || deepEqual(draft, baseline)) continue
|
||||
if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue
|
||||
untrack(() => h.setMeta({ remoteRev: rev }))
|
||||
}
|
||||
})
|
||||
|
||||
// Keep current.path bound to the outer `path` prop for consumers
|
||||
$effect(() => {
|
||||
if (current) path = current.path
|
||||
@@ -215,7 +346,7 @@
|
||||
const dirty = dirtyWorkspaces
|
||||
try {
|
||||
for (const ws of dirty) {
|
||||
const s = states[ws]
|
||||
const s = states[ws].draft!
|
||||
const ini = initialStates[ws]
|
||||
if (existedInitially[ws]) {
|
||||
await ResourceService.updateResource({
|
||||
@@ -246,6 +377,13 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
// Saved on the backend — drop the local autosave for this
|
||||
// workspace and refresh the dirty baseline. `s` is the
|
||||
// UserDraft handle's draft, a Svelte $state proxy;
|
||||
// `structuredClone` can't clone a proxy, so snapshot it to a
|
||||
// plain object first.
|
||||
initialStates[ws] = $state.snapshot(s) as ResourceState
|
||||
UserDraft.remove('resource', initialPath ?? '', { workspace: ws })
|
||||
}
|
||||
sendUserToast(
|
||||
dirty.length > 1 ? `Saved resource in ${dirty.length} workspaces` : `Saved resource`
|
||||
@@ -257,6 +395,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause="version"
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col gap-6 py-2">
|
||||
{#if otherDirty.length > 0}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
const bubble = createBubbler()
|
||||
import {
|
||||
DraftService,
|
||||
type NewScript,
|
||||
ScriptService,
|
||||
type NewScriptWithDraft,
|
||||
type Script,
|
||||
@@ -35,7 +34,6 @@
|
||||
cleanValueProperties,
|
||||
emptySchema,
|
||||
emptyString,
|
||||
encodeState,
|
||||
generateRandomString,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively,
|
||||
@@ -110,7 +108,6 @@
|
||||
savedScript = $bindable(undefined),
|
||||
searchParams = new URLSearchParams(),
|
||||
disableHistoryChange = false,
|
||||
replaceStateFn = (url) => window.history.replaceState(null, '', url),
|
||||
customUi = {},
|
||||
savedPrimarySchedule = undefined,
|
||||
functionExports = undefined,
|
||||
@@ -258,15 +255,11 @@
|
||||
|
||||
// Add triggers context store
|
||||
const triggersState = $state(
|
||||
new Triggers(
|
||||
[
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'default_email', path: '', isDraft: false },
|
||||
...(script.draft_triggers ?? [])
|
||||
],
|
||||
undefined,
|
||||
saveSessionDraft
|
||||
)
|
||||
new Triggers([
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'default_email', path: '', isDraft: false },
|
||||
...(script.draft_triggers ?? [])
|
||||
])
|
||||
)
|
||||
|
||||
const captureOn = writable<boolean | undefined>(undefined)
|
||||
@@ -330,28 +323,6 @@
|
||||
let loadingSave = $state(false)
|
||||
let loadingDraft = $state(false)
|
||||
|
||||
let timeout2: number | undefined = undefined
|
||||
function encodeScriptState(script: NewScript) {
|
||||
untrack(() => timeout2 && clearTimeout(timeout2))
|
||||
timeout2 = setTimeout(() => {
|
||||
replaceStateFn(
|
||||
'#' +
|
||||
encodeState({
|
||||
...script,
|
||||
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
})
|
||||
)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
let timeout: number | undefined = undefined
|
||||
function saveSessionDraft() {
|
||||
timeout && clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
encodeScriptState(script)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
if (script.content == '') {
|
||||
if (template === 'wac_python') {
|
||||
script.modules = {
|
||||
@@ -514,11 +485,6 @@
|
||||
|
||||
loadingSave = true
|
||||
try {
|
||||
try {
|
||||
localStorage.removeItem(script.path)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
script.schema = script.schema ?? emptySchema()
|
||||
try {
|
||||
const result = await inferArgs(
|
||||
@@ -654,11 +620,6 @@
|
||||
|
||||
loadingDraft = true
|
||||
try {
|
||||
try {
|
||||
localStorage.removeItem(script.path)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
script.schema = script.schema ?? emptySchema()
|
||||
try {
|
||||
const result = await inferArgs(
|
||||
@@ -1037,7 +998,15 @@
|
||||
})
|
||||
$effect(() => {
|
||||
readFieldsRecursively(script)
|
||||
!disableHistoryChange && encodeScriptState(script)
|
||||
})
|
||||
// Mirror the draft triggers (held in a separate `triggersState` $state)
|
||||
// back into `script.draft_triggers` so the UserDraft autosave — which
|
||||
// deep-tracks `script` — picks them up. Pre-PR ScriptBuilder ran its own
|
||||
// localStorage autosave that explicitly snapshotted triggersState; the
|
||||
// switch to a unified UserDraft handle dropped that bridge.
|
||||
$effect(() => {
|
||||
readFieldsRecursively(triggersState.triggers)
|
||||
script.draft_triggers = triggersState.getDraftTriggersSnapshot()
|
||||
})
|
||||
|
||||
loadWorkerTags()
|
||||
|
||||
@@ -2061,11 +2061,6 @@
|
||||
if (activeModuleTab === null) {
|
||||
await inferSchema(editorCode)
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(path ?? 'last_save', activeModuleTab === null ? editorCode : code)
|
||||
} catch (e) {
|
||||
console.error('Could not save last_save to local storage', e)
|
||||
}
|
||||
dispatch('format')
|
||||
}}
|
||||
class="flex flex-1 h-full !overflow-visible"
|
||||
|
||||
@@ -82,16 +82,24 @@
|
||||
}
|
||||
})
|
||||
|
||||
let color = classes[untrack(() => type)]
|
||||
// Defensive: a miscall like `sendUserToast(msg, err)` passes a non-
|
||||
// AlertType as `type`. Without a fallback the `classes[type]` lookup
|
||||
// returns undefined and `color.descriptionClass` throws — and because
|
||||
// the toast renders inside the root layout, that crashes the whole
|
||||
// page instead of just dropping one toast. Coerce anything unknown to
|
||||
// 'error' (a bad type almost always accompanies an error path).
|
||||
const safeType: ToastType = untrack(() => (type in classes ? type : 'error'))
|
||||
|
||||
let color = classes[safeType]
|
||||
|
||||
let containerClass = {
|
||||
success: 'toast-success',
|
||||
error: 'toast-error',
|
||||
info: 'toast-info',
|
||||
warning: 'toast-warning'
|
||||
}[untrack(() => type)]
|
||||
}[safeType]
|
||||
|
||||
let Icon = $derived(icons[type])
|
||||
let Icon = icons[safeType]
|
||||
|
||||
let showMore = $state(false)
|
||||
const MAX_MSG_LEN = 160
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -27,13 +30,79 @@
|
||||
|
||||
let editPath: string | undefined = $state(undefined)
|
||||
|
||||
let states: Record<string, VariableState> = $state({})
|
||||
// Per-workspace handles are driven by `useMany`. We track the workspace
|
||||
// IDs (and their seeded defaults) in a parallel `$state` array; on every
|
||||
// mutation `useMany` reconciles, acquiring entries for new workspaces and
|
||||
// releasing them on component teardown. `states` indexes the resulting
|
||||
// handles by workspace ID for ergonomic lookup downstream.
|
||||
let workspaceSpecs = $state<Array<{ ws: string; defaultValue: VariableState }>>([])
|
||||
let initialStates: Record<string, VariableState> = $state({})
|
||||
let existedInitially: Record<string, boolean> = $state({})
|
||||
let extraPerms: Record<string, Record<string, boolean>> = $state({})
|
||||
let perWsUser: Record<string, UserExt | undefined> = $state({})
|
||||
let selected: string | undefined = $state(undefined)
|
||||
let pathError = $state('')
|
||||
// Backend `edited_at` per workspace — the rev the staleness check
|
||||
// compares the local autosave's recorded rev against. Variables have
|
||||
// no DB-draft concept, so only `remoteRev` is ever populated.
|
||||
let fetchedRev: Record<string, string | undefined> = $state({})
|
||||
|
||||
// Local-draft staleness modal: opened when the backend variable moved
|
||||
// on (someone else edited it) since the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let pendingStale: { ws: string; backend: VariableState } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingStale) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { ws, backend } = pendingStale
|
||||
UserDraft.discard('variable', editPath ?? '', backend, { workspace: ws })
|
||||
initialStates[ws] = $state.snapshot(backend) as VariableState
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingStale) {
|
||||
const { ws } = pendingStale
|
||||
UserDraft.saveMeta(
|
||||
'variable',
|
||||
editPath ?? '',
|
||||
{ remoteRev: fetchedRev[ws] },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
const handlesArray = UserDraft.useMany<VariableState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
itemKind: 'variable' as const,
|
||||
path: editPath ?? '',
|
||||
workspace: s.ws,
|
||||
defaultValue: s.defaultValue
|
||||
}))
|
||||
)
|
||||
const states = $derived.by(() => {
|
||||
const out: Record<string, UserDraftHandle<VariableState>> = {}
|
||||
for (let i = 0; i < workspaceSpecs.length; i++) {
|
||||
const handle = handlesArray[i]
|
||||
if (handle) out[workspaceSpecs[i].ws] = handle
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** Register a workspace so `useMany` acquires (or reuses) its handle.
|
||||
* `defaultValue` is what the handle reports when no autosave is persisted;
|
||||
* an existing autosave always wins. The default itself never round-trips
|
||||
* to localStorage — only the user's first real edit triggers a write. */
|
||||
function ensureHandle(ws: string, defaultValue: VariableState): void {
|
||||
if (workspaceSpecs.some((s) => s.ws === ws)) return
|
||||
workspaceSpecs.push({ ws, defaultValue })
|
||||
}
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
let form: VariableForm | undefined = $state()
|
||||
@@ -47,7 +116,7 @@
|
||||
const MAX_VARIABLE_LENGTH = 10000
|
||||
const edit = $derived(editPath !== undefined)
|
||||
const initialPath = $derived(editPath ?? '')
|
||||
const current = $derived(selected ? states[selected] : undefined)
|
||||
const current = $derived(selected ? states[selected]?.draft : undefined)
|
||||
const can_write = $derived.by(() => {
|
||||
if (!selected || !edit) return true
|
||||
const perms = extraPerms[selected]
|
||||
@@ -55,7 +124,7 @@
|
||||
return canWrite(editPath ?? '', perms, perWsUser[selected] ?? $userStore)
|
||||
})
|
||||
const dirtyWorkspaces = $derived(
|
||||
Object.keys(states).filter((ws) => !deepEqual(states[ws], initialStates[ws]))
|
||||
Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws]))
|
||||
)
|
||||
const anyDirty = $derived(dirtyWorkspaces.length > 0)
|
||||
const otherDirty = $derived(
|
||||
@@ -64,7 +133,10 @@
|
||||
: dirtyWorkspaces
|
||||
)
|
||||
const dirtyValid = $derived(
|
||||
dirtyWorkspaces.every((ws) => states[ws].variable.value.length <= MAX_VARIABLE_LENGTH)
|
||||
dirtyWorkspaces.every((ws) => {
|
||||
const v = states[ws].draft
|
||||
return !!v && v.variable.value.length <= MAX_VARIABLE_LENGTH
|
||||
})
|
||||
)
|
||||
const dirtyCanWrite = $derived(
|
||||
dirtyWorkspaces.every((ws) => {
|
||||
@@ -84,6 +156,7 @@
|
||||
VariableService.getVariable({ workspace: ws, path: p, decryptSecret: false }),
|
||||
getUserExt(ws)
|
||||
]).then(([v, user]) => {
|
||||
fetchedRev[ws] = v.edited_at
|
||||
const s: VariableState = {
|
||||
path: v.path,
|
||||
variable: {
|
||||
@@ -94,7 +167,29 @@
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
}
|
||||
states[ws] = s
|
||||
// See ResourceEditor for the same pattern: a backend that
|
||||
// moved on since the autosave was written → staleness modal;
|
||||
// otherwise just a "showing your local autosave" toast with
|
||||
// a "Reset to deployed" escape.
|
||||
const persisted = UserDraft.get<VariableState>('variable', p, { workspace: ws })
|
||||
const previousMeta = UserDraft.getMeta('variable', p, { workspace: ws })
|
||||
if (persisted !== undefined && !deepEqual(persisted, s)) {
|
||||
const cause = checkStaleness(previousMeta, v.edited_at)
|
||||
if (cause) {
|
||||
pendingStale = { ws, backend: s }
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws })
|
||||
}
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToDeployed: () => {
|
||||
UserDraft.discard('variable', p, s, { workspace: ws })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
existedInitially[ws] = true
|
||||
extraPerms[ws] = v.extra_perms ?? {}
|
||||
@@ -103,8 +198,26 @@
|
||||
})
|
||||
})
|
||||
|
||||
// Seed the staleness rev once a real autosave appears (see
|
||||
// ResourceEditor for the rationale). Self-limiting via the
|
||||
// meta-already-set guard.
|
||||
$effect(() => {
|
||||
for (const ws of Object.keys(states)) {
|
||||
const h = states[ws]
|
||||
const rev = fetchedRev[ws]
|
||||
const baseline = initialStates[ws]
|
||||
if (!h || rev === undefined || baseline === undefined) continue
|
||||
const draft = h.draft
|
||||
if (draft === undefined || deepEqual(draft, baseline)) continue
|
||||
if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue
|
||||
untrack(() => h.setMeta({ remoteRev: rev }))
|
||||
}
|
||||
})
|
||||
|
||||
function reset() {
|
||||
states = {}
|
||||
// Clearing workspaceSpecs triggers useMany's reconcile to release
|
||||
// every acquired entry. The $derived `states` then collapses to {}.
|
||||
workspaceSpecs = []
|
||||
initialStates = {}
|
||||
existedInitially = {}
|
||||
extraPerms = {}
|
||||
@@ -122,7 +235,7 @@
|
||||
labels: undefined,
|
||||
wsSpecific: false
|
||||
}
|
||||
states[ws] = s
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(s)
|
||||
existedInitially[ws] = false
|
||||
selected = ws
|
||||
@@ -143,7 +256,7 @@
|
||||
path: editPath,
|
||||
decryptSecret: true
|
||||
})
|
||||
const s = states[selected]
|
||||
const s = states[selected]?.draft
|
||||
const ini = initialStates[selected]
|
||||
if (s) s.variable.value = getV.value ?? ''
|
||||
if (ini) ini.variable.value = getV.value ?? ''
|
||||
@@ -154,7 +267,7 @@
|
||||
const dirty = dirtyWorkspaces
|
||||
try {
|
||||
for (const ws of dirty) {
|
||||
const s = states[ws]
|
||||
const s = states[ws].draft!
|
||||
const ini = initialStates[ws]
|
||||
if (existedInitially[ws]) {
|
||||
await VariableService.updateVariable({
|
||||
@@ -186,6 +299,8 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
// Saved on the backend — drop the local autosave for this workspace.
|
||||
UserDraft.remove('variable', editPath ?? '', { workspace: ws })
|
||||
}
|
||||
sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`)
|
||||
dispatch('create')
|
||||
@@ -196,6 +311,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause="version"
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
<Drawer bind:this={drawer} size="50rem">
|
||||
<DrawerContent
|
||||
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
|
||||
|
||||
@@ -29,11 +29,12 @@
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
classNames,
|
||||
encodeState,
|
||||
getModifierKey,
|
||||
readFieldsRecursively,
|
||||
sendUserToast,
|
||||
urlParamsToObject
|
||||
} from '$lib/utils'
|
||||
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import AppPreview from './AppPreview.svelte'
|
||||
import ComponentList from './componentsPanel/ComponentList.svelte'
|
||||
import ContextPanel from './contextPanel/ContextPanel.svelte'
|
||||
@@ -77,13 +78,55 @@
|
||||
replaceStateFn = (path: string) => window.history.replaceState(null, '', path),
|
||||
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
|
||||
unsavedConfirmationModal,
|
||||
onSavedNewAppPath
|
||||
onSavedNewAppPath,
|
||||
initialRevs
|
||||
}: AppEditorProps = $props()
|
||||
|
||||
migrateApp(untrack(() => app))
|
||||
|
||||
const stateApp = $state(untrack(() => app))
|
||||
const appDraftPath = newApp ? '' : (path ?? '')
|
||||
const appDraftHandle = UserDraft.use<App>('app', appDraftPath)
|
||||
// Prefer the persisted autosave over the prop when both exist (e.g.
|
||||
// /apps/add reload: the route always initializes `app` to an empty
|
||||
// template, but the user's last session is sitting in LS under the
|
||||
// empty-path entry). The route is responsible for wiping the entry
|
||||
// (`UserDraft.remove`) when it wants to force a fresh start —
|
||||
// `?nodraft=true`, template/hub loads, etc.
|
||||
const stateApp = $state(untrack(() => appDraftHandle.draft ?? app))
|
||||
const appStore = writable<App>(stateApp)
|
||||
// Captured once on mount: the load-time revs are only used as the
|
||||
// seed meta on the very first persist of this entry. After that the
|
||||
// handle's own meta wins.
|
||||
const capturedInitialRevs = untrack(() => initialRevs)
|
||||
// `useLocalStorageValue`'s `saveInitialValue: false` skips the first
|
||||
// `set val` that DIFFERS from the loaded LS state — meant to absorb a
|
||||
// route's "load baseline" write. In AppEditor's $effect-mirror pattern
|
||||
// the loaded baseline always matches LS (stateApp is initialised from
|
||||
// the handle's draft), so the skip slot survives until the user's
|
||||
// FIRST edit and silently swallows it. Consume the slot up-front with
|
||||
// a wipe-then-restore pair: the wipe sets state.val = undefined
|
||||
// in-memory (the consumption side-effect of skipNextWrite, which
|
||||
// suppresses the localStorage delete the wipe would otherwise schedule),
|
||||
// and the restore immediately puts the value+meta back. Net effect: LS
|
||||
// gets re-written once on mount and user edits persist normally.
|
||||
let firstMirror = true
|
||||
$effect(() => {
|
||||
readFieldsRecursively(stateApp)
|
||||
untrack(() => {
|
||||
// Resolve the meta to attach BEFORE the wipe — the wipe clears
|
||||
// in-memory meta and would otherwise force-seed `initialRevs`
|
||||
// even when the handle had real meta.
|
||||
const currentMeta = appDraftHandle.meta
|
||||
const hasMeta =
|
||||
currentMeta.remoteRev !== undefined || currentMeta.remoteDraftRev !== undefined
|
||||
const meta: UserDraftMeta = hasMeta ? currentMeta : (capturedInitialRevs ?? {})
|
||||
if (firstMirror) {
|
||||
firstMirror = false
|
||||
appDraftHandle.setDraftAndMeta(undefined, {})
|
||||
}
|
||||
appDraftHandle.setDraftAndMeta(stateApp, meta)
|
||||
})
|
||||
})
|
||||
const selectedComponent = writable<string[] | undefined>(undefined)
|
||||
|
||||
// $: selectedComponent.subscribe((s) => {
|
||||
@@ -166,7 +209,7 @@
|
||||
runnableComponents: writable({}),
|
||||
appPath: writablePath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
onchange: () => saveFrontendDraft(),
|
||||
onchange: undefined,
|
||||
isEditor: true,
|
||||
jobs: writable([]),
|
||||
staticExporter: writable({}),
|
||||
@@ -219,19 +262,6 @@
|
||||
stylePanel: () => StylePanel
|
||||
})
|
||||
|
||||
let timeout: number | undefined = undefined
|
||||
|
||||
function saveFrontendDraft() {
|
||||
timeout && clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(path != '' ? `app-${path}` : 'app', encodeState($appStore))
|
||||
} catch (err) {
|
||||
console.error('Error storing frontend draft in localStorage', err)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function hashchange(e: HashChangeEvent) {
|
||||
context.hash = e.newURL.split('#')[1]
|
||||
context = context
|
||||
@@ -754,9 +784,6 @@
|
||||
$effect(() => {
|
||||
path && untrack(() => onPathChange())
|
||||
})
|
||||
$effect(() => {
|
||||
$appStore && untrack(() => saveFrontendDraft())
|
||||
})
|
||||
$effect(() => {
|
||||
context.mode = $mode == 'dnd' ? 'editor' : 'viewer'
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { AppService, DraftService, type Policy } from '$lib/gen'
|
||||
import { redo, undo } from '$lib/history.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { isMac, type Item, userPathPrefix } from '$lib/utils'
|
||||
import { random_adj } from '$lib/components/random_positive_adjetive'
|
||||
@@ -213,11 +214,7 @@
|
||||
}
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
try {
|
||||
localStorage.removeItem(`app-${path}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
UserDraft.remove('app', path)
|
||||
onSavedNewAppPath?.(path)
|
||||
} catch (e) {
|
||||
sendUserToast('Error creating app', e)
|
||||
@@ -317,12 +314,8 @@
|
||||
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
UserDraft.remove('app', $appPath)
|
||||
if ($appPath !== npath) {
|
||||
try {
|
||||
localStorage.removeItem(`app-${appPath}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
onSavedNewAppPath?.(npath)
|
||||
}
|
||||
}
|
||||
@@ -394,6 +387,10 @@
|
||||
}
|
||||
|
||||
draftDrawerOpen = false
|
||||
// The initial draft was promoted to a real path on the backend —
|
||||
// drop the autosave keyed on the prior (possibly empty) path so
|
||||
// a future "+ App" click opens on a clean slate.
|
||||
UserDraft.remove('app', $appPath)
|
||||
onSavedNewAppPath?.(newEditedPath)
|
||||
} catch (e) {
|
||||
sendUserToast('Error saving initial draft', e)
|
||||
@@ -484,11 +481,7 @@
|
||||
}
|
||||
|
||||
sendUserToast('Draft saved')
|
||||
try {
|
||||
localStorage.removeItem(`app-${path}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
UserDraft.remove('app', path)
|
||||
loading.saveDraft = false
|
||||
if (newApp || savedApp.draft_only) {
|
||||
onSavedNewAppPath?.(newEditedPath || path)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import JsonEditor from '../../JsonEditor.svelte'
|
||||
import { AppService, DraftService } from '$lib/gen'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
@@ -45,11 +46,7 @@
|
||||
requestBody: { ...app, value: JSON.parse(code) }
|
||||
})
|
||||
dispatch('change')
|
||||
try {
|
||||
localStorage.removeItem(`app-${path}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
UserDraft.remove('app', path)
|
||||
sendUserToast('App deployed')
|
||||
}
|
||||
|
||||
@@ -63,11 +60,7 @@
|
||||
}
|
||||
})
|
||||
dispatch('change')
|
||||
try {
|
||||
localStorage.removeItem(`app-${path}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
UserDraft.remove('app', path)
|
||||
sendUserToast('Draft saved')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -164,6 +164,16 @@ export interface AppEditorProps {
|
||||
gotoFn?: (path: string, opt?: Record<string, any> | undefined) => void
|
||||
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
|
||||
onSavedNewAppPath?: (path: string) => void
|
||||
/**
|
||||
* Backend revs at the load that produced `app`. Used as the seed
|
||||
* `UserDraft` meta on the first local autosave: until the handle has
|
||||
* its own meta (set on a previous reload, or by route backfill), the
|
||||
* mirror `$effect` injects these revs so the next reload's staleness
|
||||
* check has something to compare the current backend rev against.
|
||||
* Without this, the first deploy-after-edit can't be detected as
|
||||
* drift — `previousMeta` would be empty and the modal wouldn't fire.
|
||||
*/
|
||||
initialRevs?: import('$lib/userDraft.svelte').UserDraftMeta
|
||||
}
|
||||
|
||||
export type App = {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Modal shown when the four route-level editors detect that the
|
||||
* remote state has moved on since the local autosave was captured —
|
||||
* either a teammate (or another tab) pushed a fresh "Save as draft"
|
||||
* via `DraftService` (`cause = 'draft'`), or the deployed version
|
||||
* changed (`cause = 'version'`).
|
||||
*
|
||||
* Sits above the per-browser `UserDraft` autosave layer and is
|
||||
* separate from the backend `DraftService` flow. The actual reset
|
||||
* actions live at each call site; this modal just owns the
|
||||
* "Load latest version" vs "Keep current draft" decision.
|
||||
*/
|
||||
import { classNames } from '$lib/utils'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from '../button/Button.svelte'
|
||||
import { CornerDownLeft, RefreshCcw } from 'lucide-svelte'
|
||||
|
||||
type Cause = 'draft' | 'version'
|
||||
|
||||
let {
|
||||
open = false,
|
||||
cause = 'version',
|
||||
onLoadLatest,
|
||||
onKeepDraft
|
||||
}: {
|
||||
open?: boolean
|
||||
/** What changed on the remote since the local draft was created. */
|
||||
cause?: Cause
|
||||
onLoadLatest: () => void | Promise<void>
|
||||
onKeepDraft: () => void | Promise<void>
|
||||
} = $props()
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (!open) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
onLoadLatest()
|
||||
break
|
||||
case 'Escape':
|
||||
onKeepDraft()
|
||||
break
|
||||
}
|
||||
}
|
||||
function fadeFast(node: HTMLElement) {
|
||||
return fade(node, { duration: 100 })
|
||||
}
|
||||
|
||||
const title = $derived(
|
||||
cause === 'draft'
|
||||
? 'A newer draft was saved on the server'
|
||||
: 'A newer version was deployed on the server'
|
||||
)
|
||||
const body = $derived(
|
||||
cause === 'draft'
|
||||
? "The editor is showing your local autosave. Someone else (or another tab) pushed a newer draft to the server while you were editing — your copy is now behind. Load latest replaces what's on screen; Keep current leaves it alone."
|
||||
: "The editor is showing your local autosave. A newer version was deployed while you were editing — your copy is now behind. Load latest replaces what's on screen; Keep current leaves it alone."
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydowncapture={onKeyDown} />
|
||||
|
||||
{#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',
|
||||
open ? 'ease-out duration-300 opacity-100' : 'ease-in duration-200 opacity-0'
|
||||
)}
|
||||
></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={classNames(
|
||||
'relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
|
||||
open
|
||||
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
|
||||
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
|
||||
)}
|
||||
>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800/50"
|
||||
>
|
||||
<RefreshCcw class="text-blue-700 dark:text-blue-300" />
|
||||
</div>
|
||||
<div class="ml-4 flex-1 text-left">
|
||||
<h3 class="text-lg font-medium text-primary">{title}</h3>
|
||||
<p class="mt-2 text-sm text-secondary">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
||||
<Button
|
||||
on:click={() => onLoadLatest()}
|
||||
color="dark"
|
||||
size="sm"
|
||||
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
|
||||
variant="accent"
|
||||
>
|
||||
<span class="min-w-20">Load latest version</span>
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => onKeepDraft()}
|
||||
variant="default"
|
||||
size="sm"
|
||||
shortCut={{ key: 'Esc', withoutModifier: true }}
|
||||
>
|
||||
Keep current draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -26,6 +26,14 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({
|
||||
|
||||
vi.mock('$lib/components/vscode', () => ({}))
|
||||
|
||||
vi.mock('svelte', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
onDestroy: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('$lib/gen', async () => {
|
||||
const actual = await vi.importActual<any>('$lib/gen')
|
||||
|
||||
@@ -42,17 +50,81 @@ vi.mock('$lib/gen', async () => {
|
||||
|
||||
return {
|
||||
...actual,
|
||||
ScriptService: wrapService(actual.ScriptService, {
|
||||
existsScriptByPath: vi.fn(async () => false),
|
||||
getScriptByPath: vi.fn(),
|
||||
getScriptByPathWithDraft: vi.fn(),
|
||||
listScripts: vi.fn(async () => [])
|
||||
}),
|
||||
FlowService: wrapService(actual.FlowService, {
|
||||
existsFlowByPath: vi.fn(async () => false)
|
||||
existsFlowByPath: vi.fn(async () => false),
|
||||
listFlows: vi.fn(async () => [])
|
||||
}),
|
||||
ScheduleService: wrapService(actual.ScheduleService, {
|
||||
existsSchedule: vi.fn(async () => false),
|
||||
listSchedules: vi.fn(async () => [])
|
||||
}),
|
||||
HttpTriggerService: wrapService(actual.HttpTriggerService, {
|
||||
existsHttpTrigger: vi.fn(async () => false),
|
||||
listHttpTriggers: vi.fn(async () => [])
|
||||
}),
|
||||
WebsocketTriggerService: wrapService(actual.WebsocketTriggerService, {
|
||||
existsWebsocketTrigger: vi.fn(async () => false),
|
||||
listWebsocketTriggers: vi.fn(async () => [])
|
||||
}),
|
||||
KafkaTriggerService: wrapService(actual.KafkaTriggerService, {
|
||||
existsKafkaTrigger: vi.fn(async () => false),
|
||||
listKafkaTriggers: vi.fn(async () => [])
|
||||
}),
|
||||
NatsTriggerService: wrapService(actual.NatsTriggerService, {
|
||||
existsNatsTrigger: vi.fn(async () => false),
|
||||
listNatsTriggers: vi.fn(async () => [])
|
||||
}),
|
||||
PostgresTriggerService: wrapService(actual.PostgresTriggerService, {
|
||||
existsPostgresTrigger: vi.fn(async () => false),
|
||||
listPostgresTriggers: vi.fn(async () => [])
|
||||
}),
|
||||
MqttTriggerService: wrapService(actual.MqttTriggerService, {
|
||||
existsMqttTrigger: vi.fn(async () => false),
|
||||
listMqttTriggers: vi.fn(async () => [])
|
||||
}),
|
||||
SqsTriggerService: wrapService(actual.SqsTriggerService, {
|
||||
existsSqsTrigger: vi.fn(async () => false),
|
||||
listSqsTriggers: vi.fn(async () => [])
|
||||
}),
|
||||
GcpTriggerService: wrapService(actual.GcpTriggerService, {
|
||||
existsGcpTrigger: vi.fn(async () => false),
|
||||
listGcpTriggers: vi.fn(async () => [])
|
||||
}),
|
||||
AzureTriggerService: wrapService(actual.AzureTriggerService, {
|
||||
existsAzureTrigger: vi.fn(async () => false),
|
||||
listAzureTriggers: vi.fn(async () => [])
|
||||
}),
|
||||
AppService: wrapService(actual.AppService, {
|
||||
existsApp: vi.fn(async () => false)
|
||||
}),
|
||||
ResourceService: wrapService(actual.ResourceService, {
|
||||
existsResource: vi.fn(async () => false),
|
||||
listResource: vi.fn(async () => []),
|
||||
queryResourceTypes: vi.fn(async () => [])
|
||||
}),
|
||||
VariableService: wrapService(actual.VariableService, {
|
||||
existsVariable: vi.fn(async () => false)
|
||||
existsVariable: vi.fn(async () => false),
|
||||
getVariable: vi.fn(async ({ path }: { path: string }) => ({
|
||||
path,
|
||||
value: 'super-secret-token',
|
||||
is_secret: true,
|
||||
description: 'API key'
|
||||
})),
|
||||
listVariable: vi.fn(async () => [])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import { globalTools, prepareGlobalUserMessage } from './core'
|
||||
import { globalDraftStore } from './draftStore.svelte'
|
||||
import { deleteGlobalDraft, listGlobalDrafts } from './userDraftAdapter'
|
||||
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
|
||||
import { ScriptService } from '$lib/gen'
|
||||
import type { Tool, ToolCallbacks } from '../shared'
|
||||
|
||||
const WORKSPACE = 'global-core-test'
|
||||
@@ -86,18 +158,556 @@ async function callGlobalTool(
|
||||
|
||||
describe('global AI tools', () => {
|
||||
beforeEach(() => {
|
||||
globalDraftStore.clearDrafts(WORKSPACE)
|
||||
__resetUserDraftForTesting()
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('redacts variable draft values when reading workspace items', async () => {
|
||||
it('writes resource and variable drafts into the shared UserDraft store', async () => {
|
||||
await callGlobalTool('write_variable', {
|
||||
path: 'f/secrets/api_key',
|
||||
value: 'super-secret-token',
|
||||
value: 'secret',
|
||||
is_secret: true,
|
||||
description: 'API key'
|
||||
})
|
||||
await callGlobalTool('write_resource', {
|
||||
path: 'f/resources/api',
|
||||
resource_type: 'http',
|
||||
description: 'API resource',
|
||||
value: {
|
||||
token: '$var:f/secrets/api_key'
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
|
||||
).toMatchObject({
|
||||
path: 'f/secrets/api_key',
|
||||
value: 'secret',
|
||||
is_secret: true,
|
||||
description: 'API key'
|
||||
})
|
||||
expect(
|
||||
UserDraft.get<any>('resource', 'f/resources/api', { workspace: WORKSPACE })
|
||||
).toMatchObject({
|
||||
path: 'f/resources/api',
|
||||
resource_type: 'http',
|
||||
description: 'API resource',
|
||||
value: {
|
||||
token: '$var:f/secrets/api_key'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('writes script drafts into the shared UserDraft store', async () => {
|
||||
const content = 'export async function main() {\n\treturn "hello"\n}'
|
||||
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/hello',
|
||||
summary: 'Hello script',
|
||||
language: 'bun',
|
||||
content
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject(
|
||||
{
|
||||
path: 'f/scripts/hello',
|
||||
summary: 'Hello script',
|
||||
language: 'bun',
|
||||
content
|
||||
}
|
||||
)
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/hello'
|
||||
})
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
type: 'script',
|
||||
path: 'f/scripts/hello',
|
||||
summary: 'Hello script',
|
||||
language: 'bun',
|
||||
value: content,
|
||||
isDraft: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reads backend draft-only scripts through the draft-aware endpoint', async () => {
|
||||
const content = 'export async function main() {\n\treturn "db draft"\n}'
|
||||
vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({
|
||||
hash: 'draft-hash',
|
||||
path: 'f/scripts/backend-draft',
|
||||
summary: 'Backend draft',
|
||||
description: '',
|
||||
content,
|
||||
language: 'bun',
|
||||
draft_only: true
|
||||
} as any)
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/backend-draft'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
type: 'script',
|
||||
path: 'f/scripts/backend-draft',
|
||||
summary: 'Backend draft',
|
||||
language: 'bun',
|
||||
value: content,
|
||||
isDraft: true
|
||||
})
|
||||
expect(ScriptService.getScriptByPathWithDraft).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/scripts/backend-draft'
|
||||
})
|
||||
expect(ScriptService.getScriptByPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads a live UserDraft editor baseline as current context without marking it draft', async () => {
|
||||
const content = 'export async function main() {\n\treturn "open editor"\n}'
|
||||
const handle = UserDraft.use<any>('script', 'f/scripts/open-editor', { workspace: WORKSPACE })
|
||||
handle.setDraftAndMeta(
|
||||
{
|
||||
path: 'f/scripts/open-editor',
|
||||
summary: 'Open editor baseline',
|
||||
language: 'bun',
|
||||
content
|
||||
},
|
||||
{ remoteRev: 'v1' }
|
||||
)
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/open-editor'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
type: 'script',
|
||||
path: 'f/scripts/open-editor',
|
||||
summary: 'Open editor baseline',
|
||||
language: 'bun',
|
||||
value: content,
|
||||
isDraft: false
|
||||
})
|
||||
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/open-editor`)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat a clean live editor baseline as a deployable global draft', async () => {
|
||||
const content = 'export async function main() {\n\treturn "baseline"\n}'
|
||||
const handle = UserDraft.use<any>('script', 'f/scripts/baseline', { workspace: WORKSPACE })
|
||||
handle.setDraftAndMeta(
|
||||
{
|
||||
path: 'f/scripts/baseline',
|
||||
summary: 'Clean baseline',
|
||||
language: 'bun',
|
||||
content
|
||||
},
|
||||
{ remoteRev: 'v1' }
|
||||
)
|
||||
|
||||
expect(listGlobalDrafts(WORKSPACE)).toEqual([])
|
||||
await expect(
|
||||
callGlobalTool('deploy_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/baseline'
|
||||
})
|
||||
).rejects.toThrow('No AI draft found for script "f/scripts/baseline".')
|
||||
})
|
||||
|
||||
it('does not treat a persisted editor baseline as a deployable global draft', async () => {
|
||||
const key = `userdraft/w/${WORKSPACE}/script/f/scripts/persisted-baseline`
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
value: {
|
||||
path: 'f/scripts/persisted-baseline',
|
||||
summary: 'Persisted baseline',
|
||||
language: 'bun',
|
||||
content: 'export async function main() {\n\treturn "baseline"\n}',
|
||||
parent_hash: 'h1'
|
||||
},
|
||||
remoteRev: 'h1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(listGlobalDrafts(WORKSPACE)).toEqual([])
|
||||
await expect(
|
||||
callGlobalTool('deploy_workspace_item', {
|
||||
type: 'script',
|
||||
path: 'f/scripts/persisted-baseline'
|
||||
})
|
||||
).rejects.toThrow('No AI draft found for script "f/scripts/persisted-baseline".')
|
||||
})
|
||||
|
||||
it('persists AI writes into a live editor handle so they remain deployable', async () => {
|
||||
const handle = UserDraft.use<any>('script', 'f/scripts/live-ai', { workspace: WORKSPACE })
|
||||
handle.setDraftAndMeta(
|
||||
{
|
||||
path: 'f/scripts/live-ai',
|
||||
summary: 'Clean baseline',
|
||||
language: 'bun',
|
||||
content: 'export async function main() {\n\treturn 1\n}'
|
||||
},
|
||||
{ remoteRev: 'v1' }
|
||||
)
|
||||
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/live-ai',
|
||||
summary: 'AI edit',
|
||||
language: 'bun',
|
||||
content: 'export async function main() {\n\treturn 2\n}'
|
||||
})
|
||||
|
||||
expect(handle.draft?.content).toContain('return 2')
|
||||
expect(listGlobalDrafts(WORKSPACE)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
path: 'f/scripts/live-ai',
|
||||
summary: 'AI edit',
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/live-ai`)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('preserves NewScript metadata from existing scripts without copying backend read fields', async () => {
|
||||
vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true)
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
workspace_id: WORKSPACE,
|
||||
hash: 'old-hash',
|
||||
path: 'f/scripts/existing',
|
||||
parent_hashes: ['older-hash'],
|
||||
summary: 'Existing script',
|
||||
description: 'Existing description',
|
||||
content: 'export async function main() { return 1 }',
|
||||
created_by: 'admin',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
archived: false,
|
||||
deleted: false,
|
||||
extra_perms: {},
|
||||
is_template: true,
|
||||
language: 'bun',
|
||||
kind: 'script',
|
||||
starred: true,
|
||||
has_draft: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
}
|
||||
},
|
||||
concurrent_limit: 3,
|
||||
cache_ttl: 30,
|
||||
cache_ignore_s3_path: true,
|
||||
has_preprocessor: true,
|
||||
assets: [{ path: 'asset.txt', kind: 's3object' }],
|
||||
labels: ['important']
|
||||
} as any)
|
||||
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/existing',
|
||||
summary: 'AI update',
|
||||
language: 'python3',
|
||||
content: 'def main():\n return 2'
|
||||
})
|
||||
|
||||
const draft = UserDraft.get<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
|
||||
expect(draft).toMatchObject({
|
||||
path: 'f/scripts/existing',
|
||||
parent_hash: 'old-hash',
|
||||
summary: 'AI update',
|
||||
description: 'Existing description',
|
||||
content: 'def main():\n return 2',
|
||||
language: 'python3',
|
||||
is_template: true,
|
||||
schema: {
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
}
|
||||
},
|
||||
concurrent_limit: 3,
|
||||
cache_ttl: 30,
|
||||
cache_ignore_s3_path: true,
|
||||
has_preprocessor: true,
|
||||
assets: [{ path: 'asset.txt', kind: 's3object' }],
|
||||
labels: ['important']
|
||||
})
|
||||
expect(draft).not.toHaveProperty('hash')
|
||||
expect(draft).not.toHaveProperty('workspace_id')
|
||||
expect(draft).not.toHaveProperty('created_by')
|
||||
expect(draft).not.toHaveProperty('starred')
|
||||
expect(draft).not.toHaveProperty('extra_perms')
|
||||
})
|
||||
|
||||
it('deleteGlobalDraft clears both persisted storage and any live handle state', async () => {
|
||||
const handle = UserDraft.use<any>('script', 'f/scripts/delete-live', { workspace: WORKSPACE })
|
||||
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/delete-live',
|
||||
summary: 'Delete me',
|
||||
language: 'bun',
|
||||
content: 'export async function main() {\n\treturn "delete"\n}'
|
||||
})
|
||||
expect(handle.draft).toBeDefined()
|
||||
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/delete-live`)).not.toBeNull()
|
||||
|
||||
deleteGlobalDraft(WORKSPACE, 'script', 'f/scripts/delete-live')
|
||||
|
||||
expect(handle.draft).toBeUndefined()
|
||||
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/delete-live`)).toBeNull()
|
||||
})
|
||||
|
||||
it('lists and edits a new script draft stored under the empty add-editor key by its assigned path', async () => {
|
||||
const content = 'export async function main() {\n\treturn 1\n}'
|
||||
const handle = UserDraft.use<any>('script', '', { workspace: WORKSPACE })
|
||||
handle.setDraftAndMeta(
|
||||
{
|
||||
path: 'u/admin/assigned_script',
|
||||
summary: '',
|
||||
language: 'bun',
|
||||
content
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script']
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
path: 'u/admin/assigned_script',
|
||||
isDraft: false
|
||||
})
|
||||
])
|
||||
|
||||
UserDraft.get<any>('script', '', { workspace: WORKSPACE }).uncloneable = () =>
|
||||
'runtime-only editor field'
|
||||
|
||||
await callGlobalTool('edit_script', {
|
||||
path: 'u/admin/assigned_script',
|
||||
old_string: 'return 1',
|
||||
new_string: 'return 2',
|
||||
replace_all: false
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('script', '', { workspace: WORKSPACE })?.content).toContain(
|
||||
'return 2'
|
||||
)
|
||||
expect(
|
||||
UserDraft.get<any>('script', 'u/admin/assigned_script', { workspace: WORKSPACE })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('edits a new script draft stored under the empty add-editor key before listing it', async () => {
|
||||
const content = 'export async function main() {\n\treturn 1\n}'
|
||||
const handle = UserDraft.use<any>('script', '', { workspace: WORKSPACE })
|
||||
handle.setDraftAndMeta(
|
||||
{
|
||||
path: 'u/admin/direct_edit_script',
|
||||
summary: '',
|
||||
language: 'bun',
|
||||
content
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
UserDraft.get<any>('script', '', { workspace: WORKSPACE }).uncloneable = () =>
|
||||
'runtime-only editor field'
|
||||
|
||||
await callGlobalTool('edit_script', {
|
||||
path: 'u/admin/direct_edit_script',
|
||||
old_string: 'return 1',
|
||||
new_string: 'return 2',
|
||||
replace_all: false
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('script', '', { workspace: WORKSPACE })?.content).toContain(
|
||||
'return 2'
|
||||
)
|
||||
expect(
|
||||
UserDraft.get<any>('script', 'u/admin/direct_edit_script', { workspace: WORKSPACE })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('overlays shared UserDraft entries in list_workspace_items', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/listed',
|
||||
summary: 'Listed script',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return 1 }'
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script']
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
path: 'f/scripts/listed',
|
||||
summary: 'Listed script',
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps local UserDraft overlays marked as drafts after path_prefix filtering', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/listed',
|
||||
summary: 'Listed script',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return 1 }'
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script'],
|
||||
path_prefix: 'f/scripts'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'script',
|
||||
path: 'f/scripts/listed',
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('applies path_prefix to local UserDraft overlays in list_workspace_items', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/listed',
|
||||
summary: 'Listed script',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return 1 }'
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script'],
|
||||
path_prefix: 'f/other'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not inspect unrelated partial flow UserDraft entries when listing scripts', async () => {
|
||||
UserDraft.save<any>('flow', 'f/flows/partial', {}, { workspace: WORKSPACE })
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['script']
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([])
|
||||
})
|
||||
|
||||
it('lists legacy top-level flow UserDraft values without crashing', async () => {
|
||||
UserDraft.save<any>(
|
||||
'flow',
|
||||
'f/flows/legacy',
|
||||
{
|
||||
modules: [
|
||||
{
|
||||
id: 'start',
|
||||
value: { type: 'identity' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['flow']
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'flow',
|
||||
path: 'f/flows/legacy',
|
||||
isDraft: false
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('writes schedule drafts into the shared UserDraft store', async () => {
|
||||
await callGlobalTool('write_schedule', {
|
||||
path: 'f/schedules/daily',
|
||||
summary: 'Daily schedule',
|
||||
schedule: '0 0 12 * * *',
|
||||
timezone: 'UTC',
|
||||
script_path: 'f/scripts/hello',
|
||||
is_flow: false,
|
||||
args: { name: 'Ada' },
|
||||
enabled: true
|
||||
})
|
||||
|
||||
expect(
|
||||
UserDraft.get<any>('trigger_schedule', 'f/schedules/daily', { workspace: WORKSPACE })
|
||||
).toMatchObject({
|
||||
path: 'f/schedules/daily',
|
||||
summary: 'Daily schedule',
|
||||
script_path: 'f/scripts/hello',
|
||||
is_flow: false
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'schedule',
|
||||
path: 'f/schedules/daily'
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toMatchObject({
|
||||
type: 'schedule',
|
||||
path: 'f/schedules/daily',
|
||||
summary: 'Daily schedule',
|
||||
value: expect.objectContaining({
|
||||
schedule: '0 0 12 * * *',
|
||||
timezone: 'UTC'
|
||||
}),
|
||||
isDraft: true
|
||||
})
|
||||
})
|
||||
|
||||
it('writes trigger drafts into the shared trigger UserDraft kind', async () => {
|
||||
await callGlobalTool('write_trigger', {
|
||||
kind: 'http',
|
||||
config: {
|
||||
path: 'f/triggers/hook',
|
||||
summary: 'Hook trigger',
|
||||
script_path: 'f/scripts/hello',
|
||||
route_path: 'api/hook',
|
||||
is_flow: false,
|
||||
http_method: 'post',
|
||||
authentication_method: 'none',
|
||||
is_static_website: false
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
UserDraft.get<any>('trigger_http', 'f/triggers/hook', { workspace: WORKSPACE })
|
||||
).toMatchObject({
|
||||
path: 'f/triggers/hook',
|
||||
summary: 'Hook trigger',
|
||||
route_path: 'api/hook',
|
||||
is_flow: false
|
||||
})
|
||||
|
||||
const raw = await callGlobalTool('list_workspace_items', {
|
||||
types: ['trigger']
|
||||
})
|
||||
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'trigger',
|
||||
triggerKind: 'http',
|
||||
path: 'f/triggers/hook',
|
||||
summary: 'Hook trigger',
|
||||
isDraft: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('redacts existing variable values when reading workspace items', async () => {
|
||||
const raw = await callGlobalTool('read_workspace_item', {
|
||||
type: 'variable',
|
||||
path: 'f/secrets/api_key'
|
||||
@@ -109,7 +719,7 @@ describe('global AI tools', () => {
|
||||
type: 'variable',
|
||||
path: 'f/secrets/api_key',
|
||||
summary: 'API key',
|
||||
isDraft: true
|
||||
isDraft: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,6 +740,20 @@ describe('global AI tools', () => {
|
||||
])
|
||||
})
|
||||
|
||||
expect(
|
||||
UserDraft.get<any>('flow', 'f/flows/empty-module', { workspace: WORKSPACE })
|
||||
).toMatchObject({
|
||||
path: 'f/flows/empty-module',
|
||||
summary: 'Flow with empty module',
|
||||
value: {
|
||||
modules: [
|
||||
expect.objectContaining({
|
||||
id: 'empty_step'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const code = 'export async function main() {\n\treturn 42\n}'
|
||||
|
||||
await expect(
|
||||
@@ -148,6 +772,28 @@ describe('global AI tools', () => {
|
||||
).resolves.toBe(code)
|
||||
})
|
||||
|
||||
it('stores raw app drafts in the shared raw_app UserDraft kind', async () => {
|
||||
await callGlobalTool('init_app', {
|
||||
path: 'f/apps/demo',
|
||||
summary: 'Demo app',
|
||||
framework: 'react19'
|
||||
})
|
||||
|
||||
await callGlobalTool('write_app_file', {
|
||||
path: 'f/apps/demo',
|
||||
file_path: '/App.tsx',
|
||||
content: 'export default function App() { return <main>Demo</main> }'
|
||||
})
|
||||
|
||||
const draft = UserDraft.get<any>('raw_app', 'f/apps/demo', { workspace: WORKSPACE })
|
||||
expect(draft).toMatchObject({
|
||||
summary: 'Demo app',
|
||||
files: expect.objectContaining({
|
||||
'/App.tsx': 'export default function App() { return <main>Demo</main> }'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('writes flows with flow-mode arguments and reads compact flow value', async () => {
|
||||
const writeResult = JSON.parse(
|
||||
await callGlobalTool('write_flow', {
|
||||
@@ -181,6 +827,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
const item = JSON.parse(raw)
|
||||
|
||||
expect(item.isDraft).toBe(true)
|
||||
expect(item.value).toMatchObject({
|
||||
modules: [
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Flow, NewScript, Script } from '$lib/gen/types.gen'
|
||||
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
|
||||
import type { WorkspaceItem } from './draftStore.svelte'
|
||||
import type { WorkspaceItem } from './workspaceItems'
|
||||
|
||||
describe('global AI deploy request builders', () => {
|
||||
it('preserves existing script metadata while replacing draft-controlled fields', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen'
|
||||
import type { FlowDraftValue, WorkspaceItem } from './draftStore.svelte'
|
||||
import type { FlowDraftValue, WorkspaceItem } from './workspaceItems'
|
||||
|
||||
type ScriptWithDeployMetadata = Script & Partial<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import type {
|
||||
AzureTriggerData,
|
||||
CreateResource,
|
||||
CreateVariable,
|
||||
FlowValue,
|
||||
GcpTriggerData,
|
||||
NewHttpTrigger,
|
||||
NewKafkaTrigger,
|
||||
NewMqttTrigger,
|
||||
NewNatsTrigger,
|
||||
NewPostgresTrigger,
|
||||
NewSchedule,
|
||||
NewSqsTrigger,
|
||||
NewWebsocketTrigger,
|
||||
Policy,
|
||||
ScriptLang
|
||||
} from '$lib/gen/types.gen'
|
||||
|
||||
/**
|
||||
* Flow draft value. Mirrors what the backend's create/update flow API expects
|
||||
* — the OpenFlow value, plus the inputs schema and (optional) groups.
|
||||
*
|
||||
* Schema and groups are split out from FlowValue intentionally so that
|
||||
* deploy_workspace_item can preserve them through the draft → workspace
|
||||
* round-trip; an earlier version dropped them on every deploy.
|
||||
*/
|
||||
export type FlowDraftValue = {
|
||||
value: FlowValue
|
||||
schema?: Record<string, any> | null
|
||||
groups?: NonNullable<FlowValue['groups']> | null
|
||||
}
|
||||
|
||||
export const TRIGGER_KINDS = [
|
||||
'http',
|
||||
'websocket',
|
||||
'kafka',
|
||||
'nats',
|
||||
'postgres',
|
||||
'mqtt',
|
||||
'sqs',
|
||||
'gcp',
|
||||
'azure'
|
||||
] as const
|
||||
|
||||
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
|
||||
|
||||
export type TriggerRequestBody =
|
||||
| NewHttpTrigger
|
||||
| NewWebsocketTrigger
|
||||
| NewKafkaTrigger
|
||||
| NewNatsTrigger
|
||||
| NewPostgresTrigger
|
||||
| NewMqttTrigger
|
||||
| NewSqsTrigger
|
||||
| GcpTriggerData
|
||||
| AzureTriggerData
|
||||
|
||||
export type WorkspaceItemType =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'schedule'
|
||||
| 'trigger'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'app'
|
||||
|
||||
export type AppDraftValue = {
|
||||
summary?: string
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, any>
|
||||
data?: any
|
||||
policy?: Policy
|
||||
custom_path?: string
|
||||
}
|
||||
|
||||
export type WorkspaceItem = {
|
||||
type: WorkspaceItemType
|
||||
path: string
|
||||
summary?: string
|
||||
language?: ScriptLang
|
||||
triggerKind?: TriggerKind
|
||||
value?:
|
||||
| string
|
||||
| FlowDraftValue
|
||||
| NewSchedule
|
||||
| TriggerRequestBody
|
||||
| CreateResource
|
||||
| CreateVariable
|
||||
| AppDraftValue
|
||||
isDraft: boolean
|
||||
}
|
||||
|
||||
export function getWorkspaceItemKey(
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): string {
|
||||
if (type === 'trigger') {
|
||||
return `trigger:${triggerKind ?? ''}:${path}`
|
||||
}
|
||||
return `${type}:${path}`
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone($state.snapshot(value)) as T
|
||||
}
|
||||
|
||||
class GlobalDraftStore {
|
||||
private drafts = $state<Record<string, Record<string, WorkspaceItem>>>({})
|
||||
|
||||
private getWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
|
||||
return this.drafts[workspace] ?? {}
|
||||
}
|
||||
|
||||
private ensureWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
|
||||
if (!this.drafts[workspace]) {
|
||||
this.drafts[workspace] = {}
|
||||
}
|
||||
return this.drafts[workspace]
|
||||
}
|
||||
|
||||
listDrafts(workspace: string): WorkspaceItem[] {
|
||||
return Object.values(this.getWorkspaceDrafts(workspace)).map(clone)
|
||||
}
|
||||
|
||||
getDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
const draft = this.getWorkspaceDrafts(workspace)[getWorkspaceItemKey(type, path, triggerKind)]
|
||||
return draft ? clone(draft) : undefined
|
||||
}
|
||||
|
||||
setDraft(workspace: string, item: WorkspaceItem): WorkspaceItem {
|
||||
const stored: WorkspaceItem = { ...clone(item), isDraft: true }
|
||||
this.ensureWorkspaceDrafts(workspace)[
|
||||
getWorkspaceItemKey(item.type, item.path, item.triggerKind)
|
||||
] = stored
|
||||
return clone(stored)
|
||||
}
|
||||
|
||||
deleteDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): void {
|
||||
const drafts = this.drafts[workspace]
|
||||
if (!drafts) return
|
||||
|
||||
delete drafts[getWorkspaceItemKey(type, path, triggerKind)]
|
||||
if (Object.keys(drafts).length === 0) {
|
||||
delete this.drafts[workspace]
|
||||
}
|
||||
}
|
||||
|
||||
clearDrafts(workspace: string): void {
|
||||
delete this.drafts[workspace]
|
||||
}
|
||||
|
||||
getScriptDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'script', path)
|
||||
}
|
||||
|
||||
getFlowDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'flow', path)
|
||||
}
|
||||
|
||||
getScheduleDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'schedule', path)
|
||||
}
|
||||
|
||||
getTriggerDraft(workspace: string, kind: TriggerKind, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'trigger', path, kind)
|
||||
}
|
||||
|
||||
getResourceDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'resource', path)
|
||||
}
|
||||
|
||||
getVariableDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'variable', path)
|
||||
}
|
||||
|
||||
getAppDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'app', path)
|
||||
}
|
||||
}
|
||||
|
||||
export const globalDraftStore = new GlobalDraftStore()
|
||||
@@ -1,56 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { globalDraftStore } from './draftStore.svelte'
|
||||
|
||||
const WORKSPACE_A = 'draft-store-test-a'
|
||||
const WORKSPACE_B = 'draft-store-test-b'
|
||||
|
||||
function clearTestDrafts() {
|
||||
globalDraftStore.clearDrafts(WORKSPACE_A)
|
||||
globalDraftStore.clearDrafts(WORKSPACE_B)
|
||||
}
|
||||
|
||||
describe('globalDraftStore', () => {
|
||||
beforeEach(clearTestDrafts)
|
||||
|
||||
it('lists and reads drafts only from the requested workspace', () => {
|
||||
globalDraftStore.setDraft(WORKSPACE_A, {
|
||||
type: 'script',
|
||||
path: 'f/shared/path',
|
||||
language: 'bun',
|
||||
value: 'export async function main() {}',
|
||||
isDraft: true
|
||||
})
|
||||
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_A, 'script', 'f/shared/path')?.value).toBe(
|
||||
'export async function main() {}'
|
||||
)
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_B, 'script', 'f/shared/path')).toBeUndefined()
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(1)
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deletes and clears drafts only from the requested workspace', () => {
|
||||
globalDraftStore.setDraft(WORKSPACE_A, {
|
||||
type: 'flow',
|
||||
path: 'f/shared/path',
|
||||
value: { value: { modules: [] }, schema: null, groups: null },
|
||||
isDraft: true
|
||||
})
|
||||
globalDraftStore.setDraft(WORKSPACE_B, {
|
||||
type: 'flow',
|
||||
path: 'f/shared/path',
|
||||
value: { value: { modules: [] }, schema: { workspace: WORKSPACE_B }, groups: null },
|
||||
isDraft: true
|
||||
})
|
||||
|
||||
globalDraftStore.deleteDraft(WORKSPACE_A, 'flow', 'f/shared/path')
|
||||
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_A, 'flow', 'f/shared/path')).toBeUndefined()
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_B, 'flow', 'f/shared/path')).toBeDefined()
|
||||
|
||||
globalDraftStore.clearDrafts(WORKSPACE_B)
|
||||
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(0)
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,513 @@
|
||||
import type {
|
||||
CreateResource,
|
||||
CreateVariable,
|
||||
FlowValue,
|
||||
NewSchedule,
|
||||
NewScript
|
||||
} from '$lib/gen/types.gen'
|
||||
import {
|
||||
UserDraft,
|
||||
type UserDraftEntrySource,
|
||||
type UserDraftItemKind,
|
||||
type UserDraftListEntry
|
||||
} from '$lib/userDraft.svelte'
|
||||
import {
|
||||
getWorkspaceItemKey,
|
||||
type AppDraftValue,
|
||||
type TriggerRequestBody,
|
||||
type TriggerKind,
|
||||
type WorkspaceItem,
|
||||
type WorkspaceItemType
|
||||
} from './workspaceItems'
|
||||
|
||||
type SharedWorkspaceItemType =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'app'
|
||||
| 'schedule'
|
||||
| 'trigger'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
|
||||
const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = {
|
||||
http: 'trigger_http',
|
||||
websocket: 'trigger_websocket',
|
||||
kafka: 'trigger_kafka',
|
||||
nats: 'trigger_nats',
|
||||
postgres: 'trigger_postgres',
|
||||
mqtt: 'trigger_mqtt',
|
||||
sqs: 'trigger_sqs',
|
||||
gcp: 'trigger_gcp',
|
||||
azure: 'trigger_azure'
|
||||
} as const satisfies Record<TriggerKind, UserDraftItemKind>
|
||||
|
||||
const TRIGGER_KIND_BY_DRAFT_KIND = {
|
||||
trigger_http: 'http',
|
||||
trigger_websocket: 'websocket',
|
||||
trigger_kafka: 'kafka',
|
||||
trigger_nats: 'nats',
|
||||
trigger_postgres: 'postgres',
|
||||
trigger_mqtt: 'mqtt',
|
||||
trigger_sqs: 'sqs',
|
||||
trigger_gcp: 'gcp',
|
||||
trigger_azure: 'azure'
|
||||
} as const satisfies Partial<Record<UserDraftItemKind, TriggerKind>>
|
||||
|
||||
const SHARED_DRAFT_KINDS = [
|
||||
'script',
|
||||
'flow',
|
||||
'raw_app',
|
||||
'trigger_schedule',
|
||||
'trigger_http',
|
||||
'trigger_websocket',
|
||||
'trigger_kafka',
|
||||
'trigger_nats',
|
||||
'trigger_postgres',
|
||||
'trigger_mqtt',
|
||||
'trigger_sqs',
|
||||
'trigger_gcp',
|
||||
'trigger_azure',
|
||||
'resource',
|
||||
'variable'
|
||||
] as const satisfies UserDraftItemKind[]
|
||||
const DEFAULT_APP_DATA = { tables: [], datatable: undefined, schema: undefined }
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value) as T
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isSharedWorkspaceItemType(type: WorkspaceItemType): type is SharedWorkspaceItemType {
|
||||
return (
|
||||
type === 'script' ||
|
||||
type === 'flow' ||
|
||||
type === 'app' ||
|
||||
type === 'schedule' ||
|
||||
type === 'trigger' ||
|
||||
type === 'resource' ||
|
||||
type === 'variable'
|
||||
)
|
||||
}
|
||||
|
||||
function sharedDraftKind(
|
||||
type: SharedWorkspaceItemType,
|
||||
triggerKind?: TriggerKind
|
||||
): UserDraftItemKind | undefined {
|
||||
switch (type) {
|
||||
case 'script':
|
||||
case 'flow':
|
||||
return type
|
||||
case 'app':
|
||||
return 'raw_app'
|
||||
case 'schedule':
|
||||
return 'trigger_schedule'
|
||||
case 'trigger':
|
||||
return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined
|
||||
case 'resource':
|
||||
case 'variable':
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
function sharedDraftKindsForTypes(types?: readonly WorkspaceItemType[]): UserDraftItemKind[] {
|
||||
if (!types) return [...SHARED_DRAFT_KINDS]
|
||||
|
||||
const itemKinds = new Set<UserDraftItemKind>()
|
||||
for (const type of types) {
|
||||
switch (type) {
|
||||
case 'script':
|
||||
case 'flow':
|
||||
case 'resource':
|
||||
case 'variable':
|
||||
itemKinds.add(type)
|
||||
break
|
||||
case 'app':
|
||||
itemKinds.add('raw_app')
|
||||
break
|
||||
case 'schedule':
|
||||
itemKinds.add('trigger_schedule')
|
||||
break
|
||||
case 'trigger':
|
||||
for (const itemKind of Object.values(TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND)) {
|
||||
itemKinds.add(itemKind)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return [...itemKinds]
|
||||
}
|
||||
|
||||
function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue {
|
||||
return {
|
||||
...value,
|
||||
files: { ...(value.files ?? {}) },
|
||||
runnables: { ...(value.runnables ?? {}) },
|
||||
data: value.data ?? { ...DEFAULT_APP_DATA }
|
||||
}
|
||||
}
|
||||
|
||||
function getItemSummary(value: unknown): string | undefined {
|
||||
return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined
|
||||
}
|
||||
|
||||
function getItemPath(storagePath: string, value: unknown): string | undefined {
|
||||
const valuePath = (value as { path?: string | null } | undefined)?.path
|
||||
return valuePath?.trim() || storagePath || undefined
|
||||
}
|
||||
|
||||
function scriptDraftToWorkspaceItem(
|
||||
storagePath: string,
|
||||
draft: NewScript,
|
||||
isDraft = true
|
||||
): WorkspaceItem | undefined {
|
||||
const path = getItemPath(storagePath, draft)
|
||||
if (!path) return undefined
|
||||
|
||||
return {
|
||||
type: 'script',
|
||||
path,
|
||||
summary: draft.summary,
|
||||
language: draft.language,
|
||||
value: draft.content,
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
function getFlowValue(value: unknown): FlowValue | undefined {
|
||||
if (!isRecord(value) || !Array.isArray(value.modules)) return undefined
|
||||
return clone(value as FlowValue)
|
||||
}
|
||||
|
||||
function getFlowGroups(value: FlowValue): FlowValue['groups'] | null {
|
||||
return Array.isArray(value.groups) ? clone(value.groups) : null
|
||||
}
|
||||
|
||||
function getFlowSchema(value: unknown): Record<string, any> | null {
|
||||
return isRecord(value) ? clone(value as Record<string, any>) : null
|
||||
}
|
||||
|
||||
function flowDraftToWorkspaceItem(
|
||||
storagePath: string,
|
||||
draft: unknown,
|
||||
isDraft = true
|
||||
): WorkspaceItem | undefined {
|
||||
if (!isRecord(draft)) return undefined
|
||||
|
||||
const value = getFlowValue(draft.value) ?? getFlowValue(draft)
|
||||
if (!value) return undefined
|
||||
const path = getItemPath(storagePath, draft)
|
||||
if (!path) return undefined
|
||||
|
||||
return {
|
||||
type: 'flow',
|
||||
path,
|
||||
summary: typeof draft.summary === 'string' ? draft.summary : undefined,
|
||||
value: {
|
||||
value,
|
||||
schema: getFlowSchema(draft.schema),
|
||||
groups: Array.isArray(draft.groups)
|
||||
? clone(draft.groups as FlowValue['groups'])
|
||||
: getFlowGroups(value)
|
||||
},
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
function appDraftToWorkspaceItem(
|
||||
storagePath: string,
|
||||
draft: AppDraftValue,
|
||||
isDraft = true
|
||||
): WorkspaceItem | undefined {
|
||||
const value = normalizeAppDraftValue(draft)
|
||||
const path = getItemPath(storagePath, value)
|
||||
if (!path) return undefined
|
||||
|
||||
return {
|
||||
type: 'app',
|
||||
path,
|
||||
summary: value.summary,
|
||||
value,
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDraftToWorkspaceItem(
|
||||
storagePath: string,
|
||||
draft: NewSchedule,
|
||||
isDraft = true
|
||||
): WorkspaceItem | undefined {
|
||||
const path = getItemPath(storagePath, draft)
|
||||
if (!path) return undefined
|
||||
|
||||
return {
|
||||
type: 'schedule',
|
||||
path,
|
||||
summary: draft.summary ?? undefined,
|
||||
value: clone(draft),
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDraftToWorkspaceItem(
|
||||
kind: TriggerKind,
|
||||
storagePath: string,
|
||||
draft: TriggerRequestBody,
|
||||
isDraft = true
|
||||
): WorkspaceItem | undefined {
|
||||
const path = getItemPath(storagePath, draft)
|
||||
if (!path) return undefined
|
||||
|
||||
return {
|
||||
type: 'trigger',
|
||||
triggerKind: kind,
|
||||
path,
|
||||
summary: getItemSummary(draft),
|
||||
value: clone(draft),
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
function resourceDraftToWorkspaceItem(
|
||||
storagePath: string,
|
||||
draft: CreateResource,
|
||||
isDraft = true
|
||||
): WorkspaceItem | undefined {
|
||||
const path = getItemPath(storagePath, draft)
|
||||
if (!path) return undefined
|
||||
|
||||
return {
|
||||
type: 'resource',
|
||||
path,
|
||||
summary: draft.description ?? undefined,
|
||||
value: clone(draft),
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
function variableDraftToWorkspaceItem(
|
||||
storagePath: string,
|
||||
draft: CreateVariable,
|
||||
isDraft = true
|
||||
): WorkspaceItem | undefined {
|
||||
const path = getItemPath(storagePath, draft)
|
||||
if (!path) return undefined
|
||||
|
||||
return {
|
||||
type: 'variable',
|
||||
path,
|
||||
summary: draft.description ?? undefined,
|
||||
value: clone(draft),
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
function sharedDraftEntryToWorkspaceItem(
|
||||
entry: UserDraftListEntry,
|
||||
isDraft = true
|
||||
): WorkspaceItem | undefined {
|
||||
switch (entry.itemKind) {
|
||||
case 'script':
|
||||
return scriptDraftToWorkspaceItem(entry.storagePath, entry.value as NewScript, isDraft)
|
||||
case 'flow':
|
||||
return flowDraftToWorkspaceItem(entry.storagePath, entry.value, isDraft)
|
||||
case 'raw_app':
|
||||
return appDraftToWorkspaceItem(entry.storagePath, entry.value as AppDraftValue, isDraft)
|
||||
case 'trigger_schedule':
|
||||
return scheduleDraftToWorkspaceItem(entry.storagePath, entry.value as NewSchedule, isDraft)
|
||||
case 'resource':
|
||||
return resourceDraftToWorkspaceItem(entry.storagePath, entry.value as CreateResource, isDraft)
|
||||
case 'variable':
|
||||
return variableDraftToWorkspaceItem(entry.storagePath, entry.value as CreateVariable, isDraft)
|
||||
default:
|
||||
const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind]
|
||||
return triggerKind
|
||||
? triggerDraftToWorkspaceItem(
|
||||
triggerKind,
|
||||
entry.storagePath,
|
||||
entry.value as TriggerRequestBody,
|
||||
isDraft
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
type LocalWorkspaceItemLookup = {
|
||||
storagePath: string
|
||||
source: UserDraftEntrySource
|
||||
draftSource?: UserDraftListEntry['draftSource']
|
||||
item: WorkspaceItem
|
||||
}
|
||||
|
||||
function isDeployableEntry(entry: Pick<LocalWorkspaceItemLookup, 'source' | 'draftSource'>): boolean {
|
||||
return entry.source !== 'live' && entry.draftSource === 'external'
|
||||
}
|
||||
|
||||
function itemHasTarget(
|
||||
item: WorkspaceItem,
|
||||
type: SharedWorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): boolean {
|
||||
if (item.type !== type) return false
|
||||
if (item.path !== path) return false
|
||||
return type !== 'trigger' || item.triggerKind === triggerKind
|
||||
}
|
||||
|
||||
function listSharedEntries(
|
||||
workspace: string,
|
||||
itemKinds: UserDraftItemKind[],
|
||||
isDraft: boolean
|
||||
): LocalWorkspaceItemLookup[] {
|
||||
const entries: LocalWorkspaceItemLookup[] = []
|
||||
for (const entry of UserDraft.list({ workspace, itemKinds })) {
|
||||
const item = sharedDraftEntryToWorkspaceItem(entry, isDraft)
|
||||
if (!item) continue
|
||||
entries.push({
|
||||
storagePath: entry.storagePath,
|
||||
source: entry.source,
|
||||
draftSource: entry.draftSource,
|
||||
item
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function findSharedEntry(
|
||||
workspace: string,
|
||||
type: SharedWorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind: TriggerKind | undefined,
|
||||
mode: 'draft' | 'current'
|
||||
): LocalWorkspaceItemLookup | undefined {
|
||||
const itemKind = sharedDraftKind(type, triggerKind)
|
||||
if (!itemKind) return undefined
|
||||
|
||||
for (const entry of listSharedEntries(workspace, [itemKind], mode === 'draft')) {
|
||||
if (mode === 'draft' && !isDeployableEntry(entry)) continue
|
||||
if (itemHasTarget(entry.item, type, path, triggerKind)) return entry
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getSharedItem(
|
||||
workspace: string,
|
||||
type: SharedWorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind,
|
||||
mode: 'draft' | 'current' = 'draft'
|
||||
): WorkspaceItem | undefined {
|
||||
return findSharedEntry(workspace, type, path, triggerKind, mode)?.item
|
||||
}
|
||||
|
||||
function deleteSharedDraft(
|
||||
workspace: string,
|
||||
type: SharedWorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): void {
|
||||
const itemKind = sharedDraftKind(type, triggerKind)
|
||||
if (!itemKind) return
|
||||
const draft = findSharedEntry(workspace, type, path, triggerKind, 'current')
|
||||
UserDraft.clear(itemKind, draft?.storagePath ?? path, { workspace })
|
||||
}
|
||||
|
||||
export function getGlobalDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
if (isSharedWorkspaceItemType(type)) {
|
||||
return getSharedItem(workspace, type, path, triggerKind, 'draft')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the freshest frontend-visible value for AI context. This may be a
|
||||
* real local draft, an AI-written UserDraft, or a clean live editor baseline.
|
||||
* Therefore the returned item is intentionally not marked as `isDraft`.
|
||||
*/
|
||||
export function getGlobalCurrentItem(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
if (isSharedWorkspaceItemType(type)) {
|
||||
return getSharedItem(workspace, type, path, triggerKind, 'current')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getGlobalDraftStoragePath(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): string | undefined {
|
||||
if (isSharedWorkspaceItemType(type)) {
|
||||
return findSharedEntry(workspace, type, path, triggerKind, 'current')?.storagePath
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
|
||||
const drafts = new Map<string, WorkspaceItem>()
|
||||
|
||||
for (const entry of listSharedEntries(workspace, [...SHARED_DRAFT_KINDS], true)) {
|
||||
if (!isDeployableEntry(entry)) continue
|
||||
const { item: draft } = entry
|
||||
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
|
||||
}
|
||||
|
||||
return Array.from(drafts.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists local/current entries for AI context. Entries are not marked as
|
||||
* `isDraft` because UserDraft.list can include clean live editor baselines.
|
||||
*/
|
||||
export function listGlobalCurrentItems(
|
||||
workspace: string,
|
||||
types?: readonly WorkspaceItemType[]
|
||||
): WorkspaceItem[] {
|
||||
const items = new Map<string, WorkspaceItem>()
|
||||
|
||||
for (const { item } of listSharedEntries(
|
||||
workspace,
|
||||
sharedDraftKindsForTypes(types),
|
||||
false
|
||||
)) {
|
||||
items.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item)
|
||||
}
|
||||
|
||||
return Array.from(items.values())
|
||||
}
|
||||
|
||||
export function deleteGlobalDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): void {
|
||||
if (isSharedWorkspaceItemType(type)) {
|
||||
deleteSharedDraft(workspace, type, path, triggerKind)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGlobalDrafts(workspace: string): void {
|
||||
for (const draft of UserDraft.list({ workspace, itemKinds: [...SHARED_DRAFT_KINDS] })) {
|
||||
if (!isDeployableEntry(draft)) continue
|
||||
UserDraft.clear(draft.itemKind, draft.storagePath, { workspace })
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind {
|
||||
return TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[kind]
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
AzureTriggerData,
|
||||
CreateResource,
|
||||
CreateVariable,
|
||||
FlowValue,
|
||||
GcpTriggerData,
|
||||
NewHttpTrigger,
|
||||
NewKafkaTrigger,
|
||||
NewMqttTrigger,
|
||||
NewNatsTrigger,
|
||||
NewPostgresTrigger,
|
||||
NewSchedule,
|
||||
NewSqsTrigger,
|
||||
NewWebsocketTrigger,
|
||||
Policy,
|
||||
ScriptLang
|
||||
} from '$lib/gen/types.gen'
|
||||
|
||||
/**
|
||||
* Flow draft value. Mirrors what the backend's create/update flow API expects
|
||||
* -- the OpenFlow value, plus the inputs schema and (optional) groups.
|
||||
*
|
||||
* Schema and groups are split out from FlowValue intentionally so that
|
||||
* deploy_workspace_item can preserve them through the draft -> workspace
|
||||
* round-trip; an earlier version dropped them on every deploy.
|
||||
*/
|
||||
export type FlowDraftValue = {
|
||||
value: FlowValue
|
||||
schema?: Record<string, any> | null
|
||||
groups?: NonNullable<FlowValue['groups']> | null
|
||||
}
|
||||
|
||||
export const TRIGGER_KINDS = [
|
||||
'http',
|
||||
'websocket',
|
||||
'kafka',
|
||||
'nats',
|
||||
'postgres',
|
||||
'mqtt',
|
||||
'sqs',
|
||||
'gcp',
|
||||
'azure'
|
||||
] as const
|
||||
|
||||
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
|
||||
|
||||
export type TriggerRequestBody =
|
||||
| NewHttpTrigger
|
||||
| NewWebsocketTrigger
|
||||
| NewKafkaTrigger
|
||||
| NewNatsTrigger
|
||||
| NewPostgresTrigger
|
||||
| NewMqttTrigger
|
||||
| NewSqsTrigger
|
||||
| GcpTriggerData
|
||||
| AzureTriggerData
|
||||
|
||||
export type WorkspaceItemType =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'schedule'
|
||||
| 'trigger'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'app'
|
||||
|
||||
export type AppDraftValue = {
|
||||
summary?: string
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, any>
|
||||
data?: any
|
||||
policy?: Policy
|
||||
custom_path?: string
|
||||
}
|
||||
|
||||
export type WorkspaceItem = {
|
||||
type: WorkspaceItemType
|
||||
path: string
|
||||
summary?: string
|
||||
language?: ScriptLang
|
||||
triggerKind?: TriggerKind
|
||||
value?:
|
||||
| string
|
||||
| FlowDraftValue
|
||||
| NewSchedule
|
||||
| TriggerRequestBody
|
||||
| CreateResource
|
||||
| CreateVariable
|
||||
| AppDraftValue
|
||||
isDraft: boolean
|
||||
}
|
||||
|
||||
export function getWorkspaceItemKey(
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): string {
|
||||
if (type === 'trigger') {
|
||||
return `trigger:${triggerKind ?? ''}:${path}`
|
||||
}
|
||||
return `${type}:${path}`
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
async function importRaw() {
|
||||
$importFlowStore =
|
||||
importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '')
|
||||
await goto('/flows/add')
|
||||
await goto('/flows/add?nodraft=true')
|
||||
drawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
const parsed =
|
||||
wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '')
|
||||
$importScriptStore = parsed
|
||||
await goto(`${base}/scripts/add?import=true`)
|
||||
await goto(`${base}/scripts/add?import=true&nodraft=true`)
|
||||
wacDrawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type Drawer from '../common/drawer/Drawer.svelte'
|
||||
import { type Policy, WorkspaceService } from '$lib/gen'
|
||||
import DiffDrawer from '../DiffDrawer.svelte'
|
||||
import { encodeState } from '$lib/utils'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
|
||||
// import { addWmillClient } from './utils'
|
||||
@@ -169,25 +168,6 @@
|
||||
})
|
||||
historyManager.manualSnapshot(files ?? {}, runnables, summary, data)
|
||||
|
||||
let draftTimeout: number | undefined = undefined
|
||||
function saveFrontendDraft() {
|
||||
draftTimeout && clearTimeout(draftTimeout)
|
||||
draftTimeout = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
path != '' ? `rawapp-${path}` : 'rawapp',
|
||||
encodeState({
|
||||
files,
|
||||
runnables: runnables,
|
||||
data: data
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
let iframe: HTMLIFrameElement | undefined = $state(undefined)
|
||||
let yamlEditorDrawer: Drawer | undefined = $state(undefined)
|
||||
|
||||
@@ -393,7 +373,6 @@
|
||||
if (data.datatable !== policy.datatable || data.schema !== policy.schema) {
|
||||
data.datatable = policy.datatable
|
||||
data.schema = policy.schema
|
||||
saveFrontendDraft()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -634,7 +613,6 @@
|
||||
// Only add if not already present
|
||||
if (!data.tables.includes(newRef)) {
|
||||
data.tables = [...data.tables, newRef]
|
||||
saveFrontendDraft()
|
||||
// Clear the cached schema so it gets refreshed with the new table
|
||||
const resourcePath = `datatable://${datatableName}`
|
||||
delete $dbSchemas[resourcePath]
|
||||
@@ -664,7 +642,6 @@
|
||||
// Only add if not already present
|
||||
if (!data.tables.includes(newRef)) {
|
||||
data.tables = [...data.tables, newRef]
|
||||
saveFrontendDraft()
|
||||
void aiChatManager.refreshDatatables()
|
||||
}
|
||||
}
|
||||
@@ -752,9 +729,6 @@
|
||||
}
|
||||
|
||||
let darkMode: boolean = $state(false)
|
||||
$effect(() => {
|
||||
runnables && files && saveFrontendDraft()
|
||||
})
|
||||
$effect(() => {
|
||||
iframe?.addEventListener('load', () => {
|
||||
iframeLoaded = true
|
||||
@@ -959,7 +933,6 @@
|
||||
dataTableRefs={dataTableRefsObjects}
|
||||
onDataTableRefsChange={(newRefs) => {
|
||||
data.tables = newRefs.map(formatDataTableRef)
|
||||
saveFrontendDraft()
|
||||
}}
|
||||
defaultDatatable={data.datatable}
|
||||
defaultSchema={data.schema}
|
||||
@@ -972,7 +945,6 @@
|
||||
datatable,
|
||||
schema
|
||||
}
|
||||
saveFrontendDraft()
|
||||
}}
|
||||
{runnables}
|
||||
{modules}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
|
||||
|
||||
import { AppService, DraftService, type Policy } from '$lib/gen'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { rawAppToHubUrl } from '$lib/hub'
|
||||
import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import YAML from 'yaml'
|
||||
@@ -242,14 +243,10 @@
|
||||
}
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
try {
|
||||
localStorage.removeItem(`rawapp-${path}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
UserDraft.remove('raw_app', path)
|
||||
dispatch('savedNewAppPath', path)
|
||||
} catch (e) {
|
||||
sendUserToast('Error creating app', e)
|
||||
sendUserToast(`Error creating app: ${e.body ?? e.message}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,12 +354,8 @@
|
||||
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
UserDraft.remove('raw_app', appPath)
|
||||
if (appPath !== npath) {
|
||||
try {
|
||||
localStorage.removeItem(`rawapp-${appPath}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
dispatch('savedNewAppPath', npath)
|
||||
}
|
||||
}
|
||||
@@ -440,9 +433,13 @@
|
||||
}
|
||||
|
||||
draftDrawerOpen = false
|
||||
// The initial draft was promoted to a real path on the backend —
|
||||
// drop the autosave keyed on the prior (possibly empty) path so
|
||||
// a future "+ App" click opens on a clean slate.
|
||||
UserDraft.remove('raw_app', appPath)
|
||||
dispatch('savedNewAppPath', newEditedPath)
|
||||
} catch (e) {
|
||||
sendUserToast('Error saving initial draft', e)
|
||||
sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true)
|
||||
}
|
||||
draftDrawerOpen = false
|
||||
}
|
||||
@@ -540,11 +537,7 @@
|
||||
}
|
||||
|
||||
sendUserToast('Draft saved')
|
||||
try {
|
||||
localStorage.removeItem(`rawapp-${path}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
UserDraft.remove('raw_app', path)
|
||||
loading.saveDraft = false
|
||||
if (newApp || savedApp.draft_only) {
|
||||
dispatch('savedNewAppPath', newEditedPath || path)
|
||||
|
||||
@@ -32,7 +32,6 @@ export interface ScriptBuilderProps {
|
||||
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
|
||||
searchParams?: URLSearchParams
|
||||
disableHistoryChange?: boolean
|
||||
replaceStateFn?: (url: string) => void
|
||||
customUi?: ScriptBuilderWhitelabelCustomUi
|
||||
savedPrimarySchedule?: ScheduleTrigger | undefined
|
||||
functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
unifiedSize="lg"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
href="{base}/scripts/add"
|
||||
href="{base}/scripts/add?nodraft=true"
|
||||
endIcon={{ icon: Code2 }}
|
||||
>
|
||||
Script
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { saveAzureTriggerFromCfg } from './utils'
|
||||
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
|
||||
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
|
||||
import { base } from '$lib/base'
|
||||
@@ -106,6 +107,16 @@
|
||||
|
||||
let hasChanged = $derived(!deepEqual(getAzureConfig(), originalConfig ?? {}))
|
||||
const azureConfig = $derived.by(getAzureConfig)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_azure',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => azureConfig,
|
||||
applyCfg: loadTriggerConfig,
|
||||
deployed: () => originalConfig
|
||||
})
|
||||
const saveDisabled = $derived(
|
||||
pathError != '' || emptyString(script_path) || !isValid || !can_write || !hasChanged
|
||||
)
|
||||
@@ -124,14 +135,15 @@
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultValues)
|
||||
if (!defaultValues) {
|
||||
initialConfig = structuredClone($state.snapshot(getAzureConfig()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load Azure trigger: ${err.body}`, true)
|
||||
} finally {
|
||||
drawerLoading = false
|
||||
if (!defaultValues) {
|
||||
initialConfig = structuredClone($state.snapshot(getAzureConfig()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +222,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
deploymentLoading = true
|
||||
const previousPath = initialPath
|
||||
const cfg = azureConfig
|
||||
if (!cfg) return
|
||||
const isSaved = await saveAzureTriggerFromCfg(
|
||||
@@ -220,6 +233,7 @@
|
||||
usedTriggerKinds
|
||||
)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, getAzureConfig())
|
||||
onUpdate?.(cfg.path)
|
||||
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
|
||||
initialPath = cfg.path
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
|
||||
import { saveEmailTriggerFromCfg } from './utils'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
|
||||
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
|
||||
|
||||
@@ -88,6 +89,16 @@
|
||||
let hasChanged = $derived(!deepEqual(getEmailTriggerConfig(), originalConfig ?? {}))
|
||||
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
|
||||
const emailConfig = $derived.by(getEmailTriggerConfig)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_email',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => emailConfig,
|
||||
applyCfg: (c) => loadTriggerConfig(c as Partial<EmailTrigger>),
|
||||
deployed: () => originalConfig
|
||||
})
|
||||
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
|
||||
const saveDisabled = $derived(
|
||||
drawerLoading ||
|
||||
@@ -120,14 +131,15 @@
|
||||
dirtyPath = false
|
||||
dirtyLocalPart = false
|
||||
await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load email trigger: ${err}`, true)
|
||||
} finally {
|
||||
if (!defaultConfig) {
|
||||
// If the email trigger is loaded from the backend, we to set the initial config
|
||||
initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load email trigger: ${err}`, true)
|
||||
} finally {
|
||||
clearTimeout(loader)
|
||||
drawerLoading = false
|
||||
showLoader = false
|
||||
@@ -213,6 +225,7 @@
|
||||
drawer?.closeDrawer()
|
||||
} else {
|
||||
deploymentLoading = true
|
||||
const previousPath = initialPath
|
||||
const saveCfg = emailConfig
|
||||
const isSaved = await saveEmailTriggerFromCfg(
|
||||
initialPath,
|
||||
@@ -223,6 +236,7 @@
|
||||
usedTriggerKinds
|
||||
)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, getEmailTriggerConfig())
|
||||
onUpdate(saveCfg.path)
|
||||
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
|
||||
initialPath = saveCfg.path
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import { saveGcpTriggerFromCfg } from './utils'
|
||||
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
|
||||
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
|
||||
import { base } from '$lib/base'
|
||||
@@ -108,6 +109,16 @@
|
||||
|
||||
let hasChanged = $derived(!deepEqual(getGcpConfig(), originalConfig ?? {}))
|
||||
const gcpConfig = $derived.by(getGcpConfig)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_gcp',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => gcpConfig,
|
||||
applyCfg: loadTriggerConfig,
|
||||
deployed: () => originalConfig
|
||||
})
|
||||
const saveDisabled = $derived(
|
||||
pathError != '' || emptyString(script_path) || !isValid || !can_write || !hasChanged
|
||||
)
|
||||
@@ -126,14 +137,15 @@
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultValues)
|
||||
if (!defaultValues) {
|
||||
initialConfig = structuredClone($state.snapshot(getGcpConfig()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load GCP Pub/Sub trigger: ${err.body}`, true)
|
||||
} finally {
|
||||
drawerLoading = false
|
||||
if (!defaultValues) {
|
||||
initialConfig = structuredClone($state.snapshot(getGcpConfig()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +229,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
deploymentLoading = true
|
||||
const previousPath = initialPath
|
||||
const cfg = gcpConfig
|
||||
if (!cfg) {
|
||||
return
|
||||
@@ -229,6 +242,7 @@
|
||||
usedTriggerKinds
|
||||
)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, getGcpConfig())
|
||||
onUpdate?.(cfg.path)
|
||||
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
|
||||
initialPath = cfg.path
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
|
||||
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
|
||||
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
|
||||
import UserSettings from '$lib/components/UserSettings.svelte'
|
||||
@@ -137,6 +138,16 @@
|
||||
let scopes = $derived(['http_triggers:read:' + path])
|
||||
|
||||
const routeConfig = $derived.by(getRouteConfig)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_http',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => routeConfig,
|
||||
applyCfg: (c) => loadTriggerConfig(c as Partial<HttpTrigger>),
|
||||
deployed: () => originalConfig
|
||||
})
|
||||
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
|
||||
const saveDisabled = $derived(
|
||||
drawerLoading ||
|
||||
@@ -219,14 +230,15 @@
|
||||
dirtyPath = false
|
||||
dirtyRoutePath = false
|
||||
await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getRouteConfig()))
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load route: ${err}`, true)
|
||||
} finally {
|
||||
if (!defaultConfig) {
|
||||
// If the route is loaded from the backend, we to set the initial config
|
||||
initialConfig = structuredClone($state.snapshot(getRouteConfig()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getRouteConfig()))
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load route: ${err}`, true)
|
||||
} finally {
|
||||
clearTimeout(loader)
|
||||
drawerLoading = false
|
||||
showLoader = false
|
||||
@@ -346,6 +358,7 @@
|
||||
drawer?.closeDrawer()
|
||||
} else {
|
||||
deploymentLoading = true
|
||||
const previousPath = initialPath
|
||||
const saveCfg = routeConfig
|
||||
const isSaved = await saveHttpRouteFromCfg(
|
||||
initialPath,
|
||||
@@ -356,6 +369,7 @@
|
||||
usedTriggerKinds
|
||||
)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, getRouteConfig())
|
||||
onUpdate(saveCfg.path)
|
||||
originalConfig = structuredClone($state.snapshot(getRouteConfig()))
|
||||
initialPath = saveCfg.path
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
|
||||
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
|
||||
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
|
||||
import TriggerFilters from '../TriggerFilters.svelte'
|
||||
@@ -126,6 +127,16 @@
|
||||
!hasChanged
|
||||
)
|
||||
const kafkaConfig = $derived.by(getSaveCfg)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_kafka',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => kafkaConfig,
|
||||
applyCfg: loadTriggerConfig,
|
||||
deployed: () => originalConfig
|
||||
})
|
||||
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
|
||||
|
||||
$effect(() => {
|
||||
@@ -148,13 +159,14 @@
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load Kafka trigger: ${err}`, true)
|
||||
} finally {
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load Kafka trigger: ${err}`, true)
|
||||
} finally {
|
||||
clearTimeout(loadingTimeout)
|
||||
drawerLoading = false
|
||||
showLoading = false
|
||||
@@ -269,6 +281,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
deploymentLoading = true
|
||||
const previousPath = initialPath
|
||||
const cfg = getSaveCfg()
|
||||
const isSaved = await saveKafkaTriggerFromCfg(
|
||||
initialPath,
|
||||
@@ -278,6 +291,7 @@
|
||||
usedTriggerKinds
|
||||
)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, getSaveCfg())
|
||||
onUpdate?.(cfg.path)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
initialPath = cfg.path
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
|
||||
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
|
||||
interface Props {
|
||||
useDrawer?: boolean
|
||||
@@ -115,6 +116,16 @@
|
||||
|
||||
let hasChanged = $derived(!deepEqual(getSaveCfg(), originalConfig ?? {}))
|
||||
const mqttConfig = $derived.by(getSaveCfg)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_mqtt',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => mqttConfig,
|
||||
applyCfg: loadTriggerConfig,
|
||||
deployed: () => originalConfig
|
||||
})
|
||||
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
|
||||
const saveDisabled = $derived(
|
||||
pathError != '' || emptyString(script_path) || !can_write || !isValid || !hasChanged
|
||||
@@ -144,13 +155,14 @@
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultConfig)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load mqtt trigger: ${err.body}`, true)
|
||||
} finally {
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load mqtt trigger: ${err.body}`, true)
|
||||
} finally {
|
||||
clearTimeout(loadingTimeout)
|
||||
drawerLoading = false
|
||||
showLoading = false
|
||||
@@ -282,6 +294,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
deploymentLoading = true
|
||||
const previousPath = initialPath
|
||||
const cfg = getSaveCfg()
|
||||
const isSaved = await saveMqttTriggerFromCfg(
|
||||
initialPath,
|
||||
@@ -291,6 +304,7 @@
|
||||
usedTriggerKinds
|
||||
)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, getSaveCfg())
|
||||
onUpdate?.(cfg.path)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
initialPath = cfg.path
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
|
||||
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
|
||||
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
|
||||
|
||||
@@ -110,6 +111,16 @@
|
||||
pathError != '' || emptyString(script_path) || !can_write || !isValid || !hasChanged
|
||||
)
|
||||
const natsConfig = $derived.by(getSaveCfg)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_nats',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => natsConfig,
|
||||
applyCfg: loadTriggerConfig,
|
||||
deployed: () => originalConfig
|
||||
})
|
||||
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
|
||||
|
||||
$effect(() => {
|
||||
@@ -132,16 +143,17 @@
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultConfig)
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load nats trigger: ${err}`, true)
|
||||
} finally {
|
||||
clearTimeout(loadingTimeout)
|
||||
drawerLoading = false
|
||||
showLoading = false
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +260,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
deploymentLoading = true
|
||||
const previousPath = initialPath
|
||||
const cfg = natsConfig
|
||||
const isSaved = await saveNatsTriggerFromCfg(
|
||||
initialPath,
|
||||
@@ -257,6 +270,7 @@
|
||||
usedTriggerKinds
|
||||
)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, getSaveCfg())
|
||||
onUpdate?.(cfg.path)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
initialPath = cfg.path
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
|
||||
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
import { capitalize } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
@@ -162,6 +163,16 @@
|
||||
)
|
||||
|
||||
const postgresConfig = $derived.by(getSaveCfg)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_postgres',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => postgresConfig,
|
||||
applyCfg: loadTriggerConfig,
|
||||
deployed: () => originalConfig
|
||||
})
|
||||
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
|
||||
|
||||
const saveDisabled = $derived(
|
||||
@@ -239,13 +250,14 @@
|
||||
transaction_to_track = []
|
||||
tab = 'basic'
|
||||
await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load postgres trigger: ${err.body}`, true)
|
||||
} finally {
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load postgres trigger: ${err.body}`, true)
|
||||
} finally {
|
||||
clearTimeout(loadingTimeout)
|
||||
drawerLoading = false
|
||||
showLoading = false
|
||||
@@ -400,6 +412,7 @@
|
||||
if (!cfg) {
|
||||
return
|
||||
}
|
||||
const previousPath = initialPath
|
||||
deploymentLoading = true
|
||||
const isSaved = await savePostgresTriggerFromCfg(
|
||||
initialPath,
|
||||
@@ -409,6 +422,7 @@
|
||||
usedTriggerKinds
|
||||
)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, getSaveCfg())
|
||||
onUpdate?.(path)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
initialPath = cfg.path
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
import { runScheduleNow } from '../scheduled/utils'
|
||||
import { handleConfigChange } from '../utils'
|
||||
import { withForkConflictRetry } from '$lib/utils/forkConflict'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import PermissionedAsLine from '../PermissionedAsLine.svelte'
|
||||
@@ -130,6 +131,16 @@
|
||||
)
|
||||
const scheduleCfg = $derived.by(getScheduleCfg)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_schedule',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => scheduleCfg,
|
||||
applyCfg: loadScheduleCfg,
|
||||
deployed: () => initialConfig
|
||||
})
|
||||
|
||||
export async function openEdit(ePath: string, isFlow: boolean, defaultCfg?: Record<string, any>) {
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
showLoading = true
|
||||
@@ -142,10 +153,11 @@
|
||||
path = defaultCfg?.path ?? ePath
|
||||
await loadSchedule(defaultCfg)
|
||||
edit = true
|
||||
} finally {
|
||||
if (!defaultCfg) {
|
||||
initialConfig = structuredClone($state.snapshot(getScheduleCfg()))
|
||||
}
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} finally {
|
||||
clearTimeout(loadingTimeout)
|
||||
drawerLoading = false
|
||||
showLoading = false
|
||||
@@ -527,10 +539,12 @@
|
||||
}
|
||||
|
||||
async function scheduleScript(): Promise<void> {
|
||||
const previousPath = initialPath
|
||||
const scheduleCfg = getScheduleCfg()
|
||||
deploymentLoading = true
|
||||
const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, $workspaceStore!)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, scheduleCfg)
|
||||
onUpdate?.(scheduleCfg.path)
|
||||
drawer?.closeDrawer()
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
|
||||
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
|
||||
interface Props {
|
||||
useDrawer?: boolean
|
||||
@@ -102,6 +103,16 @@
|
||||
let originalConfig = $state<Record<string, any> | undefined>(undefined)
|
||||
|
||||
const sqsConfig = $derived.by(getSaveCfg)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_sqs',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => sqsConfig,
|
||||
applyCfg: loadTriggerConfig,
|
||||
deployed: () => originalConfig
|
||||
})
|
||||
const captureConfig = $derived.by(getCaptureConfig)
|
||||
const hasChanged = $derived(!deepEqual(sqsConfig, originalConfig ?? {}))
|
||||
const saveDisabled = $derived(
|
||||
@@ -127,13 +138,17 @@
|
||||
edit = true
|
||||
dirtyPath = false
|
||||
await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load sqs trigger: ${err.body}`, true)
|
||||
} finally {
|
||||
// Snapshot the *backend* config as the baseline before overlaying
|
||||
// any local autosave, so hasChanged / onConfigChange correctly
|
||||
// flag the local edits as unsaved changes.
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load sqs trigger: ${err.body}`, true)
|
||||
} finally {
|
||||
clearTimeout(loadingTimeout)
|
||||
drawerLoading = false
|
||||
showLoading = false
|
||||
@@ -267,6 +282,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
deploymentLoading = true
|
||||
const previousPath = initialPath
|
||||
const cfg = getSaveCfg()
|
||||
const isSaved = await saveSqsTriggerFromCfg(
|
||||
initialPath,
|
||||
@@ -276,6 +292,7 @@
|
||||
usedTriggerKinds
|
||||
)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, getSaveCfg())
|
||||
onUpdate?.(cfg.path)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
initialPath = cfg.path
|
||||
@@ -307,6 +324,11 @@
|
||||
handleConfigChange(sqsConfig, initialConfig, saveDisabled, edit, onConfigChange)
|
||||
}
|
||||
})
|
||||
|
||||
// Persist edits to UserDraft so an accidental drawer close doesn't lose
|
||||
// in-progress work. Skipped while the drawer is loading (the freshly
|
||||
// loaded backend value isn't a user edit) and for new triggers without
|
||||
// a path (no localStorage write would happen anyway).
|
||||
</script>
|
||||
|
||||
{#if mode === 'suspended'}
|
||||
|
||||
@@ -39,16 +39,10 @@ export class Triggers {
|
||||
? this.#triggers[this.#selectedTriggerIndex]
|
||||
: undefined
|
||||
)
|
||||
#updateDraftCallback: (() => void) | undefined = undefined
|
||||
|
||||
constructor(
|
||||
triggers: Trigger[] = [],
|
||||
selectedIndex?: number,
|
||||
updateDraftCallback?: (() => void) | undefined
|
||||
) {
|
||||
constructor(triggers: Trigger[] = [], selectedIndex?: number) {
|
||||
this.#triggers = triggers
|
||||
this.#selectedTriggerIndex = selectedIndex
|
||||
this.#updateDraftCallback = updateDraftCallback
|
||||
}
|
||||
|
||||
get selectedTrigger(): Trigger | undefined {
|
||||
@@ -65,7 +59,6 @@ export class Triggers {
|
||||
} else {
|
||||
this.#selectedTriggerIndex = index
|
||||
}
|
||||
this.#updateDraftCallback?.()
|
||||
}
|
||||
|
||||
get triggers(): Trigger[] {
|
||||
@@ -74,16 +67,13 @@ export class Triggers {
|
||||
|
||||
setTriggers(triggers: Trigger[]) {
|
||||
this.#triggers = triggers
|
||||
this.#updateDraftCallback?.()
|
||||
}
|
||||
|
||||
setDraftConfig(triggerIndex: number, draftConfig: Record<string, any> | undefined) {
|
||||
console.log('setDraftConfig', triggerIndex, draftConfig)
|
||||
if (triggerIndex === undefined || triggerIndex < 0 || triggerIndex >= this.#triggers.length) {
|
||||
return
|
||||
}
|
||||
this.#triggers[triggerIndex].draftConfig = draftConfig
|
||||
this.#updateDraftCallback?.()
|
||||
}
|
||||
|
||||
getDraftTriggersSnapshot(): Trigger[] | undefined {
|
||||
@@ -116,7 +106,6 @@ export class Triggers {
|
||||
}
|
||||
|
||||
this.#triggers.push(newTrigger)
|
||||
this.#updateDraftCallback?.()
|
||||
|
||||
updateTriggersCount(triggersCountStore, type, 'add', newTrigger.draftConfig)
|
||||
|
||||
@@ -135,7 +124,6 @@ export class Triggers {
|
||||
this.#triggers = this.#triggers.filter((_, index) => index !== triggerIndex)
|
||||
|
||||
updateTriggersCount(triggersCountStore, type, 'remove')
|
||||
this.#updateDraftCallback?.()
|
||||
}
|
||||
|
||||
updateTriggers(
|
||||
@@ -172,7 +160,6 @@ export class Triggers {
|
||||
const newTriggers = sortTriggers([...filteredTriggers, ...backendTriggers])
|
||||
this.#triggers = newTriggers
|
||||
|
||||
this.#updateDraftCallback?.()
|
||||
return newTriggers.filter((t) => t.type === type).length
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { untrack } from 'svelte'
|
||||
import { UserDraft, localDraftDiffers, type UserDraftItemKind } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
|
||||
type Cfg = Record<string, any>
|
||||
|
||||
export interface TriggerDraftSyncOptions {
|
||||
/** UserDraft item kind for this trigger, e.g. `'trigger_postgres'`. */
|
||||
itemKind: UserDraftItemKind
|
||||
/** Reactive editor path (the trigger being edited). */
|
||||
path: () => string
|
||||
/** Reactive workspace ($workspaceStore). */
|
||||
workspace: () => string | undefined
|
||||
/** Reactive loading flag — both effects are inert while true. */
|
||||
drawerLoading: () => boolean
|
||||
/** form → config: the editor's `getXCfg()` (or its `$derived`). */
|
||||
getCfg: () => Cfg | undefined
|
||||
/** config → form: the editor's `loadXConfig` / `loadScheduleCfg`. May be async. */
|
||||
applyCfg: (cfg: Cfg) => void | Promise<void>
|
||||
/**
|
||||
* The deployed baseline the editor's dirty check compares against —
|
||||
* `originalConfig` for TriggerCrud editors, `initialConfig` for Schedule.
|
||||
*/
|
||||
deployed: () => Cfg | undefined
|
||||
}
|
||||
|
||||
export interface TriggerDraftSync {
|
||||
/** The local autosave for the active (workspace, path), if any. */
|
||||
readonly draft: Cfg | undefined
|
||||
/**
|
||||
* Restore-on-open: if a local autosave diverges from the just-loaded
|
||||
* backend config, overlay it and toast a "Reset to deployed" action.
|
||||
* Call right after the backend load, before clearing `drawerLoading`.
|
||||
*/
|
||||
maybeRestore(path: string): Promise<void>
|
||||
/**
|
||||
* Clear the draft for `path` and reset the handle's in-memory cell to
|
||||
* `fallback`. Use after a successful deploy, passing the just-saved cfg —
|
||||
* `discard` (not `UserDraft.remove`) so the apply-effect doesn't bounce
|
||||
* the form back to the now-stale draft.
|
||||
*/
|
||||
discard(path: string, fallback: Cfg | undefined): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared local-autosave wiring for the trigger editors. Holding a live
|
||||
* `UserDraft` handle is what makes an external `UserDraft.saveExternal('trigger_x',
|
||||
* …)` (another tab, a programmatic write) propagate into the open editor.
|
||||
*
|
||||
* - **apply-effect**: reflects external `handle.draft` changes into the form.
|
||||
* - **persist-effect**: writes form edits back through the handle, dropping
|
||||
* the draft when the form is back at the deployed baseline.
|
||||
*
|
||||
* Both effect bodies are `untrack`ed and gated by `localDraftDiffers`
|
||||
* idempotence so they can't feed back into each other. Must be called once
|
||||
* during component init (it registers `useMany` + two `$effect`s).
|
||||
*/
|
||||
export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraftSync {
|
||||
const handles = UserDraft.useMany<Cfg>(() => {
|
||||
const p = opts.path()
|
||||
const ws = opts.workspace()
|
||||
return p && ws ? [{ itemKind: opts.itemKind, path: p, workspace: ws }] : []
|
||||
})
|
||||
const handle = $derived(handles[0])
|
||||
|
||||
function discard(path: string, fallback: Cfg | undefined): void {
|
||||
UserDraft.discard(opts.itemKind, path, fallback, {
|
||||
workspace: opts.workspace() ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
// apply-effect: external handle.draft → form.
|
||||
$effect(() => {
|
||||
const d = handle?.draft
|
||||
if (opts.drawerLoading() || d == null) return
|
||||
untrack(() => {
|
||||
if (localDraftDiffers(d, opts.getCfg() as Cfg)) {
|
||||
void opts.applyCfg(d)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// persist-effect: form edits → handle; drop the draft when back at the
|
||||
// deployed baseline.
|
||||
$effect(() => {
|
||||
if (opts.drawerLoading() || !opts.path()) return
|
||||
const cfg = opts.getCfg()
|
||||
if (cfg == null) return
|
||||
untrack(() => {
|
||||
const h = handle
|
||||
if (!h) return
|
||||
const deployed = opts.deployed()
|
||||
if (localDraftDiffers(cfg, deployed)) {
|
||||
if (localDraftDiffers(cfg, h.draft)) h.draft = cfg
|
||||
} else {
|
||||
discard(opts.path(), deployed)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
get draft() {
|
||||
return handle?.draft
|
||||
},
|
||||
async maybeRestore(path: string) {
|
||||
const d = handle?.draft
|
||||
if (!localDraftDiffers(d, opts.getCfg() as Cfg)) return
|
||||
// Snapshot the just-loaded backend config so "Reset to deployed"
|
||||
// can re-apply it, then overlay the local autosave.
|
||||
const deployedCfg = structuredClone($state.snapshot(opts.getCfg())) as Cfg
|
||||
await opts.applyCfg(d)
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToDeployed: async () => {
|
||||
discard(path, deployedCfg)
|
||||
await opts.applyCfg(deployedCfg)
|
||||
}
|
||||
})
|
||||
},
|
||||
discard
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
|
||||
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
|
||||
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
|
||||
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
|
||||
import { capitalize } from '$lib/utils'
|
||||
@@ -130,6 +131,16 @@
|
||||
|
||||
let hasChanged = $derived(!deepEqual(getSaveCfg(), originalConfig ?? {}))
|
||||
const websocketCfg = $derived.by(getSaveCfg)
|
||||
|
||||
const draftSync = useTriggerDraftSync({
|
||||
itemKind: 'trigger_websocket',
|
||||
path: () => initialPath,
|
||||
workspace: () => $workspaceStore,
|
||||
drawerLoading: () => drawerLoading,
|
||||
getCfg: () => websocketCfg,
|
||||
applyCfg: loadTriggerConfig,
|
||||
deployed: () => originalConfig
|
||||
})
|
||||
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
|
||||
const saveDisabled = $derived.by(() => {
|
||||
const invalidInitialMessages = initial_messages.some((v) => {
|
||||
@@ -176,13 +187,14 @@
|
||||
dirtyPath = false
|
||||
dirtyUrl = false
|
||||
await loadTrigger(defaultConfig)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load websocket trigger: ${err}`, true)
|
||||
} finally {
|
||||
if (!defaultConfig) {
|
||||
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
}
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
await draftSync.maybeRestore(ePath)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load websocket trigger: ${err}`, true)
|
||||
} finally {
|
||||
clearTimeout(loadingTimeout)
|
||||
drawerLoading = false
|
||||
showLoading = false
|
||||
@@ -342,6 +354,7 @@
|
||||
|
||||
async function updateTrigger(): Promise<void> {
|
||||
deploymentLoading = true
|
||||
const previousPath = initialPath
|
||||
const saveCfg = getSaveCfg()
|
||||
const isSaved = await saveWebsocketTriggerFromCfg(
|
||||
initialPath,
|
||||
@@ -351,6 +364,7 @@
|
||||
usedTriggerKinds
|
||||
)
|
||||
if (isSaved) {
|
||||
draftSync.discard(previousPath, getSaveCfg())
|
||||
onUpdate?.(saveCfg.path)
|
||||
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
|
||||
initialPath = saveCfg.path
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { updateProgress } from '$lib/tutorialUtils'
|
||||
const { flowStore, flowStateStore, selectionManager, currentEditor } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const { flowStore, flowStateStore, selectionManager, currentEditor } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
interface Props {
|
||||
index: number
|
||||
@@ -48,7 +49,11 @@
|
||||
}
|
||||
|
||||
// Helper function to type text character by character
|
||||
async function typeText(input: HTMLInputElement, text: string, delay: number = DELAY_TYPING): Promise<void> {
|
||||
async function typeText(
|
||||
input: HTMLInputElement,
|
||||
text: string,
|
||||
delay: number = DELAY_TYPING
|
||||
): Promise<void> {
|
||||
input.value = ''
|
||||
input.focus()
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
@@ -60,7 +65,7 @@
|
||||
|
||||
// Helper function to update module summary in flowStore
|
||||
function updateModuleSummary(moduleId: string, summary: string): void {
|
||||
const moduleIndex = flowStore.val.value.modules.findIndex(m => m.id === moduleId)
|
||||
const moduleIndex = flowStore.val.value.modules.findIndex((m) => m.id === moduleId)
|
||||
if (moduleIndex !== -1) {
|
||||
flowStore.val.value.modules[moduleIndex].summary = summary
|
||||
flowStore.val = { ...flowStore.val }
|
||||
@@ -78,9 +83,9 @@
|
||||
// Helper function to find button by text and classes
|
||||
function findButtonByText(text: string, classes: string[] = []): HTMLElement | null {
|
||||
const buttons = Array.from(document.querySelectorAll('button'))
|
||||
return buttons.find(btn => {
|
||||
return buttons.find((btn) => {
|
||||
const hasText = btn.textContent?.includes(text) ?? false
|
||||
const hasClasses = classes.every(cls => btn.classList.contains(cls))
|
||||
const hasClasses = classes.every((cls) => btn.classList.contains(cls))
|
||||
return hasText && (classes.length === 0 || hasClasses)
|
||||
}) as HTMLElement | null
|
||||
}
|
||||
@@ -127,11 +132,6 @@
|
||||
}
|
||||
|
||||
export function runTutorial() {
|
||||
try {
|
||||
localStorage.removeItem('flow')
|
||||
} catch (e) {
|
||||
console.error('Error clearing localStorage', e)
|
||||
}
|
||||
tutorial?.runTutorial()
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
celsius: {
|
||||
type: 'number',
|
||||
description: 'Temperature in Celsius',
|
||||
default: ""
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
required: ['celsius'],
|
||||
@@ -212,7 +212,7 @@
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
index={index}
|
||||
{index}
|
||||
name="flow-live-tutorial"
|
||||
tainted={isFlowTainted(flowStore.val)}
|
||||
on:error
|
||||
@@ -260,7 +260,9 @@
|
||||
overlay.style.left = '0'
|
||||
}
|
||||
|
||||
const celsiusInput = document.querySelector('input[type="number"][placeholder=""]') as HTMLInputElement
|
||||
const celsiusInput = document.querySelector(
|
||||
'input[type="number"][placeholder=""]'
|
||||
) as HTMLInputElement
|
||||
if (celsiusInput) {
|
||||
celsiusInput.value = ''
|
||||
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
@@ -312,7 +314,9 @@
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
const spans = Array.from(document.querySelectorAll('span'))
|
||||
const bunSpan = spans.find(span => span.textContent?.includes('TypeScript (Bun)')) as HTMLElement
|
||||
const bunSpan = spans.find((span) =>
|
||||
span.textContent?.includes('TypeScript (Bun)')
|
||||
) as HTMLElement
|
||||
|
||||
if (bunSpan) {
|
||||
// Animate cursor from add step button to TypeScript (Bun) span
|
||||
@@ -355,7 +359,13 @@
|
||||
side: 'top',
|
||||
onNextClick: () => {
|
||||
if (!step3Complete) {
|
||||
sendUserToast('Please wait for the script to be created...', false, [], undefined, 3000)
|
||||
sendUserToast(
|
||||
'Please wait for the script to be created...',
|
||||
false,
|
||||
[],
|
||||
undefined,
|
||||
3000
|
||||
)
|
||||
return
|
||||
}
|
||||
driver.moveNext()
|
||||
@@ -383,7 +393,9 @@
|
||||
|
||||
// First, type the summary
|
||||
await wait(DELAY_MEDIUM)
|
||||
const summaryInput = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement
|
||||
const summaryInput = document.querySelector(
|
||||
'input[placeholder="Summary"]'
|
||||
) as HTMLInputElement
|
||||
if (summaryInput) {
|
||||
const summaryText = 'Validate temperature input'
|
||||
await typeText(summaryInput, summaryText)
|
||||
@@ -405,8 +417,9 @@
|
||||
|
||||
if (editorState && editorState.type === 'script') {
|
||||
const editor = editorState.editor
|
||||
const moduleA = flowJson.value.modules.find(m => m.id === 'a')
|
||||
const codeToType = (moduleA?.value && 'content' in moduleA.value) ? moduleA.value.content : ''
|
||||
const moduleA = flowJson.value.modules.find((m) => m.id === 'a')
|
||||
const codeToType =
|
||||
moduleA?.value && 'content' in moduleA.value ? moduleA.value.content : ''
|
||||
|
||||
if (codeToType) {
|
||||
editor.setCode('', true)
|
||||
@@ -422,8 +435,11 @@
|
||||
}
|
||||
|
||||
// Update the flow store with the typed code
|
||||
const moduleIndex = flowStore.val.value.modules.findIndex(m => m.id === 'a')
|
||||
if (moduleIndex !== -1 && 'content' in flowStore.val.value.modules[moduleIndex].value) {
|
||||
const moduleIndex = flowStore.val.value.modules.findIndex((m) => m.id === 'a')
|
||||
if (
|
||||
moduleIndex !== -1 &&
|
||||
'content' in flowStore.val.value.modules[moduleIndex].value
|
||||
) {
|
||||
flowStore.val.value.modules[moduleIndex].value = {
|
||||
...flowStore.val.value.modules[moduleIndex].value,
|
||||
content: codeToType
|
||||
@@ -445,13 +461,18 @@
|
||||
},
|
||||
popover: {
|
||||
title: 'Add validation logic',
|
||||
description:
|
||||
"Watch as we write code to validate the temperature input.",
|
||||
description: 'Watch as we write code to validate the temperature input.',
|
||||
side: 'bottom',
|
||||
onNextClick: () => {
|
||||
// Only proceed if code writing is complete
|
||||
if (!step4Complete) {
|
||||
sendUserToast('Please wait for the code to finish typing...', false, [], undefined, 3000)
|
||||
sendUserToast(
|
||||
'Please wait for the code to finish typing...',
|
||||
false,
|
||||
[],
|
||||
undefined,
|
||||
3000
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -524,7 +545,9 @@
|
||||
await wait(DELAY_MEDIUM)
|
||||
|
||||
// Step 2: Move to and click flow_input.celsius
|
||||
const targetButton = document.querySelector('button[title="flow_input.celsius"]') as HTMLElement
|
||||
const targetButton = document.querySelector(
|
||||
'button[title="flow_input.celsius"]'
|
||||
) as HTMLElement
|
||||
if (targetButton) {
|
||||
await moveCursorToElement(fakeCursor, targetButton, DELAY_ANIMATION_LONG)
|
||||
await wait(DELAY_MEDIUM)
|
||||
@@ -636,7 +659,9 @@
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Type summary for script 'b'
|
||||
const summaryInputB = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement
|
||||
const summaryInputB = document.querySelector(
|
||||
'input[placeholder="Summary"]'
|
||||
) as HTMLInputElement
|
||||
if (summaryInputB) {
|
||||
const summaryTextB = 'Convert to Fahrenheit'
|
||||
await typeText(summaryInputB, summaryTextB)
|
||||
@@ -655,7 +680,9 @@
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Type summary for script 'c'
|
||||
const summaryInputC = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement
|
||||
const summaryInputC = document.querySelector(
|
||||
'input[placeholder="Summary"]'
|
||||
) as HTMLInputElement
|
||||
if (summaryInputC) {
|
||||
const summaryTextC = 'Categorize temperature'
|
||||
await typeText(summaryInputC, summaryTextC)
|
||||
@@ -680,7 +707,13 @@
|
||||
description: 'Two more scripts to convert and categorize the temperature.',
|
||||
onNextClick: () => {
|
||||
if (!step6Complete) {
|
||||
sendUserToast('Please wait for the summaries to be added...', false, [], undefined, 3000)
|
||||
sendUserToast(
|
||||
'Please wait for the summaries to be added...',
|
||||
false,
|
||||
[],
|
||||
undefined,
|
||||
3000
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -703,7 +736,8 @@
|
||||
element: '#flow-editor-test-flow',
|
||||
popover: {
|
||||
title: 'Ready to test!',
|
||||
description: 'Run the complete flow and see your temperature converter in action.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu or in the <strong>Help</strong> submenu.</p>',
|
||||
description:
|
||||
'Run the complete flow and see your temperature converter in action.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu or in the <strong>Help</strong> submenu.</p>',
|
||||
onNextClick: () => {
|
||||
updateProgress(index)
|
||||
driver.destroy()
|
||||
@@ -712,7 +746,7 @@
|
||||
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
return steps
|
||||
|
||||
@@ -11,12 +11,6 @@ import {
|
||||
import { resetProtectionRules, loadProtectionRules } from './workspaceProtectionRules.svelte'
|
||||
|
||||
export function switchWorkspace(workspace: string | undefined) {
|
||||
try {
|
||||
localStorage.removeItem('flow')
|
||||
localStorage.removeItem('app')
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
resourceTypesStore.set(undefined)
|
||||
|
||||
// Clear protection rules state
|
||||
@@ -32,8 +26,6 @@ export function switchWorkspace(workspace: string | undefined) {
|
||||
|
||||
export function clearStores(): void {
|
||||
try {
|
||||
localStorage.removeItem('flow')
|
||||
localStorage.removeItem('app')
|
||||
clearWorkspaceFromStorage()
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { untrack } from 'svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { StateStore } from './utils'
|
||||
import { readFieldsRecursively, type StateStore } from './utils'
|
||||
import { resource, watch, type ResourceReturn } from 'runed'
|
||||
|
||||
export function withProps<Component, Props>(component: Component, props: Props) {
|
||||
@@ -575,8 +575,36 @@ export class DebouncedTempValue<T> {
|
||||
export function useLocalStorageValue<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
typ?: 'string' | 'number' | 'boolean'
|
||||
): { val: T } {
|
||||
typ?: 'string' | 'number' | 'boolean',
|
||||
options?: {
|
||||
saveInitialValue?: boolean
|
||||
/**
|
||||
* Coalesce localStorage writes within a sliding window. When set to a
|
||||
* positive number, repeated mutations within `debounce` ms produce a
|
||||
* single localStorage write at the end of the window. The in-memory
|
||||
* `$state` is updated immediately on each change — only the persistence
|
||||
* side-effect is deferred. Readers of `.val` always see the latest
|
||||
* value; readers of `localStorage` may see a stale value during the
|
||||
* window. A pending write fires from a plain `setTimeout`, which
|
||||
* keeps running across SPA route teardown — only a hard browser tab
|
||||
* close drops it.
|
||||
*/
|
||||
debounce?: number
|
||||
/**
|
||||
* Transform applied to the value just before serialisation, on every
|
||||
* persist (both setter-driven and deep-mutation-driven). The in-memory
|
||||
* `$state` is left as-is. Useful for injecting per-write metadata
|
||||
* (timestamps, counters) that must reflect the actual write time, not
|
||||
* the last `.val =` assignment — deep mutations don't re-run the
|
||||
* setter, so a timestamp set via `.val =` would otherwise grow stale
|
||||
* across long editing sessions.
|
||||
*/
|
||||
transformBeforePersist?: (val: T) => T
|
||||
}
|
||||
): { val: T; skipNextWriteOnce(): void } {
|
||||
const saveInitialValue = options?.saveInitialValue ?? true
|
||||
const debounceMs = options?.debounce ?? 0
|
||||
const transformBeforePersist = options?.transformBeforePersist
|
||||
const serialize = (val: T) =>
|
||||
typ === 'string' || typ === 'number' || typ === 'boolean' ? String(val) : JSON.stringify(val)
|
||||
const deserialize = (val: string): T => {
|
||||
@@ -585,17 +613,97 @@ export function useLocalStorageValue<T>(
|
||||
if (typ === 'boolean') return (val === 'true') as any
|
||||
return JSON.parse(val) as T
|
||||
}
|
||||
const persist = (val: T | undefined) => {
|
||||
try {
|
||||
if (val === undefined) {
|
||||
localStorage.removeItem(key)
|
||||
} else {
|
||||
const toStore = transformBeforePersist ? transformBeforePersist(val as T) : (val as T)
|
||||
localStorage.setItem(key, serialize(toStore))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('useLocalStorageValue: localStorage write failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return { val: defaultValue }
|
||||
if (typeof window === 'undefined') return { val: defaultValue, skipNextWriteOnce: () => {} }
|
||||
const savedValue = localStorage.getItem(key)
|
||||
let s = $state(savedValue ? (deserialize(savedValue) as T) : defaultValue)
|
||||
let s = $state<T>(
|
||||
savedValue != null && savedValue !== 'undefined' ? (deserialize(savedValue) as T) : defaultValue
|
||||
)
|
||||
|
||||
// Track the serialized form last written so we can detect deep mutations
|
||||
// (changes that didn't go through the setter) without double-writing on
|
||||
// every setter call. The first effect run sees identical serialized output
|
||||
// and is a no-op (avoids persisting the default value on mount).
|
||||
let lastSerialized: string | undefined = untrack(() =>
|
||||
s === undefined ? undefined : serialize(s)
|
||||
)
|
||||
// When saveInitialValue=false, the first time the value actually changes
|
||||
// (either via the setter or a deep mutation) is treated as "loading the
|
||||
// initial value" rather than a user edit — we update lastSerialized so
|
||||
// future writes are detected, but we don't persist.
|
||||
let skipNextWrite = !saveInitialValue
|
||||
|
||||
// Debounce wrapper. Captures the latest pending value; a follow-up call
|
||||
// within the window replaces the queued payload and resets the timer.
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let pendingValue: T | undefined
|
||||
const schedulePersist = (val: T | undefined) => {
|
||||
if (debounceMs <= 0) {
|
||||
persist(val)
|
||||
return
|
||||
}
|
||||
pendingValue = val
|
||||
if (debounceTimer != null) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = undefined
|
||||
persist(pendingValue)
|
||||
pendingValue = undefined
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
readFieldsRecursively(s)
|
||||
const next = s === undefined ? undefined : serialize(s)
|
||||
if (next === lastSerialized) return
|
||||
lastSerialized = next
|
||||
if (skipNextWrite) {
|
||||
skipNextWrite = false
|
||||
return
|
||||
}
|
||||
schedulePersist(s)
|
||||
})
|
||||
|
||||
return {
|
||||
get val() {
|
||||
return s
|
||||
},
|
||||
set val(newVal: T) {
|
||||
localStorage.setItem(key, serialize(newVal))
|
||||
// In-memory state is updated synchronously; the localStorage write
|
||||
// is debounced when `options.debounce` is set. Callers that read
|
||||
// `.val` get the latest value either way; only direct localStorage
|
||||
// reads see the stale value during the debounce window.
|
||||
const next = newVal === undefined ? undefined : serialize(newVal as T)
|
||||
if (next !== lastSerialized) {
|
||||
lastSerialized = next
|
||||
if (skipNextWrite) {
|
||||
skipNextWrite = false
|
||||
} else {
|
||||
schedulePersist(newVal)
|
||||
}
|
||||
}
|
||||
s = newVal
|
||||
},
|
||||
/**
|
||||
* Arm the persist skip so the next `set val` (or deep-mutation flush)
|
||||
* updates only the in-memory cell and leaves localStorage untouched.
|
||||
* Used by `UserDraft.discard` to reset the in-memory state to a
|
||||
* fallback without re-persisting it — pairs with an explicit LS
|
||||
* delete to leave the slot empty.
|
||||
*/
|
||||
skipNextWriteOnce(): void {
|
||||
skipNextWrite = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,16 @@ Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
writable: true
|
||||
})
|
||||
|
||||
// Some modules (e.g. svelte5Utils.useLocalStorageValue) gate browser-only
|
||||
// behavior on `typeof window`. Provide a minimal window so they don't
|
||||
// short-circuit during tests.
|
||||
if (typeof (globalThis as any).window === 'undefined') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: globalThis,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features/worker', () => ({
|
||||
TypeScriptWorker: class TypeScriptWorker {
|
||||
private _mockScriptSnapshot?: {
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { workspaceStore } from './stores'
|
||||
import { useLocalStorageValue } from './svelte5Utils.svelte'
|
||||
|
||||
export type UserDraftItemKind =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'app'
|
||||
| 'raw_app'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'trigger_schedule'
|
||||
| 'trigger_webhook'
|
||||
| 'trigger_default_email'
|
||||
| 'trigger_email'
|
||||
| 'trigger_http'
|
||||
| 'trigger_websocket'
|
||||
| 'trigger_postgres'
|
||||
| 'trigger_kafka'
|
||||
| 'trigger_nats'
|
||||
| 'trigger_mqtt'
|
||||
| 'trigger_sqs'
|
||||
| 'trigger_gcp'
|
||||
| 'trigger_azure'
|
||||
| 'trigger_poll'
|
||||
| 'trigger_cli'
|
||||
| 'trigger_nextcloud'
|
||||
| 'trigger_google'
|
||||
| 'trigger_github'
|
||||
|
||||
export type UserDraftOptions = {
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
export type UserDraftUseOptions<V> = UserDraftOptions & {
|
||||
/**
|
||||
* Initial value used when localStorage holds no draft for this
|
||||
* (workspace, itemKind, path). It is *not* eagerly persisted — the first
|
||||
* actual mutation is what writes to localStorage.
|
||||
*/
|
||||
defaultValue?: V
|
||||
}
|
||||
|
||||
export type UserDraftListOptions = UserDraftOptions & {
|
||||
itemKinds?: UserDraftItemKind[]
|
||||
}
|
||||
|
||||
export type UserDraftEntrySource = 'persisted' | 'live' | 'both'
|
||||
|
||||
export type UserDraftListEntry<V = unknown> = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
/**
|
||||
* Storage key path. For new-item editor drafts this can be empty even
|
||||
* when the draft value already contains its final workspace path.
|
||||
*/
|
||||
path: string
|
||||
storagePath: string
|
||||
source: UserDraftEntrySource
|
||||
draftSource?: UserDraftMeta['draftSource']
|
||||
value: V
|
||||
}
|
||||
|
||||
/**
|
||||
* A single (kind, path, workspace) tuple that `useMany` should hold a handle
|
||||
* for. The shape mirrors `use()`'s arguments, just bundled into one object
|
||||
* so a getter can return a list of them.
|
||||
*/
|
||||
export type UserDraftSpec<V> = {
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
workspace?: string
|
||||
defaultValue?: V
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the remote item's freshness at the moment the local draft was
|
||||
* written. Used by editor routes to detect that the remote has moved on
|
||||
* (someone else deployed, or saved a DB draft) so we can warn the user
|
||||
* before they push stale changes.
|
||||
*
|
||||
* - `remoteRev`: the deployed version's id/hash/timestamp at draft creation.
|
||||
* - `remoteDraftRev`: the DB-draft `created_at` at draft creation, only set
|
||||
* for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`).
|
||||
*/
|
||||
export type UserDraftMeta = {
|
||||
remoteRev?: string | number
|
||||
remoteDraftRev?: string | number
|
||||
draftSource?: 'external'
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of what we actually persist. Wrapping the value lets us add
|
||||
* metadata (timestamps, originating user, schema version, ...) later
|
||||
* without breaking existing entries.
|
||||
*
|
||||
* `lastWrittenAt` is the unix-ms timestamp of the most recent write
|
||||
* (setter call or deep mutation flush). It's the GC signal —
|
||||
* `gcUserDrafts` sweeps entries that haven't been touched in N days.
|
||||
* Set at every persist via `useLocalStorageValue`'s `transformBeforePersist`,
|
||||
* `UserDraft.save`'s direct-write fallback, and `persistDirect`. Missing
|
||||
* (undefined) on entries written before this field was introduced;
|
||||
* `gcUserDrafts` backfills them on first sighting.
|
||||
*/
|
||||
type StoredDraft<V> = { value: V; lastWrittenAt?: number } & UserDraftMeta
|
||||
|
||||
function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefined {
|
||||
if (stored === undefined) return undefined
|
||||
return { ...stored, lastWrittenAt: Date.now() }
|
||||
}
|
||||
|
||||
type DraftState<V> = {
|
||||
val: StoredDraft<V> | undefined
|
||||
skipNextWriteOnce(): void
|
||||
}
|
||||
|
||||
type DraftEntry = {
|
||||
count: number
|
||||
state: DraftState<unknown>
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
/**
|
||||
* Tears down the `$effect.root` scope that owns the entry's
|
||||
* `useLocalStorageValue` reactivity — its `$state` cell and the persist
|
||||
* `$effect` deep-mutation loop. Called when the refcount hits 0.
|
||||
*
|
||||
* `undefined` only when the test runtime's broken `$effect.root` forced
|
||||
* us through the fallback path (see `acquireEntry`).
|
||||
*/
|
||||
destroyRoot?: () => void
|
||||
}
|
||||
|
||||
const entries = new Map<string, DraftEntry>()
|
||||
|
||||
function resolveWorkspace(opts?: UserDraftOptions): string {
|
||||
const ws = opts?.workspace ?? get(workspaceStore)
|
||||
if (!ws) {
|
||||
throw new Error(
|
||||
'UserDraft: no workspace available (pass opts.workspace or set $workspaceStore)'
|
||||
)
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
function wrap<V>(value: V | undefined, meta?: UserDraftMeta): StoredDraft<V> | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const out: StoredDraft<V> = { value }
|
||||
if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev
|
||||
if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev
|
||||
if (meta?.draftSource !== undefined) out.draftSource = meta.draftSource
|
||||
return out
|
||||
}
|
||||
|
||||
function unwrap<V>(stored: StoredDraft<V> | undefined): V | undefined {
|
||||
return stored?.value
|
||||
}
|
||||
|
||||
function extractMeta(stored: StoredDraft<unknown> | undefined): UserDraftMeta {
|
||||
if (!stored) return {}
|
||||
const meta: UserDraftMeta = {}
|
||||
if (stored.remoteRev !== undefined) meta.remoteRev = stored.remoteRev
|
||||
if (stored.remoteDraftRev !== undefined) meta.remoteDraftRev = stored.remoteDraftRev
|
||||
if (stored.draftSource !== undefined) meta.draftSource = stored.draftSource
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the rev metadata recorded against the local draft to the current
|
||||
* backend revs. Returns the staleness cause, or `null` when the local draft
|
||||
* is still based on the latest backend state we know about.
|
||||
*
|
||||
* - Entries with no recorded meta (legacy entries written before this field
|
||||
* existed) report `null` — we can't tell if they're stale, and we'd rather
|
||||
* trust the local autosave than spam the user with false positives.
|
||||
* - DB-draft staleness wins over deployed-version staleness: a remote DB
|
||||
* draft is the more recent state to reconcile against.
|
||||
* - If a DB draft existed when the local autosave was created but now no
|
||||
* longer exists on the remote (someone discarded it), we report `version`
|
||||
* because the deployed version is now the canonical "latest saved".
|
||||
*/
|
||||
export type UserDraftStalenessCause = 'draft' | 'version'
|
||||
|
||||
export function checkStaleness(
|
||||
meta: UserDraftMeta,
|
||||
currentRev: string | number | undefined,
|
||||
currentDraftRev?: string | number | undefined
|
||||
): UserDraftStalenessCause | null {
|
||||
if (meta.remoteRev === undefined && meta.remoteDraftRev === undefined) return null
|
||||
if (meta.remoteDraftRev !== currentDraftRev) {
|
||||
return currentDraftRev !== undefined ? 'draft' : 'version'
|
||||
}
|
||||
if (currentRev !== undefined && meta.remoteRev !== currentRev) return 'version'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous localStorage write, bypassing the entry's debounced setter
|
||||
* and its first-write skip. See `setMeta({ force: true })`.
|
||||
*/
|
||||
function persistDirect<V>(key: string, value: V | undefined, meta: UserDraftMeta): void {
|
||||
try {
|
||||
const next = stamp(wrap(value, meta))
|
||||
if (next === undefined) {
|
||||
localStorage.removeItem(key)
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(next))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('UserDraft: localStorage write failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function readPersisted<V>(key: string): StoredDraft<V> | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null || raw === 'undefined') return undefined
|
||||
const parsed = JSON.parse(raw)
|
||||
// Defensive: ignore pre-wrapping payloads (no `.value`).
|
||||
if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined
|
||||
return parsed as StoredDraft<V>
|
||||
} catch (e) {
|
||||
console.error('UserDraft: localStorage read failed', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
|
||||
return `${workspace}/${itemKind}/${path}`
|
||||
}
|
||||
|
||||
function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
|
||||
return `userdraft/w/${workspace}/${itemKind}/${path}`
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
const snapshot = $state.snapshot(value)
|
||||
try {
|
||||
return structuredClone(snapshot) as T
|
||||
} catch {
|
||||
// Live editor values may contain runtime-only fields (functions,
|
||||
// class instances). Listing should still expose the usable draft
|
||||
// shape; downstream converters copy only the fields they understand.
|
||||
return snapshot as T
|
||||
}
|
||||
}
|
||||
|
||||
function listLocalStorageDrafts(
|
||||
workspace: string,
|
||||
itemKinds: Set<UserDraftItemKind> | undefined
|
||||
): Map<string, UserDraftListEntry> {
|
||||
const drafts = new Map<string, UserDraftListEntry>()
|
||||
if (typeof localStorage === 'undefined') return drafts
|
||||
|
||||
const prefix = `userdraft/w/${workspace}/`
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (!key?.startsWith(prefix)) continue
|
||||
|
||||
const rest = key.slice(prefix.length)
|
||||
const separator = rest.indexOf('/')
|
||||
if (separator === -1) continue
|
||||
|
||||
const itemKind = rest.slice(0, separator) as UserDraftItemKind
|
||||
if (itemKinds && !itemKinds.has(itemKind)) continue
|
||||
|
||||
const path = rest.slice(separator + 1)
|
||||
const stored = readPersisted<unknown>(key)
|
||||
const value = unwrap(stored)
|
||||
if (value === undefined) continue
|
||||
|
||||
drafts.set(mapKey(workspace, itemKind, path), {
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
storagePath: path,
|
||||
source: 'persisted',
|
||||
draftSource: stored?.draftSource,
|
||||
value: clone(value)
|
||||
})
|
||||
}
|
||||
|
||||
return drafts
|
||||
}
|
||||
|
||||
export type UserDraftHandle<V> = {
|
||||
get draft(): V | undefined
|
||||
set draft(value: V | undefined)
|
||||
/**
|
||||
* Read the rev metadata stored alongside the current draft. Empty object
|
||||
* if the entry has no draft or no rev was ever recorded.
|
||||
*/
|
||||
get meta(): UserDraftMeta
|
||||
/**
|
||||
* Set value AND rev metadata in one write (no extra persist). Later
|
||||
* `draft = X` writes preserve the rev metadata.
|
||||
*/
|
||||
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void
|
||||
/**
|
||||
* Update rev metadata without touching the value. `{ force: true }` also
|
||||
* persists synchronously — use when this may be the entry's first write,
|
||||
* else the ack is lost on remount.
|
||||
*/
|
||||
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON round-trip normalization. localStorage persistence stringifies the
|
||||
* draft, which silently drops keys whose value is `undefined`, turns `Date`
|
||||
* into a string, etc. A freshly-built config object (e.g. a trigger editor's
|
||||
* `getXConfig()`) keeps those `undefined`-valued keys, so a raw
|
||||
* `deepEqual(persistedDraft, freshConfig)` reports spurious differences
|
||||
* (`{ a: undefined }` ≠ `{}`). Normalize BOTH sides through the same
|
||||
* round-trip before comparing. Returns the input unchanged if it can't be
|
||||
* serialized (e.g. a cyclic structure) — better a false "differs" than a
|
||||
* throw inside a load/effect path.
|
||||
*/
|
||||
export function normalizeForCompare<V>(value: V | undefined): V | undefined {
|
||||
if (value === undefined) return undefined
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as V
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the persisted local autosave (`localDraft`, as returned by
|
||||
* `UserDraft.get`) meaningfully differs from the freshly-built
|
||||
* `currentConfig`. Editor restore guards use this to decide whether to
|
||||
* overlay the local autosave and toast.
|
||||
*
|
||||
* Returns `false` when there is no local draft. Normalizes both sides (see
|
||||
* `normalizeForCompare`) so a draft that round-trips equal to the deployed
|
||||
* config — e.g. one written by merely opening then closing the editor with
|
||||
* no edits — is correctly treated as "no meaningful draft" instead of
|
||||
* spuriously triggering a restore on every reopen.
|
||||
*
|
||||
* Typed as a guard: a `true` result narrows `localDraft` to non-nullish
|
||||
* `V`, mirroring the `localCfg && …` narrowing it replaces so call sites
|
||||
* can pass the draft straight into `loadXConfig(...)` without re-checking.
|
||||
*/
|
||||
export function localDraftDiffers<V>(
|
||||
localDraft: V | undefined | null,
|
||||
currentConfig: V
|
||||
): localDraft is V {
|
||||
if (localDraft === undefined || localDraft === null) return false
|
||||
return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig))
|
||||
}
|
||||
|
||||
function saveDraft<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V,
|
||||
opts: UserDraftOptions | undefined,
|
||||
metaPatch: UserDraftMeta,
|
||||
persistLiveEntry: boolean
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const key = localStorageKey(ws, itemKind, path)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
// Notify observers; preserve existing rev metadata. `untrack`ed
|
||||
// read — see `set draft` below for why.
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
const meta: UserDraftMeta = { ...extractMeta(current), ...metaPatch }
|
||||
entry.state.val = wrap(value, meta)
|
||||
if (persistLiveEntry) {
|
||||
persistDirect(key, value, meta)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No live handle: preserve any persisted meta so the staleness
|
||||
// signal survives a write while the editor is closed.
|
||||
const existing = readPersisted<unknown>(key)
|
||||
persistDirect(key, value, { ...extractMeta(existing), ...metaPatch })
|
||||
}
|
||||
|
||||
export const UserDraft = {
|
||||
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
|
||||
saveDraft(itemKind, path, value, opts, {}, false)
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist a draft written by an external actor such as global AI mode.
|
||||
* Unlike editor baseline hydration, this must be visible immediately to
|
||||
* draft deployment/listing tools even when a live editor handle is open.
|
||||
*/
|
||||
saveExternal<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
saveDraft(itemKind, path, value, opts, { draftSource: 'external' }, true)
|
||||
},
|
||||
|
||||
/**
|
||||
* Autosave gate: persist `value` only when it differs (after
|
||||
* `normalizeForCompare`) from the `deployed` baseline; otherwise remove
|
||||
* any draft. Without this, opening and closing an editor with no edits
|
||||
* would leave a no-op draft that `has()` / restore guards treat as
|
||||
* unsaved work.
|
||||
*/
|
||||
saveIfChanged<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V,
|
||||
deployed: V | undefined,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
if (deepEqual(normalizeForCompare(value), normalizeForCompare(deployed))) {
|
||||
UserDraft.remove(itemKind, path, opts)
|
||||
} else {
|
||||
UserDraft.save(itemKind, path, value, opts)
|
||||
}
|
||||
},
|
||||
|
||||
get<V = unknown>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
opts?: UserDraftOptions
|
||||
): V | undefined {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
return unwrap(entry.state.val as StoredDraft<V> | undefined)
|
||||
}
|
||||
return unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path)))
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the rev metadata for an entry without touching the value, and
|
||||
* persist immediately. Used by editor routes that don't hold a live
|
||||
* handle (apps, raw apps) — they read the local draft via `UserDraft.get`
|
||||
* and the handle is created later inside the child editor.
|
||||
*
|
||||
* No-op when the entry has no draft to attach meta to.
|
||||
*/
|
||||
saveMeta(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
meta: UserDraftMeta,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
if (current === undefined) return
|
||||
entry.state.val = wrap(current.value, { ...extractMeta(current), ...meta })
|
||||
}
|
||||
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
|
||||
if (existing === undefined) return
|
||||
persistDirect(localStorageKey(ws, itemKind, path), existing.value, {
|
||||
...extractMeta(existing),
|
||||
...meta
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the rev metadata for the entry. Returns an empty object if there
|
||||
* is no entry. Useful for staleness checks before reading the draft.
|
||||
*/
|
||||
getMeta(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): UserDraftMeta {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) return extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
|
||||
return extractMeta(readPersisted<unknown>(localStorageKey(ws, itemKind, path)))
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a draft currently exists for (workspace, itemKind, path).
|
||||
* Falls back to the persisted localStorage entry when no live handle is
|
||||
* registered. Useful for distinguishing "first visit" from "returning
|
||||
* visit with unsaved local changes".
|
||||
*/
|
||||
has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) return entry.state.val !== undefined
|
||||
return readPersisted(localStorageKey(ws, itemKind, path)) !== undefined
|
||||
},
|
||||
|
||||
list(opts?: UserDraftListOptions): UserDraftListEntry[] {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const itemKinds = opts?.itemKinds ? new Set(opts.itemKinds) : undefined
|
||||
const drafts = listLocalStorageDrafts(ws, itemKinds)
|
||||
|
||||
for (const [key, entry] of entries) {
|
||||
if (entry.workspace !== ws) continue
|
||||
if (itemKinds && !itemKinds.has(entry.itemKind)) continue
|
||||
|
||||
const value = unwrap(entry.state.val as StoredDraft<unknown> | undefined)
|
||||
if (value === undefined) continue
|
||||
|
||||
const persisted = drafts.get(key)
|
||||
drafts.set(key, {
|
||||
workspace: ws,
|
||||
itemKind: entry.itemKind,
|
||||
path: entry.path,
|
||||
storagePath: entry.path,
|
||||
source: persisted ? 'both' : 'live',
|
||||
draftSource: extractMeta(entry.state.val as StoredDraft<unknown> | undefined).draftSource,
|
||||
value: clone(value)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(drafts.values())
|
||||
},
|
||||
|
||||
remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey(ws, itemKind, path))
|
||||
} catch (e) {
|
||||
console.error('UserDraft.remove: localStorage remove failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Like `remove`, but also resets any live handle's `draft` to
|
||||
* `fallback` in-memory (so reactive readers see it immediately) and
|
||||
* skips re-persisting it, leaving the LS slot empty until the next real
|
||||
* edit. Pass the deployed baseline as `fallback`.
|
||||
*/
|
||||
discard<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
fallback: V | undefined,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
// Arm the skip before the cell write so the setter suppresses
|
||||
// the persist; the removeItem below actually clears the slot.
|
||||
entry.state.skipNextWriteOnce()
|
||||
entry.state.val = wrap(fallback) as StoredDraft<unknown> | undefined
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey(ws, itemKind, path))
|
||||
} catch (e) {
|
||||
console.error('UserDraft.discard: localStorage remove failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
UserDraft.discard(itemKind, path, undefined, opts)
|
||||
},
|
||||
|
||||
use<V = unknown>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
opts?: UserDraftUseOptions<V>
|
||||
): UserDraftHandle<V> {
|
||||
// `use()` is a single-spec wrapper around `useMany`. We untrack the
|
||||
// getter so that reactive opts (e.g. `$workspaceStore`) are captured
|
||||
// once at call time — the current `use()` contract is "the handle
|
||||
// stays bound to this workspace until the component unmounts." Use
|
||||
// `useMany` directly if you want spec changes to release/acquire
|
||||
// entries as you go.
|
||||
const handles = UserDraft.useMany<V>(() =>
|
||||
untrack(() => [
|
||||
{
|
||||
itemKind,
|
||||
path,
|
||||
workspace: opts?.workspace,
|
||||
defaultValue: opts?.defaultValue
|
||||
}
|
||||
])
|
||||
)
|
||||
return handles[0]
|
||||
},
|
||||
|
||||
useMany<V = unknown>(getSpecs: () => UserDraftSpec<V>[]): UserDraftHandle<V>[] {
|
||||
// Reactive handles array, reconciled against the latest `getSpecs()`
|
||||
// output. Indices line up with the spec array. Handles for the same
|
||||
// (workspace, kind, path) tuple are reused across reconciles so
|
||||
// callers can capture a reference and keep it alive — only the
|
||||
// underlying entry's refcount moves.
|
||||
const handles = $state<UserDraftHandle<V>[]>([])
|
||||
const acquired = new Set<string>()
|
||||
const handleCache = new Map<string, UserDraftHandle<V>>()
|
||||
|
||||
function reconcile() {
|
||||
const specs = getSpecs()
|
||||
const seen = new Set<string>()
|
||||
const next: UserDraftHandle<V>[] = []
|
||||
|
||||
for (const spec of specs) {
|
||||
const ws = spec.workspace ?? resolveWorkspace()
|
||||
const mk = mapKey(ws, spec.itemKind, spec.path)
|
||||
seen.add(mk)
|
||||
|
||||
if (!acquired.has(mk)) {
|
||||
acquireEntry(ws, spec.itemKind, spec.path, spec.defaultValue)
|
||||
acquired.add(mk)
|
||||
}
|
||||
let handle = handleCache.get(mk)
|
||||
if (!handle) {
|
||||
handle = makeHandle<V>(ws, spec.itemKind, spec.path)
|
||||
handleCache.set(mk, handle)
|
||||
}
|
||||
next.push(handle)
|
||||
}
|
||||
|
||||
for (const mk of [...acquired]) {
|
||||
if (!seen.has(mk)) {
|
||||
releaseEntry(mk)
|
||||
acquired.delete(mk)
|
||||
handleCache.delete(mk)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip no-op mutations (handles are cached by mapKey, so an
|
||||
// unchanged spec set yields reference-equal arrays). `untrack` so
|
||||
// this effect doesn't subscribe to its own `handles` write —
|
||||
// otherwise it self-loops (`effect_update_depth_exceeded`).
|
||||
// Downstream notification still propagates.
|
||||
untrack(() => {
|
||||
const unchanged = handles.length === next.length && handles.every((h, i) => h === next[i])
|
||||
if (!unchanged) handles.splice(0, handles.length, ...next)
|
||||
})
|
||||
}
|
||||
|
||||
// Synchronous initial reconcile so single-spec callers (`use()`) get a
|
||||
// populated `handles[0]` before the function returns. Reactive reads
|
||||
// inside `getSpecs()` here are intentionally not tracked — the
|
||||
// `$effect` below picks up any subsequent dependency changes.
|
||||
untrack(reconcile)
|
||||
$effect(reconcile)
|
||||
onDestroy(() => {
|
||||
for (const mk of acquired) releaseEntry(mk)
|
||||
acquired.clear()
|
||||
handleCache.clear()
|
||||
})
|
||||
|
||||
return handles
|
||||
}
|
||||
}
|
||||
|
||||
function acquireEntry(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
defaultValue: unknown
|
||||
): void {
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
const existing = entries.get(mk)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
return
|
||||
}
|
||||
// `useLocalStorageValue`'s internal persist `$effect` would otherwise
|
||||
// parent to `useMany`'s reconcile effect and be torn down on the next
|
||||
// reconcile. `$effect.root` gives the entry its own scope, disposed only
|
||||
// by `releaseEntry`.
|
||||
const useLocalStorageOptions = {
|
||||
// First value is the baseline (don't persist it); coalesce edits.
|
||||
saveInitialValue: false,
|
||||
debounce: 500,
|
||||
// Stamp `lastWrittenAt` at persist time so deep mutations also bump
|
||||
// the GC clock (the setter doesn't re-run for those).
|
||||
transformBeforePersist: stamp<unknown>
|
||||
} as const
|
||||
let stateRef: DraftState<unknown> | undefined
|
||||
const destroyRoot = $effect.root(() => {
|
||||
stateRef = useLocalStorageValue<StoredDraft<unknown> | undefined>(
|
||||
localStorageKey(workspace, itemKind, path),
|
||||
wrap(defaultValue),
|
||||
undefined,
|
||||
useLocalStorageOptions
|
||||
)
|
||||
})
|
||||
if (stateRef) {
|
||||
entries.set(mk, {
|
||||
count: 1,
|
||||
state: stateRef,
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
destroyRoot
|
||||
})
|
||||
return
|
||||
}
|
||||
// Fallback for the vitest runtime where `$effect.root`'s callback isn't
|
||||
// invoked. Unreachable in production (Svelte runs it synchronously).
|
||||
const state = useLocalStorageValue<StoredDraft<unknown> | undefined>(
|
||||
localStorageKey(workspace, itemKind, path),
|
||||
wrap(defaultValue),
|
||||
undefined,
|
||||
useLocalStorageOptions
|
||||
)
|
||||
entries.set(mk, { count: 1, state, workspace, itemKind, path })
|
||||
}
|
||||
|
||||
function releaseEntry(mk: string): void {
|
||||
const entry = entries.get(mk)
|
||||
if (!entry) return
|
||||
entry.count--
|
||||
if (entry.count <= 0) {
|
||||
entry.destroyRoot?.()
|
||||
entries.delete(mk)
|
||||
}
|
||||
}
|
||||
|
||||
function makeHandle<V>(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string
|
||||
): UserDraftHandle<V> {
|
||||
// The handle reads `entries.get(mk)` on every access. The entry it points
|
||||
// at is stable as long as the refcount stays > 0 (which `useMany` keeps
|
||||
// the case for as long as a spec references it). If the refcount drops to
|
||||
// 0 and the entry is destroyed, reads return `undefined` rather than
|
||||
// throwing — the consumer should already have been torn down by that point.
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
const stateOf = (): DraftState<unknown> | undefined => entries.get(mk)?.state
|
||||
return {
|
||||
get draft(): V | undefined {
|
||||
return unwrap(stateOf()?.val as StoredDraft<V> | undefined)
|
||||
},
|
||||
set draft(value: V | undefined) {
|
||||
// Preserve existing rev metadata on a value edit. `untrack` the
|
||||
// read: callers often set this from inside a `$effect` mirroring
|
||||
// `$state` into the handle; a tracked read would subscribe that
|
||||
// effect to the cell it's about to write (self-loop →
|
||||
// effect_update_depth_exceeded).
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
state.val = wrap(value, extractMeta(current))
|
||||
},
|
||||
get meta(): UserDraftMeta {
|
||||
return extractMeta(stateOf()?.val as StoredDraft<unknown> | undefined)
|
||||
},
|
||||
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void {
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
state.val = wrap(value, meta)
|
||||
},
|
||||
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void {
|
||||
// Read under `untrack` for the same reason as `set draft` above —
|
||||
// avoid making any surrounding effect re-fire on the write below.
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
if (current === undefined) return
|
||||
state.val = wrap(current.value, { ...extractMeta(current), ...meta })
|
||||
if (opts?.force) {
|
||||
persistDirect(localStorageKey(workspace, itemKind, path), current.value, {
|
||||
...extractMeta(current),
|
||||
...meta
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default GC retention window: 30 days. Entries that haven't been touched
|
||||
* (no setter call, no deep-mutation persist) for this long are swept on
|
||||
* the next `gcUserDrafts` invocation.
|
||||
*/
|
||||
export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Sweep stale UserDraft entries from localStorage. Walks every
|
||||
* `userdraft/w/...` key, checks its `lastWrittenAt` stamp, and removes
|
||||
* any entry older than `maxAgeMs`.
|
||||
*
|
||||
* Entries written before `lastWrittenAt` was introduced lack the field;
|
||||
* we backfill them to `now()` on first sighting so they participate in
|
||||
* the next sweep cycle rather than getting wiped immediately.
|
||||
*
|
||||
* Safe to call on every load and on a timer (e.g. every 30 min) — live
|
||||
* entries get their stamp refreshed on every persist, so the sweep only
|
||||
* touches truly stale records.
|
||||
*/
|
||||
export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
const now = Date.now()
|
||||
const cutoff = now - maxAgeMs
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k != null && k.startsWith('userdraft/w/')) keys.push(k)
|
||||
}
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) continue
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed == null || typeof parsed !== 'object') continue
|
||||
if (typeof parsed.lastWrittenAt !== 'number') {
|
||||
// Pre-GC-feature entry. Backfill so the next sweep can decide.
|
||||
parsed.lastWrittenAt = now
|
||||
localStorage.setItem(key, JSON.stringify(parsed))
|
||||
continue
|
||||
}
|
||||
if (parsed.lastWrittenAt < cutoff) localStorage.removeItem(key)
|
||||
} catch (e) {
|
||||
console.error('UserDraft GC: failed to inspect', key, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear all in-memory entries. */
|
||||
export function __resetUserDraftForTesting(): void {
|
||||
entries.clear()
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// Capture onDestroy callbacks so we can simulate component teardown without
|
||||
// a real component context.
|
||||
const onDestroyCallbacks: Array<() => void> = []
|
||||
|
||||
vi.mock('svelte', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
onDestroy: (fn: () => void) => {
|
||||
onDestroyCallbacks.push(fn)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Imported AFTER vi.mock so the module sees the mocked onDestroy.
|
||||
const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } =
|
||||
await import('./userDraft.svelte')
|
||||
const { workspaceStore } = await import('./stores')
|
||||
|
||||
function flushDestroyCallbacks(): void {
|
||||
const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length)
|
||||
for (const cb of callbacks) cb()
|
||||
}
|
||||
|
||||
// UserDraft.use debounces localStorage writes by 500 ms via
|
||||
// useLocalStorageValue. Tests assert localStorage state synchronously after
|
||||
// writes, so we use fake timers and call this helper to fast-forward past
|
||||
// the debounce window before each assertion.
|
||||
function flushPersist(): void {
|
||||
vi.runAllTimers()
|
||||
}
|
||||
|
||||
// Helper: localStorage payloads are always wrapped as { value: <draft> } so
|
||||
// future metadata fields can be added without breaking existing entries.
|
||||
function wrapped<V>(value: V, meta: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({ value, ...meta })
|
||||
}
|
||||
|
||||
// Helper: read a localStorage entry, strip the GC `lastWrittenAt` stamp so
|
||||
// assertions can stay focused on value + rev metadata. Real entries always
|
||||
// carry `lastWrittenAt` once written; the GC tests below assert on it
|
||||
// directly via `localStorage.getItem`.
|
||||
function storedShape(key: string): string | null {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
delete parsed.lastWrittenAt
|
||||
return JSON.stringify(parsed)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetUserDraftForTesting()
|
||||
onDestroyCallbacks.length = 0
|
||||
localStorage.clear()
|
||||
workspaceStore.set('test_ws')
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
describe('UserDraft.save / get / remove (no observers)', () => {
|
||||
it('save writes a wrapped { value } payload under the workspace-scoped key', () => {
|
||||
UserDraft.save('flow', 'u/me/myflow', { hello: 'world' })
|
||||
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/myflow')).toBe(wrapped({ hello: 'world' }))
|
||||
})
|
||||
|
||||
it('get reads from a wrapped localStorage payload when no observer is registered', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/script/u/me/script1', wrapped('code'))
|
||||
|
||||
expect(UserDraft.get('script', 'u/me/script1')).toBe('code')
|
||||
})
|
||||
|
||||
it('get returns undefined when nothing is stored', () => {
|
||||
expect(UserDraft.get('flow', 'u/me/missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('get returns undefined when the stored payload is malformed', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/bad', 'not-json')
|
||||
expect(UserDraft.get('flow', 'u/me/bad')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('get returns undefined when the stored payload is unwrapped (pre-migration entry)', () => {
|
||||
// Drafts written before the wrapping was introduced look like the raw
|
||||
// value rather than { value: ... }. They must be ignored rather than
|
||||
// surface as undefined-shaped drafts.
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/raw', JSON.stringify({ hello: 'world' }))
|
||||
expect(UserDraft.get('flow', 'u/me/raw')).toBeUndefined()
|
||||
expect(UserDraft.has('flow', 'u/me/raw')).toBe(false)
|
||||
})
|
||||
|
||||
it('remove clears the localStorage entry', () => {
|
||||
UserDraft.save('app', 'u/me/app1', { grid: [] })
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).not.toBeNull()
|
||||
|
||||
UserDraft.remove('app', 'u/me/app1')
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the workspace from opts when provided', () => {
|
||||
UserDraft.save('flow', 'u/me/f', 1, { workspace: 'other_ws' })
|
||||
|
||||
expect(storedShape('userdraft/w/other_ws/flow/u/me/f')).toBe(wrapped(1))
|
||||
// Default workspace key must remain empty.
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/f')).toBeNull()
|
||||
})
|
||||
|
||||
it('supports trigger kinds as item kinds', () => {
|
||||
UserDraft.save('trigger_kafka', 'u/me/topic1', { brokers: ['localhost:9092'] })
|
||||
|
||||
expect(storedShape('userdraft/w/test_ws/trigger_kafka/u/me/topic1')).toBe(
|
||||
wrapped({ brokers: ['localhost:9092'] })
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when neither opts.workspace nor $workspaceStore is set', () => {
|
||||
workspaceStore.set(undefined)
|
||||
expect(() => UserDraft.save('flow', 'u/me/x', 1)).toThrow(/no workspace/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.use() — observer sync', () => {
|
||||
it('loads the existing localStorage value on first use', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/loaded', wrapped('preloaded'))
|
||||
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/loaded')
|
||||
expect(handle.draft).toBe('preloaded')
|
||||
})
|
||||
|
||||
it('two handles on the same key share the same underlying state', () => {
|
||||
const a = UserDraft.use<number>('flow', 'u/me/shared')
|
||||
const b = UserDraft.use<number>('flow', 'u/me/shared')
|
||||
|
||||
a.draft = 42
|
||||
expect(b.draft).toBe(42)
|
||||
|
||||
b.draft = 99
|
||||
expect(a.draft).toBe(99)
|
||||
})
|
||||
|
||||
it('save() propagates to live use() handles (in-memory)', () => {
|
||||
const handle = UserDraft.use<number>('flow', 'u/me/observed')
|
||||
expect(handle.draft).toBeUndefined()
|
||||
|
||||
// First write through a live entry is treated as the "initial value"
|
||||
// (saveInitialValue=false) and is NOT persisted — observers still see it.
|
||||
UserDraft.save('flow', 'u/me/observed', 7)
|
||||
expect(handle.draft).toBe(7)
|
||||
flushPersist()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBeNull()
|
||||
|
||||
// Subsequent writes persist.
|
||||
UserDraft.save('flow', 'u/me/observed', 9)
|
||||
expect(handle.draft).toBe(9)
|
||||
flushPersist()
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9))
|
||||
})
|
||||
|
||||
it('saveExternal() persists through live use() handles and marks the draft source', () => {
|
||||
const handle = UserDraft.use<number>('flow', 'u/me/external')
|
||||
expect(handle.draft).toBeUndefined()
|
||||
|
||||
UserDraft.saveExternal('flow', 'u/me/external', 7)
|
||||
expect(handle.draft).toBe(7)
|
||||
flushPersist()
|
||||
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/external')).toBe(
|
||||
wrapped(7, { draftSource: 'external' })
|
||||
)
|
||||
})
|
||||
|
||||
it('remove() clears localStorage without touching the in-memory handle', () => {
|
||||
// Seed localStorage so the live handle initialises from it.
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/removed', wrapped(1))
|
||||
const handle = UserDraft.use<number>('flow', 'u/me/removed')
|
||||
expect(handle.draft).toBe(1)
|
||||
|
||||
UserDraft.remove('flow', 'u/me/removed')
|
||||
// Live handle keeps its current value — remove() only wipes the
|
||||
// persisted side. This is what lets callers run UserDraft.remove
|
||||
// during navigation without flickering the editor UI.
|
||||
expect(handle.draft).toBe(1)
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/removed')).toBeNull()
|
||||
})
|
||||
|
||||
it('discard() clears LS, resets the handle to the fallback, and does NOT re-persist', () => {
|
||||
// Seed: handle holds a divergent local autosave.
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/discard', wrapped('local-edit'))
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/discard')
|
||||
expect(handle.draft).toBe('local-edit')
|
||||
|
||||
// Reset to a known backend baseline.
|
||||
UserDraft.discard('flow', 'u/me/discard', 'backend-baseline')
|
||||
flushPersist()
|
||||
|
||||
// In-memory handle reflects the fallback immediately.
|
||||
expect(handle.draft).toBe('backend-baseline')
|
||||
// LS is cleared and stays cleared — the fallback must NOT round-trip
|
||||
// back into storage (that would make the next reload "restore" the
|
||||
// fallback as if it were a real autosave).
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/discard')).toBeNull()
|
||||
})
|
||||
|
||||
it('discard() with undefined fallback clears both LS and in-memory state', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/wipe', wrapped('local-edit'))
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/wipe')
|
||||
expect(handle.draft).toBe('local-edit')
|
||||
|
||||
UserDraft.discard('flow', 'u/me/wipe', undefined)
|
||||
flushPersist()
|
||||
|
||||
expect(handle.draft).toBeUndefined()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/wipe')).toBeNull()
|
||||
})
|
||||
|
||||
it('clear() clears both localStorage and the live handle', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/clear-live', wrapped('local-edit'))
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/clear-live')
|
||||
expect(handle.draft).toBe('local-edit')
|
||||
|
||||
UserDraft.clear('flow', 'u/me/clear-live')
|
||||
flushPersist()
|
||||
|
||||
expect(handle.draft).toBeUndefined()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear-live')).toBeNull()
|
||||
})
|
||||
|
||||
it('the second write through the handle setter persists to localStorage', () => {
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/setter')
|
||||
|
||||
// First write is the baseline — not persisted.
|
||||
handle.draft = 'initial'
|
||||
flushPersist()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBeNull()
|
||||
|
||||
// Second (and onwards) persists.
|
||||
handle.draft = 'persisted'
|
||||
flushPersist()
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted'))
|
||||
})
|
||||
|
||||
it('setting handle.draft = undefined after edits removes the localStorage entry', () => {
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/clear')
|
||||
handle.draft = 'initial' // baseline, not persisted
|
||||
handle.draft = 'edited' // persisted
|
||||
flushPersist()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).not.toBeNull()
|
||||
|
||||
handle.draft = undefined
|
||||
flushPersist()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).toBeNull()
|
||||
expect(handle.draft).toBeUndefined()
|
||||
})
|
||||
|
||||
it('two handles in different workspaces are isolated', () => {
|
||||
const a = UserDraft.use<number>('flow', 'u/me/iso', { workspace: 'ws_a' })
|
||||
const b = UserDraft.use<number>('flow', 'u/me/iso', { workspace: 'ws_b' })
|
||||
|
||||
a.draft = 1
|
||||
b.draft = 2
|
||||
|
||||
expect(a.draft).toBe(1)
|
||||
expect(b.draft).toBe(2)
|
||||
})
|
||||
|
||||
it('save() falls back to localStorage when no handle is registered', () => {
|
||||
UserDraft.save('flow', 'u/me/noobs', 'fallback')
|
||||
// First use() afterwards loads the persisted value.
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/noobs')
|
||||
expect(handle.draft).toBe('fallback')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.use() — defaultValue', () => {
|
||||
it('returns defaultValue when localStorage has no entry', () => {
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/withdefault', { defaultValue: 'fallback' })
|
||||
|
||||
expect(handle.draft).toBe('fallback')
|
||||
})
|
||||
|
||||
it('does not persist the defaultValue on first read', () => {
|
||||
UserDraft.use<string>('flow', 'u/me/lazyDefault', { defaultValue: 'fallback' })
|
||||
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/lazyDefault')).toBeNull()
|
||||
})
|
||||
|
||||
it('localStorage value wins over defaultValue', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/overridden', wrapped('persisted'))
|
||||
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/overridden', {
|
||||
defaultValue: 'fallback'
|
||||
})
|
||||
|
||||
expect(handle.draft).toBe('persisted')
|
||||
})
|
||||
|
||||
it('second write through the setter persists even though defaultValue was set', () => {
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/writeDefault', {
|
||||
defaultValue: 'fallback'
|
||||
})
|
||||
|
||||
// First write is the initial-value baseline.
|
||||
handle.draft = 'initial'
|
||||
flushPersist()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBeNull()
|
||||
|
||||
handle.draft = 'modified'
|
||||
flushPersist()
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(wrapped('modified'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.list()', () => {
|
||||
it('lists persisted drafts for the selected workspace and item kinds', () => {
|
||||
UserDraft.save('script', 'u/me/script1', { content: 'code' })
|
||||
UserDraft.save('flow', 'u/me/flow1', { modules: [] })
|
||||
UserDraft.save('resource', 'u/me/resource1', { path: 'u/me/resource1' })
|
||||
UserDraft.save('script', 'u/other/script2', { content: 'other' }, { workspace: 'other_ws' })
|
||||
localStorage.setItem('userdraft/w/test_ws/script/u/me/bad', 'not-json')
|
||||
|
||||
expect(UserDraft.list({ itemKinds: ['script', 'flow'] })).toEqual([
|
||||
{
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'script',
|
||||
path: 'u/me/script1',
|
||||
storagePath: 'u/me/script1',
|
||||
source: 'persisted',
|
||||
draftSource: undefined,
|
||||
value: { content: 'code' }
|
||||
},
|
||||
{
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'flow',
|
||||
path: 'u/me/flow1',
|
||||
storagePath: 'u/me/flow1',
|
||||
source: 'persisted',
|
||||
draftSource: undefined,
|
||||
value: { modules: [] }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('exposes draftSource for externally saved drafts', () => {
|
||||
UserDraft.saveExternal('script', 'u/me/script1', { content: 'code' })
|
||||
|
||||
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
||||
{
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'script',
|
||||
path: 'u/me/script1',
|
||||
storagePath: 'u/me/script1',
|
||||
source: 'persisted',
|
||||
draftSource: 'external',
|
||||
value: { content: 'code' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers live handle state over a stale localStorage entry', () => {
|
||||
const handle = UserDraft.use<{ source: string }>('script', 'u/me/live')
|
||||
handle.draft = { source: 'live' }
|
||||
localStorage.setItem('userdraft/w/test_ws/script/u/me/live', wrapped({ source: 'persisted' }))
|
||||
|
||||
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
||||
{
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'script',
|
||||
path: 'u/me/live',
|
||||
storagePath: 'u/me/live',
|
||||
source: 'both',
|
||||
draftSource: undefined,
|
||||
value: { source: 'live' }
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft — empty path (new-item drafts persist across reloads)', () => {
|
||||
it('use() with empty path persists subsequent edits to localStorage', () => {
|
||||
const handle = UserDraft.use<number>('flow', '', { defaultValue: 0 })
|
||||
|
||||
// First write under saveInitialValue=false counts as the baseline and
|
||||
// is skipped — only the user's subsequent edits persist.
|
||||
handle.draft = 99
|
||||
flushPersist()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
|
||||
handle.draft = 100
|
||||
flushPersist()
|
||||
// The "+ Flow / + Script / …" buttons are expected to call
|
||||
// `UserDraft.remove(kind, '')` to wipe before navigating; an
|
||||
// unguarded /add reload therefore restores the previous session.
|
||||
expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(100))
|
||||
})
|
||||
|
||||
it('two handles with empty path share state per workspace', () => {
|
||||
const a = UserDraft.use<number>('flow', '')
|
||||
const b = UserDraft.use<number>('flow', '')
|
||||
|
||||
a.draft = 1
|
||||
expect(b.draft).toBe(1)
|
||||
|
||||
b.draft = 2
|
||||
expect(a.draft).toBe(2)
|
||||
})
|
||||
|
||||
it('save() with empty path writes to localStorage when no handle is live', () => {
|
||||
UserDraft.save('flow', '', 5)
|
||||
expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(5))
|
||||
})
|
||||
|
||||
it('get() with empty path falls back to localStorage when no handle is live', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(11))
|
||||
expect(UserDraft.get('flow', '')).toBe(11)
|
||||
})
|
||||
|
||||
it('remove() with empty path clears localStorage', () => {
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(1))
|
||||
UserDraft.remove('flow', '')
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft — rev metadata for staleness checks', () => {
|
||||
it('setDraftAndMeta atomically stores value + rev, and the first write is still skipped', () => {
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/atomic')
|
||||
|
||||
// Single atomic write — under saveInitialValue=false this counts as the
|
||||
// initial baseline and shouldn't hit localStorage yet.
|
||||
handle.setDraftAndMeta('backendValue', {
|
||||
remoteRev: 42,
|
||||
remoteDraftRev: '2026-01-01T00:00:00Z'
|
||||
})
|
||||
expect(handle.draft).toBe('backendValue')
|
||||
expect(handle.meta).toEqual({ remoteRev: 42, remoteDraftRev: '2026-01-01T00:00:00Z' })
|
||||
flushPersist()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic')).toBeNull()
|
||||
|
||||
// A subsequent user edit persists *with* the rev metadata.
|
||||
handle.draft = 'userEdit'
|
||||
flushPersist()
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/atomic')).toBe(
|
||||
JSON.stringify({
|
||||
value: 'userEdit',
|
||||
remoteRev: 42,
|
||||
remoteDraftRev: '2026-01-01T00:00:00Z'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('setMeta updates only the rev fields, preserving the value', () => {
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/setmeta')
|
||||
handle.setDraftAndMeta('initial', { remoteRev: 1 }) // baseline, not persisted
|
||||
handle.draft = 'edited' // persisted with remoteRev: 1
|
||||
|
||||
handle.setMeta({ remoteRev: 2 })
|
||||
expect(handle.draft).toBe('edited')
|
||||
expect(handle.meta).toEqual({ remoteRev: 2 })
|
||||
flushPersist()
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/setmeta')).toBe(
|
||||
JSON.stringify({ value: 'edited', remoteRev: 2 })
|
||||
)
|
||||
})
|
||||
|
||||
it('handle.draft setter preserves rev metadata across user edits', () => {
|
||||
const handle = UserDraft.use<{ count: number }>('flow', 'u/me/preserve')
|
||||
handle.setDraftAndMeta({ count: 0 }, { remoteRev: 'v1' })
|
||||
handle.draft = { count: 1 } // first edit, persisted
|
||||
handle.draft = { count: 2 } // another edit
|
||||
|
||||
expect(handle.meta).toEqual({ remoteRev: 'v1' })
|
||||
flushPersist()
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/preserve')).toBe(
|
||||
JSON.stringify({ value: { count: 2 }, remoteRev: 'v1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('UserDraft.getMeta reads from localStorage when no live handle exists', () => {
|
||||
localStorage.setItem(
|
||||
'userdraft/w/test_ws/flow/u/me/getmeta',
|
||||
JSON.stringify({ value: 'x', remoteRev: 7, remoteDraftRev: '2026-01-02' })
|
||||
)
|
||||
expect(UserDraft.getMeta('flow', 'u/me/getmeta')).toEqual({
|
||||
remoteRev: 7,
|
||||
remoteDraftRev: '2026-01-02'
|
||||
})
|
||||
})
|
||||
|
||||
it('UserDraft.getMeta returns empty object when there is no entry', () => {
|
||||
expect(UserDraft.getMeta('flow', 'u/me/none')).toEqual({})
|
||||
})
|
||||
|
||||
it('UserDraft.save preserves persisted rev metadata when no live handle exists', () => {
|
||||
localStorage.setItem(
|
||||
'userdraft/w/test_ws/flow/u/me/savepreserve',
|
||||
JSON.stringify({ value: 'old', remoteRev: 5 })
|
||||
)
|
||||
UserDraft.save('flow', 'u/me/savepreserve', 'new')
|
||||
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe(
|
||||
JSON.stringify({ value: 'new', remoteRev: 5 })
|
||||
)
|
||||
})
|
||||
|
||||
it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => {
|
||||
localStorage.setItem(
|
||||
'userdraft/w/test_ws/flow/u/me/legacy',
|
||||
JSON.stringify({ value: 'no-rev' })
|
||||
)
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/legacy')
|
||||
expect(handle.draft).toBe('no-rev')
|
||||
expect(handle.meta).toEqual({})
|
||||
})
|
||||
|
||||
it('setMeta({ force: true }) persists immediately, bypassing the first-write skip', () => {
|
||||
localStorage.setItem(
|
||||
'userdraft/w/test_ws/flow/u/me/forceack',
|
||||
JSON.stringify({ value: 'edited', remoteRev: 'v1' })
|
||||
)
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/forceack')
|
||||
|
||||
// Without force, this is the entry's first state mutation and gets
|
||||
// swallowed by saveInitialValue=false — localStorage would still
|
||||
// hold the old remoteRev.
|
||||
handle.setMeta({ remoteRev: 'v2' }, { force: true })
|
||||
|
||||
expect(handle.meta).toEqual({ remoteRev: 'v2' })
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/forceack')).toBe(
|
||||
JSON.stringify({ value: 'edited', remoteRev: 'v2' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkStaleness', () => {
|
||||
let checkStaleness: (
|
||||
meta: { remoteRev?: string | number; remoteDraftRev?: string | number },
|
||||
currentRev: string | number | undefined,
|
||||
currentDraftRev?: string | number | undefined
|
||||
) => 'draft' | 'version' | null
|
||||
|
||||
beforeEach(async () => {
|
||||
// Re-import to dodge ESM caching surprises across test files.
|
||||
;({ checkStaleness } = await import('./userDraft.svelte'))
|
||||
})
|
||||
|
||||
it('returns null for legacy entries with no recorded rev', () => {
|
||||
expect(checkStaleness({}, 'h1', '2026-01-01')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when meta matches current revs exactly', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd1')).toBeNull()
|
||||
expect(checkStaleness({ remoteRev: 'h1' }, 'h1', undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns "draft" when a newer DB draft was pushed on the remote', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd2')).toBe('draft')
|
||||
})
|
||||
|
||||
it('returns "draft" when the remote gained a DB draft that we didn\'t baseline against', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1' }, 'h1', 'd1')).toBe('draft')
|
||||
})
|
||||
|
||||
it('returns "version" when the deployed rev moved and draft revs match', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1' }, 'h2', undefined)).toBe('version')
|
||||
})
|
||||
|
||||
it('returns "version" when the baseline draft was deleted on the remote (no current draft)', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', undefined)).toBe(
|
||||
'version'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers "draft" over "version" when both have changed', () => {
|
||||
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h2', 'd2')).toBe('draft')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.use() — reference counting & cleanup', () => {
|
||||
it('destroys the entry when the last handle is released', () => {
|
||||
// First handle acquires the entry.
|
||||
const a = UserDraft.use<number>('flow', 'u/me/ref')
|
||||
a.draft = 1 // baseline write — not persisted
|
||||
|
||||
// Second handle increments the count.
|
||||
const b = UserDraft.use<number>('flow', 'u/me/ref')
|
||||
expect(b.draft).toBe(1)
|
||||
|
||||
// onDestroy for both handles got registered.
|
||||
expect(onDestroyCallbacks.length).toBe(2)
|
||||
|
||||
// Releasing one handle keeps the entry alive — save() still updates handle a.
|
||||
const firstCb = onDestroyCallbacks.shift()!
|
||||
firstCb()
|
||||
|
||||
UserDraft.save('flow', 'u/me/ref', 2)
|
||||
expect(a.draft).toBe(2)
|
||||
// Now persisted (second write after the baseline).
|
||||
flushPersist()
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2))
|
||||
|
||||
// Releasing the second handle drops the entry; subsequent save()
|
||||
// must go straight to localStorage rather than mutating in-memory
|
||||
// state (which no longer exists).
|
||||
const secondCb = onDestroyCallbacks.shift()!
|
||||
secondCb()
|
||||
|
||||
UserDraft.save('flow', 'u/me/ref', 3)
|
||||
// UserDraft.save without a live entry writes synchronously.
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(3))
|
||||
})
|
||||
|
||||
it('a fresh use() after cleanup re-reads the latest persisted value', () => {
|
||||
const a = UserDraft.use<string>('flow', 'u/me/cycle')
|
||||
a.draft = 'initial' // baseline — not persisted
|
||||
a.draft = 'edited' // persisted (after debounce)
|
||||
flushPersist()
|
||||
flushDestroyCallbacks()
|
||||
|
||||
// After all handles release, a brand-new use() must pick up the
|
||||
// value persisted to localStorage from the previous round.
|
||||
const b = UserDraft.use<string>('flow', 'u/me/cycle')
|
||||
expect(b.draft).toBe('edited')
|
||||
})
|
||||
|
||||
it('coalesces a typing storm into a single localStorage write per 500 ms window', () => {
|
||||
const handle = UserDraft.use<string>('flow', 'u/me/debounce')
|
||||
handle.draft = 'baseline' // first write — skipped under saveInitialValue=false
|
||||
|
||||
// Three quick edits inside the 500 ms window: in-memory updates every
|
||||
// time, but localStorage stays untouched until the timer fires.
|
||||
handle.draft = 'one'
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull()
|
||||
handle.draft = 'two'
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull()
|
||||
handle.draft = 'three'
|
||||
expect(handle.draft).toBe('three')
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull()
|
||||
|
||||
// After the window elapses, only the latest value lands.
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(storedShape('userdraft/w/test_ws/flow/u/me/debounce')).toBe(wrapped('three'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.useMany()', () => {
|
||||
it('acquires one handle per spec in the synchronous initial reconcile', () => {
|
||||
// `useMany`'s sync reconcile populates handles[0..] before returning,
|
||||
// so callers (and `use()`'s 1-len wrapper) can use them immediately
|
||||
// without waiting for an `$effect` tick.
|
||||
const handles = UserDraft.useMany<number>(() => [
|
||||
{ itemKind: 'flow', path: 'u/me/many', workspace: 'a' },
|
||||
{ itemKind: 'flow', path: 'u/me/many', workspace: 'b' }
|
||||
])
|
||||
expect(handles.length).toBe(2)
|
||||
|
||||
// Each spec gets its own entry in the workspace-keyed store.
|
||||
handles[0].draft = 0 // baseline
|
||||
handles[0].draft = 1 // persisted
|
||||
handles[1].draft = 0
|
||||
handles[1].draft = 9
|
||||
flushPersist()
|
||||
expect(storedShape('userdraft/w/a/flow/u/me/many')).toBe(wrapped(1))
|
||||
expect(storedShape('userdraft/w/b/flow/u/me/many')).toBe(wrapped(9))
|
||||
|
||||
// One component-level onDestroy releases every acquired entry.
|
||||
expect(onDestroyCallbacks.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gcUserDrafts', () => {
|
||||
let gcUserDrafts: (maxAgeMs?: number) => void
|
||||
let USER_DRAFT_GC_MAX_AGE_MS: number
|
||||
const DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
beforeEach(async () => {
|
||||
;({ gcUserDrafts, USER_DRAFT_GC_MAX_AGE_MS } = await import('./userDraft.svelte'))
|
||||
})
|
||||
|
||||
it('sweeps entries whose lastWrittenAt is older than the cutoff', () => {
|
||||
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
|
||||
const old = Date.now() - 31 * DAY
|
||||
const fresh = Date.now() - 1 * DAY
|
||||
localStorage.setItem(
|
||||
'userdraft/w/test_ws/flow/u/me/old',
|
||||
JSON.stringify({ value: 1, lastWrittenAt: old })
|
||||
)
|
||||
localStorage.setItem(
|
||||
'userdraft/w/test_ws/flow/u/me/fresh',
|
||||
JSON.stringify({ value: 2, lastWrittenAt: fresh })
|
||||
)
|
||||
// Unrelated keys are left alone.
|
||||
localStorage.setItem('some_other_key', 'unrelated')
|
||||
|
||||
gcUserDrafts()
|
||||
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/old')).toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/fresh')).not.toBeNull()
|
||||
expect(localStorage.getItem('some_other_key')).toBe('unrelated')
|
||||
})
|
||||
|
||||
it('backfills lastWrittenAt on entries lacking it, instead of sweeping them immediately', () => {
|
||||
// Pre-GC-feature entry (legacy migration output, or just an old entry
|
||||
// from earlier in this PR's lifecycle): no `lastWrittenAt`. First GC
|
||||
// pass should stamp it as "now" rather than wipe it on sight.
|
||||
localStorage.setItem('userdraft/w/test_ws/flow/u/me/legacy', JSON.stringify({ value: 'data' }))
|
||||
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
|
||||
|
||||
gcUserDrafts()
|
||||
|
||||
const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/legacy')
|
||||
expect(raw).not.toBeNull()
|
||||
const parsed = JSON.parse(raw!)
|
||||
expect(parsed.lastWrittenAt).toBe(Date.now())
|
||||
expect(parsed.value).toBe('data')
|
||||
})
|
||||
|
||||
it('exposes a 30-day default retention window', () => {
|
||||
expect(USER_DRAFT_GC_MAX_AGE_MS).toBe(30 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('respects a custom maxAgeMs', () => {
|
||||
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
|
||||
localStorage.setItem(
|
||||
'userdraft/w/test_ws/flow/u/me/two_hours_ago',
|
||||
JSON.stringify({ value: 1, lastWrittenAt: Date.now() - 2 * 60 * 60 * 1000 })
|
||||
)
|
||||
|
||||
gcUserDrafts(60 * 60 * 1000) // 1h cutoff
|
||||
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/two_hours_ago')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeForCompare', () => {
|
||||
it('returns undefined for undefined input', () => {
|
||||
expect(normalizeForCompare(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops keys whose value is undefined (mirrors JSON.stringify persistence)', () => {
|
||||
const out = normalizeForCompare({ a: 1, b: undefined, c: { d: undefined, e: 2 } })
|
||||
expect(out).toEqual({ a: 1, c: { e: 2 } })
|
||||
expect(Object.keys(out as object)).not.toContain('b')
|
||||
expect(Object.keys((out as any).c)).not.toContain('d')
|
||||
})
|
||||
|
||||
it('falls back to the original value when not serializable (cyclic)', () => {
|
||||
const cyclic: any = { a: 1 }
|
||||
cyclic.self = cyclic
|
||||
expect(normalizeForCompare(cyclic)).toBe(cyclic)
|
||||
})
|
||||
})
|
||||
|
||||
describe('localDraftDiffers', () => {
|
||||
it('returns false when there is no local draft', () => {
|
||||
expect(localDraftDiffers(undefined, { a: 1 })).toBe(false)
|
||||
expect(localDraftDiffers(null, { a: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a draft that round-trips equal to the config as NOT differing', () => {
|
||||
// The Schedule bug: getXCfg() emits conditionally-undefined keys, but
|
||||
// the persisted draft went through JSON.stringify which dropped them.
|
||||
const freshCfg = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
|
||||
const persisted = JSON.parse(JSON.stringify(freshCfg)) // { path, schedule }
|
||||
expect(localDraftDiffers(persisted, freshCfg)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a genuine difference', () => {
|
||||
expect(localDraftDiffers({ a: 1 }, { a: 2 })).toBe(true)
|
||||
expect(localDraftDiffers({ a: 1, extra: 'x' }, { a: 1 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.saveIfChanged', () => {
|
||||
const KEY = 'userdraft/w/test_ws/trigger_schedule/u/me/s'
|
||||
|
||||
it('does not persist a draft equal to the deployed baseline', () => {
|
||||
const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
|
||||
// value is the post-load reactive cfg — same shape, undefined keys present
|
||||
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed)
|
||||
expect(localStorage.getItem(KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a value that round-trips equal to deployed as unchanged', () => {
|
||||
const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
|
||||
const value = JSON.parse(JSON.stringify(deployed)) // { path, schedule }
|
||||
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed)
|
||||
expect(localStorage.getItem(KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('persists when the value differs from the deployed baseline', () => {
|
||||
const deployed = { path: 'u/me/s', schedule: '0 0 * * *' }
|
||||
const value = { path: 'u/me/s', schedule: '5 0 * * *' }
|
||||
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed)
|
||||
expect(storedShape(KEY)).toBe(wrapped(value))
|
||||
})
|
||||
|
||||
it('removes a pre-existing draft once the value reverts to deployed', () => {
|
||||
const deployed = { path: 'u/me/s', schedule: '0 0 * * *' }
|
||||
UserDraft.save('trigger_schedule', 'u/me/s', { path: 'u/me/s', schedule: '5 0 * * *' })
|
||||
expect(localStorage.getItem(KEY)).not.toBeNull()
|
||||
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed)
|
||||
expect(localStorage.getItem(KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('persists when there is no deployed baseline (undefined)', () => {
|
||||
const value = { path: 'u/me/s', schedule: '0 0 * * *' }
|
||||
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, undefined)
|
||||
expect(storedShape(KEY)).toBe(wrapped(value))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
migrateLegacyUserDrafts,
|
||||
__resetUserDraftLegacyMigrationForTesting
|
||||
} from './userDraftLegacyMigration'
|
||||
|
||||
function encodeLegacy(value: unknown): string {
|
||||
return btoa(encodeURIComponent(JSON.stringify(value)))
|
||||
}
|
||||
|
||||
function wrapped<V>(value: V): string {
|
||||
return JSON.stringify({ value })
|
||||
}
|
||||
|
||||
// Read a migrated entry, strip the GC `lastWrittenAt` stamp so assertions
|
||||
// can match the `{ value }` shape regardless of when the migration ran.
|
||||
function storedShape(key: string): string | null {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
delete parsed.lastWrittenAt
|
||||
return JSON.stringify(parsed)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
__resetUserDraftLegacyMigrationForTesting()
|
||||
})
|
||||
|
||||
describe('migrateLegacyUserDrafts', () => {
|
||||
it('migrates a legacy app draft to the workspace-scoped key with a { value } wrapper', () => {
|
||||
// Shape mirrors what the legacy AppEditor wrote: `encodeState($appStore)`,
|
||||
// i.e. the inner App value, not the wrapping AppWithLastVersion.
|
||||
const legacyApp = {
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
theme: undefined,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
}
|
||||
localStorage.setItem('app-u/me/dashboard', encodeLegacy(legacyApp))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dashboard')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp))
|
||||
})
|
||||
|
||||
it('migrates a legacy empty-path app draft (the `app` literal key)', () => {
|
||||
const legacyApp = {
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
}
|
||||
localStorage.setItem('app', encodeLegacy(legacyApp))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/app/')).toBe(wrapped(legacyApp))
|
||||
})
|
||||
|
||||
it('migrates a legacy flow draft and strips the view-state envelope', () => {
|
||||
const flow = { summary: 'f', value: { modules: [] }, path: 'u/me/myflow' }
|
||||
const legacyBundle = {
|
||||
flow,
|
||||
path: 'u/me/myflow',
|
||||
selectedId: 'settings',
|
||||
draft_triggers: [{ id: 't1' }],
|
||||
selected_trigger: null,
|
||||
loadedFromHistory: undefined
|
||||
}
|
||||
localStorage.setItem('flow-u/me/myflow', encodeLegacy(legacyBundle))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('flow-u/me/myflow')).toBeNull()
|
||||
// Only the inner Flow survives; the view-state envelope is dropped.
|
||||
expect(storedShape('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow))
|
||||
})
|
||||
|
||||
it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => {
|
||||
const legacy = {
|
||||
files: { 'index.tsx': 'export default () => null' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
}
|
||||
localStorage.setItem('rawapp-u/me/site', encodeLegacy(legacy))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('rawapp-u/me/site')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/raw_app/u/me/site')).toBe(
|
||||
wrapped({ ...legacy, summary: '' })
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves an existing new-format entry instead of overwriting it', () => {
|
||||
// Old and new both exist for the same item — the new one is presumed
|
||||
// fresher.
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
const existingNew = wrapped({ value: 'new' })
|
||||
localStorage.setItem('userdraft/w/main/app/u/me/dash', existingNew)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBe(existingNew)
|
||||
})
|
||||
|
||||
it('is idempotent — the second invocation is a no-op', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
migrateLegacyUserDrafts('main')
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).not.toBeNull()
|
||||
|
||||
// Drop the migrated entry to detect any re-migration attempt.
|
||||
localStorage.removeItem('userdraft/w/main/app/u/me/dash')
|
||||
// Drop the source too, so re-running couldn't even find a source.
|
||||
// (The sentinel alone should be enough; this just clarifies the intent.)
|
||||
migrateLegacyUserDrafts('main')
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
|
||||
})
|
||||
|
||||
it('skips entirely when no workspace is available', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
migrateLegacyUserDrafts('')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('handles malformed legacy payloads without throwing', () => {
|
||||
localStorage.setItem('app-u/me/garbled', 'not-base64!!!')
|
||||
expect(() => migrateLegacyUserDrafts('main')).not.toThrow()
|
||||
// Migration didn't migrate, didn't crash — leaves the entry alone.
|
||||
expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!')
|
||||
})
|
||||
|
||||
it('leaves keys whose path does not match the legacy `u|f/owner/name` shape alone', () => {
|
||||
// A future feature or neighbouring code might pick a key like
|
||||
// `app-recent` for its own purposes. The path doesn't look like a
|
||||
// Windmill item path, so the migration must skip it.
|
||||
localStorage.setItem('app-recent', 'whatever')
|
||||
localStorage.setItem('app-some_other_app', 'whatever')
|
||||
// `flow-u/me/foo` matches the shape and would be migrated, but the
|
||||
// payload also needs to look like a Windmill draft (asserted below).
|
||||
localStorage.setItem('flow-u/me/foo', encodeLegacy({ flow: { value: { modules: [] } } }))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-recent')).toBe('whatever')
|
||||
expect(localStorage.getItem('app-some_other_app')).toBe('whatever')
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/foo')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('skips legacy-shaped keys whose payload does not look like a Windmill draft', () => {
|
||||
// `app-u/me/dash` matches LEGACY_PATH_SHAPE and decodes to valid JSON,
|
||||
// but none of the App-shape fields (grid/fullscreen/theme/
|
||||
// unusedInlineScripts/hiddenInlineScripts) are present. Treat it as
|
||||
// unrelated and leave it untouched.
|
||||
const unrelated = encodeLegacy({ random: 'data', count: 7 })
|
||||
localStorage.setItem('app-u/me/dash', unrelated)
|
||||
const unrelatedFlow = encodeLegacy({ stepsState: {} })
|
||||
localStorage.setItem('flow-u/me/bar', unrelatedFlow)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).toBe(unrelated)
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
|
||||
expect(localStorage.getItem('flow-u/me/bar')).toBe(unrelatedFlow)
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('migrates multiple legacy entries in a single invocation', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/a',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
localStorage.setItem(
|
||||
'flow-u/me/b',
|
||||
encodeLegacy({ flow: { summary: '', value: { modules: [] }, path: 'u/me/b' } })
|
||||
)
|
||||
localStorage.setItem(
|
||||
'rawapp-u/me/c',
|
||||
encodeLegacy({ files: {}, runnables: {}, data: { tables: [] } })
|
||||
)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/a')).not.toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/b')).not.toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/c')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* One-off migration from the pre-UserDraft localStorage autosave entries to
|
||||
* the workspace-scoped `userdraft/w/{ws}/{kind}/{path}` format.
|
||||
*
|
||||
* Legacy keys (global, not workspace-scoped — assumed to belong to the user's
|
||||
* current workspace at migration time):
|
||||
*
|
||||
* `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })`
|
||||
* `app` / `app-{path}` base64 of `encodeState(App)`
|
||||
* `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })`
|
||||
*
|
||||
* Target keys: `userdraft/w/{workspace}/{flow|app|raw_app}/{path}` storing
|
||||
* `JSON.stringify({ value: <transformed legacy value> })`.
|
||||
*
|
||||
* Idempotent: writes a sentinel under `MIGRATION_FLAG` after the first run so
|
||||
* subsequent invocations are no-ops. Existing new-format entries are never
|
||||
* overwritten — when both an old and a new entry exist for the same item, the
|
||||
* old one is simply dropped on the assumption that the new entry is the more
|
||||
* recent edit.
|
||||
*
|
||||
* This file is intentionally standalone — it does not import from
|
||||
* `userDraft.svelte.ts` so the new code stays uncluttered by the legacy
|
||||
* decoders.
|
||||
*/
|
||||
|
||||
const MIGRATION_FLAG = 'userdraft/legacy_migrated_v1'
|
||||
|
||||
type LegacyKind = 'flow' | 'app' | 'raw_app'
|
||||
|
||||
const LEGACY_PREFIXES: ReadonlyArray<{ prefix: string; newKind: LegacyKind }> = [
|
||||
// `rawapp` is listed before `app` even though our matcher uses exact /
|
||||
// dash-separated comparison (so there's no ambiguity); it documents the
|
||||
// intent that raw apps are a distinct kind, not a sub-case of apps.
|
||||
{ prefix: 'rawapp', newKind: 'raw_app' },
|
||||
{ prefix: 'flow', newKind: 'flow' },
|
||||
{ prefix: 'app', newKind: 'app' }
|
||||
]
|
||||
|
||||
/**
|
||||
* A Windmill item path: `u/<owner>/<name…>` or `f/<folder>/<name…>`. The
|
||||
* `<name…>` segment may itself contain slashes, so we don't constrain it
|
||||
* past requiring at least one character. Used to reject incidentally-named
|
||||
* localStorage keys (e.g. `app-recent` from a future feature, or a
|
||||
* neighbouring app's data) before treating them as Windmill drafts.
|
||||
*/
|
||||
const LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/
|
||||
|
||||
function matchLegacyKey(
|
||||
key: string
|
||||
): { prefix: string; newKind: LegacyKind; path: string } | undefined {
|
||||
for (const { prefix, newKind } of LEGACY_PREFIXES) {
|
||||
if (key === prefix) return { prefix, newKind, path: '' }
|
||||
if (key.startsWith(prefix + '-')) {
|
||||
const path = key.slice(prefix.length + 1)
|
||||
if (!LEGACY_PATH_SHAPE.test(path)) return undefined
|
||||
return { prefix, newKind, path }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function decodeLegacyState(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(atob(raw)))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-kind shape gate. The legacy keys (`app-foo`, `flow-foo`, ...) are
|
||||
* unusual enough that nothing else in the codebase has used them, but
|
||||
* matching `LEGACY_PATH_SHAPE` doesn't prove the payload is actually a
|
||||
* Windmill draft (any base64-of-JSON could pass). Promoting a stray payload
|
||||
* would silently surface as a phantom "Restored from local storage" toast
|
||||
* on the next edit, so we reject anything that doesn't carry the fields the
|
||||
* legacy writers actually produced.
|
||||
*/
|
||||
function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean {
|
||||
if (decoded == null || typeof decoded !== 'object') return false
|
||||
const obj = decoded as Record<string, unknown>
|
||||
switch (kind) {
|
||||
case 'flow':
|
||||
// Legacy FlowBuilder wrote { flow, path, selectedId, draft_triggers, ... }.
|
||||
return obj.flow != null && typeof obj.flow === 'object'
|
||||
case 'app':
|
||||
// Legacy AppEditor wrote `encodeState($appStore)`, i.e. the inner App
|
||||
// value (see `frontend/src/lib/components/apps/types.ts`) — NOT the
|
||||
// wrapping AppWithLastVersion. It carries `grid`, `fullscreen`,
|
||||
// `theme`, `unusedInlineScripts`, `hiddenInlineScripts` among other
|
||||
// fields — any one of those is a strong signal it's actually a
|
||||
// Windmill app payload.
|
||||
return (
|
||||
'grid' in obj ||
|
||||
'fullscreen' in obj ||
|
||||
'theme' in obj ||
|
||||
'unusedInlineScripts' in obj ||
|
||||
'hiddenInlineScripts' in obj
|
||||
)
|
||||
case 'raw_app':
|
||||
// Legacy RawAppEditor wrote { files, runnables, data }.
|
||||
return 'files' in obj || 'runnables' in obj || 'data' in obj
|
||||
}
|
||||
}
|
||||
|
||||
function transformLegacyValue(kind: LegacyKind, decoded: unknown): unknown {
|
||||
const obj = decoded as Record<string, unknown>
|
||||
switch (kind) {
|
||||
case 'flow':
|
||||
// The legacy bundle wrapped the Flow alongside view-state fields
|
||||
// (selectedId, draft_triggers, ...). The new entry stores only the
|
||||
// Flow — the view-state lives elsewhere or is re-derived.
|
||||
return obj.flow
|
||||
case 'app':
|
||||
// Legacy stored the App directly.
|
||||
return obj
|
||||
case 'raw_app':
|
||||
// Legacy bundle missed the `summary` field that the new editor adds.
|
||||
return {
|
||||
files: obj.files ?? {},
|
||||
runnables: obj.runnables ?? {},
|
||||
data: obj.data ?? {},
|
||||
summary: typeof obj.summary === 'string' ? obj.summary : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function newKey(workspace: string, kind: LegacyKind, path: string): string {
|
||||
return `userdraft/w/${workspace}/${kind}/${path}`
|
||||
}
|
||||
|
||||
function listLocalStorageKeys(): string[] {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k != null) out.push(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the legacy → new-format migration. Idempotent: returns immediately if a
|
||||
* previous run completed (signalled by `MIGRATION_FLAG`).
|
||||
*
|
||||
* The migration is workspace-scoped because the legacy keys had no notion of
|
||||
* workspace — we treat the caller's current workspace as the owner of any
|
||||
* surviving legacy entries.
|
||||
*/
|
||||
export function migrateLegacyUserDrafts(workspace: string): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
if (!workspace) return
|
||||
if (localStorage.getItem(MIGRATION_FLAG) !== null) return
|
||||
|
||||
try {
|
||||
for (const key of listLocalStorageKeys()) {
|
||||
const match = matchLegacyKey(key)
|
||||
if (!match) continue
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) continue
|
||||
|
||||
try {
|
||||
const decoded = decodeLegacyState(raw)
|
||||
if (!isPlausibleLegacyValue(match.newKind, decoded)) continue
|
||||
const value = transformLegacyValue(match.newKind, decoded)
|
||||
const target = newKey(workspace, match.newKind, match.path)
|
||||
if (value !== undefined && localStorage.getItem(target) == null) {
|
||||
// `lastWrittenAt` makes the migrated entry visible to
|
||||
// `gcUserDrafts`. We stamp it as "now" so a freshly-migrated
|
||||
// autosave gets the full retention window — sweeping it
|
||||
// immediately on the first GC pass would lose work the
|
||||
// legacy migration just rescued.
|
||||
localStorage.setItem(target, JSON.stringify({ value, lastWrittenAt: Date.now() }))
|
||||
}
|
||||
localStorage.removeItem(key)
|
||||
} catch (e) {
|
||||
console.error('UserDraft legacy migration: failed to migrate', key, e)
|
||||
}
|
||||
}
|
||||
localStorage.setItem(MIGRATION_FLAG, new Date().toISOString())
|
||||
} catch (e) {
|
||||
console.error('UserDraft legacy migration: aborted', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the sentinel so the migration can re-run. */
|
||||
export function __resetUserDraftLegacyMigrationForTesting(): void {
|
||||
try {
|
||||
localStorage.removeItem(MIGRATION_FLAG)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* "Restored from local storage" toast, shown when an editor reopens on a
|
||||
* local autosave that differs from the backend. Owns only the wording and
|
||||
* which reset actions are offered; the reset side-effects live at each call
|
||||
* site (route-specific state).
|
||||
*/
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
export type RestoreFromLocalActions = {
|
||||
/** Drop the local autosave, apply the backend DB draft. Offered when `hasBackendDraft`. */
|
||||
onResetToSavedDraft?: () => void | Promise<void>
|
||||
/** Drop the local autosave, load the deployed version. Offered when `hasDeployed`. */
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Show the toast with up to two reset actions, gated by what the backend has. */
|
||||
export function notifyRestoredFromLocal(
|
||||
hasBackendDraft: boolean,
|
||||
hasDeployed: boolean,
|
||||
{ onResetToSavedDraft, onResetToDeployed }: RestoreFromLocalActions
|
||||
): void {
|
||||
const actions: Array<{ label: string; callback: () => void | Promise<void> }> = []
|
||||
if (hasBackendDraft && onResetToSavedDraft) {
|
||||
actions.push({ label: 'Reset to saved draft', callback: onResetToSavedDraft })
|
||||
}
|
||||
if (hasDeployed && onResetToDeployed) {
|
||||
actions.push({ label: 'Reset to deployed', callback: onResetToDeployed })
|
||||
}
|
||||
if (actions.length === 0) return
|
||||
sendUserToast('Restored from local storage', false, actions)
|
||||
}
|
||||
@@ -58,6 +58,8 @@
|
||||
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
|
||||
import { gcUserDrafts } from '$lib/userDraft.svelte'
|
||||
import { setContext, untrack } from 'svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { Menubar } from '$lib/components/meltComponents'
|
||||
@@ -364,8 +366,8 @@
|
||||
async function loadCriticalAlertsMuted() {
|
||||
let g_muted = true
|
||||
const ws_muted =
|
||||
(await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })).mute_critical_alerts ||
|
||||
false
|
||||
(await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! }))
|
||||
.mute_critical_alerts || false
|
||||
|
||||
if ($superadmin) {
|
||||
g_muted = (await SettingService.getGlobal({
|
||||
@@ -418,6 +420,19 @@
|
||||
$effect(() => {
|
||||
$workspaceStore && untrack(() => onLoad())
|
||||
})
|
||||
$effect(() => {
|
||||
if ($workspaceStore) untrack(() => migrateLegacyUserDrafts($workspaceStore!))
|
||||
})
|
||||
// Sweep UserDraft entries that haven't been touched in 30 days. Runs
|
||||
// once on mount and on a 30-min timer so a single very long session
|
||||
// also clears out stale autosaves over time. Live entries stamp
|
||||
// `lastWrittenAt` on every persist, so the sweep only touches truly
|
||||
// dormant records.
|
||||
$effect(() => {
|
||||
gcUserDrafts()
|
||||
const interval = setInterval(() => gcUserDrafts(), 30 * 60 * 1000)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
$effect(() => {
|
||||
innerWidth && untrack(() => changeCollapsed())
|
||||
})
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
import AppEditor from '$lib/components/apps/editor/AppEditor.svelte'
|
||||
import { AppService, type Policy } from '$lib/gen'
|
||||
import { page } from '$app/state'
|
||||
import { decodeState } from '$lib/utils'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
import { afterNavigate, replaceState } from '$app/navigation'
|
||||
import { replaceState } from '$app/navigation'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
|
||||
import { goto } from '$lib/navigation'
|
||||
@@ -15,8 +14,19 @@
|
||||
import { DEFAULT_THEME } from '$lib/components/apps/editor/componentsPanel/themeUtils'
|
||||
import { emptyApp } from '$lib/components/apps/editor/appUtils'
|
||||
import { tick } from 'svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
let nodraft = page.url.searchParams.get('nodraft')
|
||||
// "+ App" buttons navigate with ?nodraft=true to signal "start fresh".
|
||||
// Wipe the persisted empty-path autosave and strip the flag from the URL
|
||||
// synchronously so a reload doesn't wipe the freshly-started draft. A
|
||||
// plain reload of /apps/add (no nodraft) instead restores the previous
|
||||
// session via the child AppEditor's `UserDraft.use`.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('app', '')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
let appEditor: AppEditor | undefined = $state(undefined)
|
||||
const hubId = page.url.searchParams.get('hub')
|
||||
const templatePath = page.url.searchParams.get('template')
|
||||
@@ -27,8 +37,6 @@
|
||||
$importStore = undefined
|
||||
}
|
||||
|
||||
const appState = nodraft ? undefined : localStorage.getItem('app')
|
||||
|
||||
let summary = $state('')
|
||||
let value: App = $state({
|
||||
grid: [],
|
||||
@@ -40,13 +48,6 @@
|
||||
path: DEFAULT_THEME
|
||||
}
|
||||
})
|
||||
afterNavigate(() => {
|
||||
if (nodraft) {
|
||||
let url = new URL(page.url.href)
|
||||
url.search = ''
|
||||
replaceState(url.toString(), page.state)
|
||||
}
|
||||
})
|
||||
let policy: Policy = $state({
|
||||
on_behalf_of: $userStore?.username.includes('@')
|
||||
? $userStore?.username
|
||||
@@ -59,6 +60,10 @@
|
||||
|
||||
async function loadApp() {
|
||||
if (importRaw) {
|
||||
// Import/template/hub loads are an explicit "start fresh from this
|
||||
// content" — drop any previous empty-path autosave so it doesn't
|
||||
// shadow the imported value on AppEditor mount.
|
||||
UserDraft.remove('app', '')
|
||||
sendUserToast('Loaded from YAML/JSON')
|
||||
if ('value' in importRaw) {
|
||||
summary = importRaw.summary
|
||||
@@ -68,6 +73,7 @@
|
||||
value = importRaw
|
||||
}
|
||||
} else if (templatePath) {
|
||||
UserDraft.remove('app', '')
|
||||
const template = await AppService.getAppByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: templatePath
|
||||
@@ -76,6 +82,7 @@
|
||||
sendUserToast('App loaded from template')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (templateId) {
|
||||
UserDraft.remove('app', '')
|
||||
const template = await AppService.getAppByVersion({
|
||||
workspace: $workspaceStore!,
|
||||
id: parseInt(templateId)
|
||||
@@ -84,6 +91,7 @@
|
||||
sendUserToast('App loaded from template')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (hubId) {
|
||||
UserDraft.remove('app', '')
|
||||
const hub = await AppService.getHubAppById({ id: Number(hubId) })
|
||||
value = {
|
||||
hiddenInlineScripts: [],
|
||||
@@ -94,22 +102,6 @@
|
||||
summary = hub.app.summary
|
||||
sendUserToast('App loaded from Hub')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (!templatePath && !hubId && appState) {
|
||||
sendUserToast('App restored from browser stored autosave', false, [
|
||||
{
|
||||
label: 'Start from blank',
|
||||
callback: () => {
|
||||
value = {
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: [],
|
||||
theme: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
value = decodeState(appState)
|
||||
} else {
|
||||
value = emptyApp()
|
||||
}
|
||||
@@ -121,7 +113,7 @@
|
||||
await tick()
|
||||
let attempts = 0
|
||||
while (attempts < 20 && !document.querySelector('#app-editor-runnable-panel')) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
attempts++
|
||||
}
|
||||
appEditor?.triggerTutorial()
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
DraftService
|
||||
} from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { cleanValueProperties, decodeState, type Value } from '$lib/utils'
|
||||
import { afterNavigate, replaceState } from '$app/navigation'
|
||||
import { cleanValueProperties, orderedJsonStringify, type Value } from '$lib/utils'
|
||||
import { replaceState } from '$app/navigation'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast, type ToastAction } from '$lib/toast'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
|
||||
let app = $state(
|
||||
undefined as (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined
|
||||
@@ -33,19 +36,60 @@
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
let redraw = $state(0)
|
||||
let path = page.params.path ?? ''
|
||||
|
||||
let nodraft = page.url.searchParams.get('nodraft')
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: AppWithLastVersion & { draft_only?: boolean; value: any }; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
afterNavigate(() => {
|
||||
if (nodraft) {
|
||||
let url = new URL(page.url.href)
|
||||
url.search = ''
|
||||
replaceState(url.toString(), page.state)
|
||||
// Backend revs at the most recent `loadApp` — handed to AppEditor as
|
||||
// `initialRevs` so the very first local autosave persists with a meta
|
||||
// stamp. Without it the next reload's staleness check has nothing to
|
||||
// compare against and the first external deploy/draft slips through.
|
||||
let currentRevs = $state<UserDraftMeta | undefined>(undefined)
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
})
|
||||
const initialState = nodraft ? undefined : localStorage.getItem(`app-${page.params.path}`)
|
||||
let stateLoadedFromLocalStorage =
|
||||
initialState != undefined ? decodeState(initialState) : undefined
|
||||
// `discard` (not `remove`) so the entry's in-memory state.val is
|
||||
// cleared synchronously. `redraw++` remounts AppEditor on the next
|
||||
// microtask, but Svelte may mount the new instance before the old
|
||||
// one's onDestroy releases its handle — the new instance would
|
||||
// then re-acquire the SAME entry whose state.val still has the
|
||||
// stale autosave, ignoring the just-emptied LS. Same reason every
|
||||
// "reset" path below uses discard.
|
||||
UserDraft.discard('app', path, undefined)
|
||||
currentRevs = pendingBaseline.revs
|
||||
app = pendingBaseline.baseline
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
redraw++
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
UserDraft.saveMeta('app', path, pendingBaseline.revs)
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
// `?nodraft=true` is the callers' way of saying "skip the local autosave
|
||||
// on this load." Wipe the UserDraft entry and strip the flag from the
|
||||
// URL synchronously, before any descendant reads it. A plain reload
|
||||
// (no nodraft) restores normally.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('app', path)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
/** Increments per `loadApp` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
@@ -80,101 +124,116 @@
|
||||
custom_path: app_w_draft_.custom_path
|
||||
}
|
||||
|
||||
if (stateLoadedFromLocalStorage) {
|
||||
const reloadAction = async () => {
|
||||
stateLoadedFromLocalStorage = undefined
|
||||
await loadApp()
|
||||
// Resolve the app value: backend draft > deployed, then overlay any
|
||||
// local autosave from UserDraft if present.
|
||||
const backendApp = app_w_draft.draft
|
||||
? app_w_draft.summary !== undefined
|
||||
? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & {
|
||||
draft_only?: boolean
|
||||
value: any
|
||||
})
|
||||
: ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & {
|
||||
draft_only?: boolean
|
||||
value: any
|
||||
})
|
||||
: app_w_draft
|
||||
|
||||
const localDraftValue = UserDraft.get<App>('app', path)
|
||||
const previousMeta = UserDraft.getMeta('app', path)
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: app_w_draft.versions
|
||||
? app_w_draft.versions[app_w_draft.versions.length - 1]
|
||||
: undefined,
|
||||
remoteDraftRev: app_w_draft.draft_created_at
|
||||
}
|
||||
currentRevs = newRevs
|
||||
if (
|
||||
localDraftValue != undefined &&
|
||||
orderedJsonStringify(cleanValueProperties(localDraftValue)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendApp.value))
|
||||
) {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendApp, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
UserDraft.saveMeta('app', path, newRevs)
|
||||
}
|
||||
const appPath = backendApp.path
|
||||
const hasBackendDraft = app_w_draft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.discard('app', path, undefined)
|
||||
currentRevs = newRevs
|
||||
app = backendApp
|
||||
redraw++
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${appPath}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
})
|
||||
}
|
||||
app = { ...backendApp, value: localDraftValue }
|
||||
} else {
|
||||
// Local is missing or matches backend — wipe any stale entry so it
|
||||
// doesn't haunt the next session and use the backend value.
|
||||
if (localDraftValue != undefined) UserDraft.remove('app', path)
|
||||
app = backendApp
|
||||
}
|
||||
|
||||
if (app_w_draft.draft && !app_w_draft.draft_only && localDraftValue == undefined) {
|
||||
const reloadAction = () => {
|
||||
app = app_w_draft
|
||||
redraw++
|
||||
}
|
||||
const actions: ToastAction[] = []
|
||||
if (stateLoadedFromLocalStorage) {
|
||||
actions.push({
|
||||
label: 'Discard browser autosave and reload',
|
||||
callback: reloadAction
|
||||
})
|
||||
|
||||
const draftOrDeployed = cleanValueProperties(savedApp?.draft || savedApp)
|
||||
const urlScript = {
|
||||
...draftOrDeployed,
|
||||
value: stateLoadedFromLocalStorage
|
||||
}
|
||||
actions.push({
|
||||
const deployed = cleanValueProperties(app_w_draft as Value)
|
||||
const draft = cleanValueProperties(app ?? {})
|
||||
sendUserToast('app loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: draftOrDeployed,
|
||||
current: urlScript,
|
||||
title: `${savedApp?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
|
||||
button: { text: 'Discard autosave', onClick: reloadAction }
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sendUserToast('App restored from browser storage', false, actions)
|
||||
app_w_draft.value = stateLoadedFromLocalStorage
|
||||
app = app_w_draft
|
||||
} else if (app_w_draft.draft) {
|
||||
if (app_w_draft.summary !== undefined) {
|
||||
// backward compatibility for old drafts missing metadata
|
||||
app = {
|
||||
...app_w_draft,
|
||||
...app_w_draft.draft
|
||||
}
|
||||
} else {
|
||||
app = {
|
||||
...app_w_draft,
|
||||
value: app_w_draft.draft as any
|
||||
}
|
||||
}
|
||||
|
||||
if (!app_w_draft.draft_only) {
|
||||
const reloadAction = () => {
|
||||
stateLoadedFromLocalStorage = undefined
|
||||
app = app_w_draft
|
||||
redraw++
|
||||
}
|
||||
|
||||
const deployed = cleanValueProperties(app_w_draft as Value)
|
||||
const draft = cleanValueProperties(app ?? {})
|
||||
sendUserToast('app loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Discard draft and load from latest deployed version',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
app = app_w_draft
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Re-run on workspace OR path change so navigating from one app editor
|
||||
// to another (e.g. via the workspace picker) reloads the new app.
|
||||
const newPath = page.params.path
|
||||
if ($workspaceStore) {
|
||||
const currentPath = page.params.path
|
||||
if ($workspaceStore && currentPath !== undefined) {
|
||||
untrack(() => {
|
||||
// Clear the app so AppEditor unmounts; it will remount once loadApp
|
||||
// completes with fresh data, re-initializing its internal stores.
|
||||
app = undefined
|
||||
const s = nodraft ? undefined : localStorage.getItem(`app-${newPath}`)
|
||||
stateLoadedFromLocalStorage = s != undefined ? decodeState(s) : undefined
|
||||
path = currentPath
|
||||
loadApp()
|
||||
})
|
||||
}
|
||||
@@ -186,6 +245,7 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${savedApp.draft.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -204,6 +264,7 @@
|
||||
path: savedApp.path
|
||||
})
|
||||
}
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${savedApp.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -227,6 +288,12 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
{#key redraw}
|
||||
{#if app}
|
||||
@@ -248,6 +315,7 @@
|
||||
{diffDrawer}
|
||||
version={app.versions ? app.versions[app.versions.length - 1] : undefined}
|
||||
newApp={false}
|
||||
initialRevs={currentRevs}
|
||||
replaceStateFn={(path) => replaceState(path, page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
>
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
|
||||
import { AppService, type Policy } from '$lib/gen'
|
||||
import { page } from '$app/state'
|
||||
import { decodeState } from '$lib/utils'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { afterNavigate, replaceState } from '$app/navigation'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import FileEditorIcon from '$lib/components/raw_apps/FileEditorIcon.svelte'
|
||||
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
|
||||
import { readFieldsRecursively } from '$lib/utils'
|
||||
import { untrack } from 'svelte'
|
||||
import {
|
||||
react18Template,
|
||||
react19Template,
|
||||
@@ -40,11 +41,26 @@
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
|
||||
|
||||
let nodraft = page.url.searchParams.get('nodraft')
|
||||
// `nodraft` is captured into a local because we strip it from the URL
|
||||
// below — downstream readers like `templatePicker` must see the original
|
||||
// signal.
|
||||
const nodraft = page.url.searchParams.get('nodraft')
|
||||
const templatePath = page.url.searchParams.get('template')
|
||||
const templateId = page.url.searchParams.get('template_id')
|
||||
const hubId = page.url.searchParams.get('hub')
|
||||
|
||||
// "+ Raw App" / "+ App > Full code" buttons navigate with ?nodraft=true to
|
||||
// signal "start fresh". Wipe the persisted empty-path autosave and strip
|
||||
// the flag from the URL synchronously so a reload doesn't wipe the
|
||||
// freshly-started draft. A plain reload of /apps_raw/add (no nodraft)
|
||||
// instead restores the previous session.
|
||||
if (nodraft && typeof window !== 'undefined') {
|
||||
UserDraft.discard('raw_app', '', undefined)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
// Check in-memory store first, then sessionStorage (used when full page reload occurs)
|
||||
let importRaw = $importStore
|
||||
if ($importStore) {
|
||||
@@ -58,26 +74,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
const appState = nodraft || hubId ? undefined : localStorage.getItem('rawapp')
|
||||
const draftHandle = UserDraft.use<{
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, Runnable>
|
||||
data: RawAppData
|
||||
summary: string
|
||||
}>('raw_app', '')
|
||||
// Restore the persisted autosave so a plain reload of /apps_raw/add
|
||||
// resumes the last session. Captured once; the $effect below mirrors
|
||||
// later edits back. Import/template/hub flows in loadApp() wipe the
|
||||
// entry first (`UserDraft.remove`) for "start fresh" semantics.
|
||||
const restoredDraft = untrack(() => draftHandle.draft)
|
||||
|
||||
let summary = $state('')
|
||||
let files: Record<string, string> = $state(react19Template)
|
||||
afterNavigate(() => {
|
||||
if (nodraft) {
|
||||
let url = new URL(page.url.href)
|
||||
url.search = ''
|
||||
replaceState(url.toString(), page.state)
|
||||
}
|
||||
})
|
||||
let policy: Policy = $state({
|
||||
on_behalf_of: $userStore?.username.includes('@')
|
||||
? $userStore?.username
|
||||
: `u/${$userStore?.username}`,
|
||||
on_behalf_of_email: $userStore?.email,
|
||||
execution_mode: 'publisher'
|
||||
})
|
||||
|
||||
let runnables: Record<string, Runnable> = $state({
|
||||
const defaultRunnables: Record<string, Runnable> = {
|
||||
a: {
|
||||
name: 'a',
|
||||
fields: {},
|
||||
@@ -101,9 +110,55 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let summary = $state(restoredDraft?.summary ?? '')
|
||||
let files: Record<string, string> = $state(restoredDraft?.files ?? react19Template)
|
||||
let policy: Policy = $state({
|
||||
on_behalf_of: $userStore?.username.includes('@')
|
||||
? $userStore?.username
|
||||
: `u/${$userStore?.username}`,
|
||||
on_behalf_of_email: $userStore?.email,
|
||||
execution_mode: 'publisher'
|
||||
})
|
||||
|
||||
let runnables: Record<string, Runnable> = $state(restoredDraft?.runnables ?? defaultRunnables)
|
||||
/** Data configuration including tables and creation policy */
|
||||
let data: RawAppData = $state({ ...DEFAULT_DATA })
|
||||
let data: RawAppData = $state(restoredDraft?.data ?? { ...DEFAULT_DATA })
|
||||
|
||||
// First mirror consumes the handle's first-write skip up-front (wipe
|
||||
// then restore) so the user's first real edit isn't the one dropped.
|
||||
let firstMirror = true
|
||||
$effect(() => {
|
||||
readFieldsRecursively(files)
|
||||
readFieldsRecursively(runnables)
|
||||
readFieldsRecursively(data)
|
||||
void summary
|
||||
untrack(() => {
|
||||
if (firstMirror) {
|
||||
firstMirror = false
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
}
|
||||
draftHandle.draft = { files, runnables, data, summary }
|
||||
})
|
||||
})
|
||||
|
||||
// Reflect an external UserDraft.saveExternal into the form. Idempotent + the
|
||||
// d == null guard keeps it from looping with the mirror above or
|
||||
// clobbering "start fresh" loads (which discard the in-memory draft).
|
||||
$effect(() => {
|
||||
const d = draftHandle.draft
|
||||
if (d == null) return
|
||||
untrack(() => {
|
||||
if (localDraftDiffers(d, { files, runnables, data, summary })) {
|
||||
files = d.files
|
||||
runnables = d.runnables
|
||||
data = d.data
|
||||
summary = d.summary
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
loadApp()
|
||||
|
||||
function extractValue(value: any) {
|
||||
@@ -128,6 +183,10 @@
|
||||
}
|
||||
async function loadApp() {
|
||||
if (importRaw) {
|
||||
// Import/template/hub loads are an explicit "start fresh from this
|
||||
// content" — drop the restored empty-path autosave so it doesn't
|
||||
// linger as the next plain reload's baseline.
|
||||
UserDraft.discard('raw_app', '', undefined)
|
||||
sendUserToast('Loaded from YAML/JSON')
|
||||
if ('value' in importRaw) {
|
||||
summary = importRaw.summary
|
||||
@@ -139,6 +198,7 @@
|
||||
}
|
||||
console.log('importRaw', importRaw)
|
||||
} else if (templatePath) {
|
||||
UserDraft.discard('raw_app', '', undefined)
|
||||
const template = await AppService.getAppByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: templatePath
|
||||
@@ -148,6 +208,7 @@
|
||||
sendUserToast('App loaded from template path')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (templateId) {
|
||||
UserDraft.discard('raw_app', '', undefined)
|
||||
const template = await AppService.getAppByVersion({
|
||||
workspace: $workspaceStore!,
|
||||
id: parseInt(templateId)
|
||||
@@ -157,6 +218,7 @@
|
||||
sendUserToast('App loaded from template')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (hubId) {
|
||||
UserDraft.discard('raw_app', '', undefined)
|
||||
const hub = await AppService.getHubRawAppById({ id: Number(hubId) })
|
||||
if (hub.app?.value) {
|
||||
extractValue(hub.app.value)
|
||||
@@ -167,19 +229,6 @@
|
||||
console.log('App loaded from Hub')
|
||||
sendUserToast('App loaded from Hub')
|
||||
goto('?', { replaceState: true })
|
||||
} else if (!templatePath && !hubId && appState) {
|
||||
console.log('App loaded from browser stored autosave')
|
||||
sendUserToast('App restored from browser stored autosave', false, [
|
||||
{
|
||||
label: 'Start from blank',
|
||||
callback: () => {
|
||||
files = {}
|
||||
runnables = {}
|
||||
}
|
||||
}
|
||||
])
|
||||
let decoded = decodeState(appState)
|
||||
extractValue(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
import { AppService, DraftService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { cleanValueProperties, decodeState, type Value } from '$lib/utils'
|
||||
import { afterNavigate, replaceState } from '$app/navigation'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
orderedJsonStringify,
|
||||
readFieldsRecursively,
|
||||
type Value
|
||||
} from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast, type ToastAction } from '$lib/toast'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { HiddenRunnable } from '$lib/components/apps/types'
|
||||
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
UserDraft,
|
||||
checkStaleness,
|
||||
localDraftDiffers,
|
||||
type UserDraftMeta
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
|
||||
type RawAppDraft = {
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, any>
|
||||
data: RawAppData
|
||||
summary: string
|
||||
}
|
||||
|
||||
let files: Record<string, string> | undefined = $state(undefined)
|
||||
let runnables = $state({})
|
||||
/** Data configuration including tables and creation policy */
|
||||
@@ -37,15 +58,74 @@
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
let redraw = $state(0)
|
||||
let path = page.params.path ?? ''
|
||||
|
||||
let nodraft = page.url.searchParams.get('nodraft')
|
||||
// `?nodraft=true` is the callers' way of saying "skip the local autosave
|
||||
// on this load." Wipe the UserDraft entry and strip the flag from the
|
||||
// URL synchronously, before the handle is created. A plain reload (no
|
||||
// nodraft) restores normally.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('raw_app', path)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
if (nodraft) {
|
||||
let url = new URL(page.url.href)
|
||||
url.search = ''
|
||||
replaceState(url.toString(), page.state)
|
||||
const draftHandle = UserDraft.use<RawAppDraft>('raw_app', path)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: RawAppDraft; backendSource: any; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, backendSource, revs } = pendingBaseline
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(baseline, revs)
|
||||
extractRawApp(backendSource)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
redraw++
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
draftHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
// Persist the bundle whenever any of the four pieces of state changes.
|
||||
$effect(() => {
|
||||
if (!files) return
|
||||
readFieldsRecursively(files)
|
||||
readFieldsRecursively(runnables)
|
||||
readFieldsRecursively(data)
|
||||
void summary
|
||||
draftHandle.draft = { files, runnables, data, summary }
|
||||
})
|
||||
|
||||
// Reflect an external UserDraft.saveExternal into the form. Idempotent; the
|
||||
// `!files` guard skips the reload window so it doesn't fight loadApp.
|
||||
$effect(() => {
|
||||
const d = draftHandle.draft
|
||||
if (d == null || !files) return
|
||||
untrack(() => {
|
||||
if (localDraftDiffers(d, { files, runnables, data, summary })) {
|
||||
files = d.files
|
||||
runnables = d.runnables
|
||||
data = d.data
|
||||
summary = d.summary
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function extractRawApp(app: any) {
|
||||
@@ -73,10 +153,6 @@
|
||||
newPath = app.path
|
||||
}
|
||||
|
||||
const initialState = nodraft ? undefined : localStorage.getItem(`rawapp-${page.params.path}`)
|
||||
let stateLoadedFromLocalStorage =
|
||||
initialState != undefined ? decodeState(initialState) : undefined
|
||||
|
||||
/** Increments per `loadApp` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
@@ -99,48 +175,81 @@
|
||||
custom_path: app_w_draft_.custom_path
|
||||
}
|
||||
|
||||
if (stateLoadedFromLocalStorage) {
|
||||
const reloadAction = async () => {
|
||||
stateLoadedFromLocalStorage = undefined
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
const actions: ToastAction[] = []
|
||||
if (stateLoadedFromLocalStorage) {
|
||||
actions.push({
|
||||
label: 'Discard browser autosave and reload',
|
||||
callback: reloadAction
|
||||
})
|
||||
const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft
|
||||
const localDraft = draftHandle.draft
|
||||
const previousMeta = draftHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: app_w_draft.versions
|
||||
? app_w_draft.versions[app_w_draft.versions.length - 1]
|
||||
: undefined,
|
||||
remoteDraftRev: app_w_draft.draft_created_at
|
||||
}
|
||||
const backendBundle: RawAppDraft = {
|
||||
files: backendSource.value?.files ?? {},
|
||||
runnables: backendSource.value?.runnables ?? {},
|
||||
data:
|
||||
backendSource.value?.data ??
|
||||
(backendSource.value?.datatables
|
||||
? { ...DEFAULT_DATA, tables: backendSource.value.datatables }
|
||||
: { ...DEFAULT_DATA }),
|
||||
summary: backendSource.summary ?? ''
|
||||
}
|
||||
|
||||
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
|
||||
const urlScript = {
|
||||
...draftOrDeployed,
|
||||
value: stateLoadedFromLocalStorage
|
||||
if (
|
||||
localDraft != undefined &&
|
||||
orderedJsonStringify(cleanValueProperties(localDraft)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendBundle))
|
||||
) {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendBundle, backendSource, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
draftHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
actions.push({
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: draftOrDeployed,
|
||||
current: urlScript,
|
||||
title: `${savedApp?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
|
||||
button: { text: 'Discard autosave', onClick: reloadAction }
|
||||
})
|
||||
const appPath = app_w_draft.path
|
||||
const hasBackendDraft = app_w_draft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
extractRawApp(backendSource)
|
||||
redraw++
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'app',
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('raw_app', path)
|
||||
// UserDraft.remove only clears localStorage. Drop the
|
||||
// entry's in-memory state too so loadApp doesn't re-read
|
||||
// the stale autosave and re-fire the same toast.
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
})
|
||||
}
|
||||
sendUserToast('App restored from browser storage', false, actions)
|
||||
app_w_draft.value = stateLoadedFromLocalStorage
|
||||
extractRawApp(app_w_draft)
|
||||
redraw += 1
|
||||
} else if (app_w_draft.draft) {
|
||||
extractRawApp(app_w_draft.draft)
|
||||
runnables = localDraft.runnables
|
||||
data = localDraft.data
|
||||
summary = localDraft.summary
|
||||
policy = app_w_draft.policy
|
||||
newPath = app_w_draft.path
|
||||
files = localDraft.files
|
||||
} else {
|
||||
if (localDraft != undefined) UserDraft.remove('raw_app', path)
|
||||
extractRawApp(backendSource)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
|
||||
if (!app_w_draft.draft_only) {
|
||||
if (app_w_draft.draft && !app_w_draft.draft_only) {
|
||||
const reloadAction = () => {
|
||||
stateLoadedFromLocalStorage = undefined
|
||||
extractRawApp(app_w_draft)
|
||||
redraw++
|
||||
}
|
||||
@@ -149,7 +258,7 @@
|
||||
const draft = cleanValueProperties({ files, runnables })
|
||||
sendUserToast('app loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Discard draft and load from latest deployed version',
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
@@ -167,21 +276,18 @@
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
extractRawApp(app_w_draft)
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
// Re-run on workspace OR path change so navigating from one raw app editor
|
||||
// to another (e.g. via the workspace picker) reloads the new app.
|
||||
const newPath = page.params.path
|
||||
if ($workspaceStore) {
|
||||
const currentPath = page.params.path
|
||||
if ($workspaceStore && currentPath !== undefined) {
|
||||
// Clear files so RawAppEditor unmounts; it will remount when loadApp
|
||||
// completes with fresh data, re-initializing its internal stores.
|
||||
files = undefined
|
||||
const s = nodraft ? undefined : localStorage.getItem(`rawapp-${newPath}`)
|
||||
stateLoadedFromLocalStorage = s != undefined ? decodeState(s) : undefined
|
||||
path = currentPath
|
||||
loadApp()
|
||||
}
|
||||
})
|
||||
@@ -192,6 +298,12 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('raw_app', path)
|
||||
// Drop the in-memory handle state so loadApp sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare
|
||||
// the stale in-memory meta against the freshly fetched backend and
|
||||
// fire a spurious modal.
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/apps/edit/${savedApp.draft.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -210,6 +322,8 @@
|
||||
path: savedApp.path
|
||||
})
|
||||
}
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/apps/edit/${savedApp.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -233,12 +347,19 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
{#if files}
|
||||
{#key redraw}
|
||||
<div class="h-screen">
|
||||
<RawAppEditor
|
||||
on:savedNewAppPath={(event) => {
|
||||
UserDraft.remove('raw_app', path)
|
||||
goto(`/apps_raw/edit/${event.detail}`)
|
||||
newPath = event.detail
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$lib/navigation'
|
||||
import { afterNavigate, replaceState } from '$app/navigation'
|
||||
import { page } from '$app/state'
|
||||
|
||||
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
|
||||
@@ -14,16 +13,19 @@
|
||||
import { tick } from 'svelte'
|
||||
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
let nodraft = page.url.searchParams.get('nodraft')
|
||||
|
||||
afterNavigate(() => {
|
||||
if (nodraft) {
|
||||
let url = new URL(page.url.href)
|
||||
url.search = ''
|
||||
replaceState(url.toString(), page.state)
|
||||
}
|
||||
})
|
||||
// "+ Flow" buttons navigate with ?nodraft=true to signal "start fresh".
|
||||
// Wipe the persisted empty-path autosave and strip the flag from the URL
|
||||
// synchronously so a reload doesn't wipe the freshly-started draft. A
|
||||
// plain reload of /flows/add (no nodraft) instead restores whatever the
|
||||
// user was last working on.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('flow', '')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
const hubId = page.url.searchParams.get('hub')
|
||||
const templatePath = page.url.searchParams.get('template')
|
||||
@@ -44,9 +46,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
const initialState =
|
||||
hubId || templatePath || nodraft || isFork ? undefined : localStorage.getItem('flow')
|
||||
|
||||
let selectedId: string = $state('settings-metadata')
|
||||
let loading = $state(false)
|
||||
|
||||
@@ -61,8 +60,8 @@
|
||||
// initialArgs may also be set from decoded state below (e.g. fork preview)
|
||||
let flowBuilder: FlowBuilder | undefined = $state(undefined)
|
||||
|
||||
const flowStore: StateStore<Flow> = $state({
|
||||
val: {
|
||||
function emptyFlow(): Flow {
|
||||
return {
|
||||
summary: '',
|
||||
value: { modules: [] },
|
||||
path: '',
|
||||
@@ -72,25 +71,32 @@
|
||||
extra_perms: {},
|
||||
schema: emptySchema()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const flowHandle = UserDraft.use<Flow>('flow', '', { defaultValue: emptyFlow() })
|
||||
|
||||
const flowStore: StateStore<Flow> = {
|
||||
get val() {
|
||||
return flowHandle.draft ?? emptyFlow()
|
||||
},
|
||||
set val(v: Flow) {
|
||||
flowHandle.draft = v
|
||||
}
|
||||
}
|
||||
const flowStateStore = $state({ val: {} })
|
||||
|
||||
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
|
||||
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
|
||||
async function loadFlow() {
|
||||
loading = true
|
||||
let flow: Flow = {
|
||||
path: '',
|
||||
summary: '',
|
||||
value: { modules: [] },
|
||||
edited_by: '',
|
||||
edited_at: '',
|
||||
archived: false,
|
||||
extra_perms: {},
|
||||
schema: emptySchema()
|
||||
}
|
||||
// Start from the persisted autosave, not a fresh `emptyFlow()`. The
|
||||
// branches below override `flow` when the user explicitly asked for a
|
||||
// different starting point (import/fork/URL state/template/hub); a
|
||||
// plain reload of /flows/add (no query params) falls through with
|
||||
// the LS value intact so the user's last session is restored.
|
||||
let flow: Flow = flowHandle.draft ?? emptyFlow()
|
||||
|
||||
let state = forkState ?? (initialState ? decodeState(initialState) : undefined)
|
||||
let state = forkState
|
||||
const initialStateQuery = page.url.hash != '' ? page.url.hash.slice(1) : undefined
|
||||
|
||||
if (initialStateQuery) {
|
||||
@@ -101,24 +107,6 @@
|
||||
$importFlowStore = undefined
|
||||
sendUserToast('Flow loaded from YAML/JSON')
|
||||
} else if (!templatePath && !hubId && state) {
|
||||
sendUserToast('Flow restored from draft', false, [
|
||||
{
|
||||
label: 'Start from blank instead',
|
||||
callback: () => {
|
||||
flowStore.val = {
|
||||
summary: '',
|
||||
value: { modules: [] },
|
||||
path: '',
|
||||
edited_at: '',
|
||||
edited_by: '',
|
||||
archived: false,
|
||||
extra_perms: {},
|
||||
schema: emptySchema()
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
flow = state.flow
|
||||
pathStoreInit = state.path
|
||||
if (state.initialArgs) {
|
||||
@@ -143,12 +131,15 @@
|
||||
path: templatePath
|
||||
})
|
||||
}
|
||||
// Template/hub flows are an explicit "start fresh from this
|
||||
// content" — drop any previous empty-path autosave and use
|
||||
// the freshly built flow as the baseline.
|
||||
flow = emptyFlow()
|
||||
Object.assign(flow, template)
|
||||
const oldPath = templatePath.split('/')
|
||||
initialPath = `u/${$userStore?.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')}/${
|
||||
oldPath[oldPath.length - 1]
|
||||
}_fork`
|
||||
flow = flow
|
||||
goto('?', { replaceState: true })
|
||||
selectedId = 'settings-metadata'
|
||||
} else if (hubId) {
|
||||
@@ -157,6 +148,7 @@
|
||||
initialPath = `u/${$userStore?.username
|
||||
.split('@')[0]
|
||||
.replace(/[^a-zA-Z0-9_]/g, '')}/flow_${hubId}`
|
||||
flow = emptyFlow()
|
||||
Object.assign(flow, hub.flow)
|
||||
if (flow.value.preprocessor_module?.value.type === 'rawscript') {
|
||||
flow.value.preprocessor_module.value.content = replaceScriptPlaceholderWithItsValues(
|
||||
@@ -164,7 +156,6 @@
|
||||
flow.value.preprocessor_module.value.content
|
||||
)
|
||||
}
|
||||
flow = flow
|
||||
goto('?', { replaceState: true })
|
||||
selectedId = 'constants'
|
||||
}
|
||||
@@ -194,10 +185,12 @@
|
||||
|
||||
<FlowBuilder
|
||||
onSaveInitial={(e) => {
|
||||
UserDraft.remove('flow', '')
|
||||
if ($workspaceStore) invalidate($workspaceStore, 'flow')
|
||||
goto(`/flows/edit/${e.path}?selected=${e.id}`)
|
||||
}}
|
||||
onDeploy={(e) => {
|
||||
UserDraft.remove('flow', '')
|
||||
if ($workspaceStore) invalidate($workspaceStore, 'flow')
|
||||
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
|
||||
}}
|
||||
|
||||
@@ -13,23 +13,25 @@
|
||||
} from '$lib/utils'
|
||||
import { initFlow } from '$lib/components/flows/flowStore.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { afterNavigate, replaceState } from '$app/navigation'
|
||||
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import type { ScheduleTrigger } from '$lib/components/triggers'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { untrack } from 'svelte'
|
||||
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
|
||||
let version: undefined | number = $state(undefined)
|
||||
|
||||
// `initialArgs` is captured once at mount — it's the session's initial
|
||||
// argument set. Per-flow autosave (`stateLoadedFromUrl`) and the
|
||||
// `nodraft` flag are re-read inside `loadFlow` / the navigation hook so
|
||||
// picker navigation doesn't reuse the original path's state.
|
||||
// argument set. The flow draft itself lives in UserDraft and is re-read
|
||||
// per loadFlow() so picker navigation doesn't reuse the original path's
|
||||
// state.
|
||||
const urlArgs = page.url.searchParams.get('initial_args')
|
||||
|
||||
let initialArgs = $state({})
|
||||
@@ -46,16 +48,23 @@
|
||||
})
|
||||
| undefined = $state(undefined)
|
||||
|
||||
afterNavigate(() => {
|
||||
if (page.url.searchParams.get('nodraft')) {
|
||||
let url = new URL(page.url.href)
|
||||
url.search = ''
|
||||
replaceState(url.toString(), page.state)
|
||||
}
|
||||
})
|
||||
const flowDraftPath = page.params.path ?? ''
|
||||
|
||||
export const flowStore: StateStore<Flow> = $state({
|
||||
val: {
|
||||
// `?nodraft=true` is the callers' way of saying "skip the local autosave
|
||||
// on this load." Wipe the UserDraft entry and strip the flag from the
|
||||
// URL synchronously, before the handle is created — same pattern as
|
||||
// /flows/add. A plain reload (no nodraft) restores normally.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
const flowHandle = UserDraft.use<Flow>('flow', flowDraftPath)
|
||||
|
||||
function emptyFlow(): Flow {
|
||||
return {
|
||||
summary: '',
|
||||
value: { modules: [] },
|
||||
path: '',
|
||||
@@ -65,7 +74,16 @@
|
||||
extra_perms: {},
|
||||
schema: emptySchema()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const flowStore: StateStore<Flow> = {
|
||||
get val() {
|
||||
return flowHandle.draft ?? emptyFlow()
|
||||
},
|
||||
set val(v: Flow) {
|
||||
flowHandle.draft = v
|
||||
}
|
||||
}
|
||||
const flowStateStore = $state({ val: {} })
|
||||
|
||||
let loading = $state(false)
|
||||
@@ -74,15 +92,34 @@
|
||||
|
||||
let nobackenddraft = false
|
||||
|
||||
// One-shot read of mount-time autosave, used only to seed the initial
|
||||
// `savedPrimarySchedule` before `loadFlow` runs. `loadFlow` itself
|
||||
// re-reads localStorage on every invocation (see comment there).
|
||||
const initialAutosave = (() => {
|
||||
if (page.url.searchParams.get('nodraft')) return undefined
|
||||
const raw = localStorage.getItem(`flow-${page.params.path}`)
|
||||
return raw != undefined ? decodeState(raw) : undefined
|
||||
})()
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(initialAutosave?.primarySchedule)
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline: { baseline: Flow; revs: UserDraftMeta } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, revs } = pendingBaseline
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(baseline, revs)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
flowHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
|
||||
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
|
||||
@@ -100,77 +137,127 @@
|
||||
async function loadFlow(): Promise<void> {
|
||||
const tok = ++loadFlowToken
|
||||
loading = true
|
||||
|
||||
// Re-read autosave per load. The component doesn't remount when the
|
||||
// picker navigates between flows, so capturing at module init would
|
||||
// keep reusing the original flow's state.
|
||||
const stored = page.url.searchParams.get('nodraft')
|
||||
? undefined
|
||||
: localStorage.getItem(`flow-${page.params.path}`)
|
||||
const stateLoadedFromUrl = stored != undefined ? decodeState(stored) : undefined
|
||||
|
||||
let flow: Flow
|
||||
let statePath = stateLoadedFromUrl?.path
|
||||
if (stateLoadedFromUrl != undefined && statePath == page.params.path) {
|
||||
// Currently there is no way to get version of flow with flow.
|
||||
// So we have to request it here
|
||||
const v = (
|
||||
await FlowService.getFlowLatestVersion({
|
||||
workspace: $workspaceStore!,
|
||||
path: statePath
|
||||
})
|
||||
)?.id
|
||||
if (tok !== loadFlowToken) return
|
||||
version = v
|
||||
|
||||
if (version == undefined) {
|
||||
notFound = true
|
||||
sendUserToast(`Flow not found at path ${statePath}`, true)
|
||||
return
|
||||
}
|
||||
|
||||
const sf = await FlowService.getFlowByPathWithDraft({
|
||||
// Currently there is no way to get version of flow with flow.
|
||||
// So we have to request it here
|
||||
const v = (
|
||||
await FlowService.getFlowLatestVersion({
|
||||
workspace: $workspaceStore!,
|
||||
path: statePath
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
if (tok !== loadFlowToken) return
|
||||
savedFlow = sf
|
||||
).id
|
||||
if (tok !== loadFlowToken) return
|
||||
version = v
|
||||
|
||||
const draftOrDeployed = cleanValueProperties(savedFlow?.draft || savedFlow)
|
||||
const urlScript = cleanValueProperties(
|
||||
$state.snapshot({
|
||||
...stateLoadedFromUrl.flow,
|
||||
draft_triggers: stateLoadedFromUrl.draft_triggers
|
||||
})
|
||||
)
|
||||
flow = stateLoadedFromUrl.flow
|
||||
draftTriggersFromUrl = stateLoadedFromUrl.draft_triggers
|
||||
selectedTriggerIndexFromUrl = stateLoadedFromUrl.selected_trigger
|
||||
loadedFromHistoryFromUrl = stateLoadedFromUrl.loadedFromHistory
|
||||
flowBuilder?.setDraftTriggers(draftTriggersFromUrl)
|
||||
flowBuilder?.setSelectedTriggerIndex(selectedTriggerIndexFromUrl)
|
||||
flowBuilder?.setLoadedFromHistory(loadedFromHistoryFromUrl)
|
||||
const selectedId = stateLoadedFromUrl?.selectedId ?? 'settings-metadata'
|
||||
const reloadAction = () => {
|
||||
// Discard the localStorage autosave so the next `loadFlow`
|
||||
// (re-)read sees an empty slot and falls through to the
|
||||
// fetch branch — otherwise we'd re-enter this branch and
|
||||
// loop. Scripts dodge this because their state lives in
|
||||
// the URL fragment, which `goto` clears for us.
|
||||
try {
|
||||
localStorage.removeItem(`flow-${statePath}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
goto(`/flows/edit/${statePath}?selected=${selectedId}`)
|
||||
loadFlow()
|
||||
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
if (tok !== loadFlowToken) return
|
||||
savedFlow = {
|
||||
...structuredClone($state.snapshot(flowWithDraft)),
|
||||
draft: flowWithDraft.draft
|
||||
? {
|
||||
...structuredClone($state.snapshot(flowWithDraft.draft)),
|
||||
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
|
||||
}
|
||||
: undefined
|
||||
} as Flow & {
|
||||
draft?: Flow & {
|
||||
draft_triggers?: Trigger[]
|
||||
}
|
||||
if (orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(urlScript)) {
|
||||
reloadAction()
|
||||
}
|
||||
|
||||
const backendFlow =
|
||||
flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft
|
||||
const localDraft = flowHandle.draft
|
||||
const previousMeta = flowHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: v,
|
||||
remoteDraftRev: flowWithDraft.draft_created_at
|
||||
}
|
||||
|
||||
if (localDraft != undefined) {
|
||||
const localClean = cleanValueProperties(localDraft)
|
||||
const backendClean = cleanValueProperties(backendFlow)
|
||||
if (orderedJsonStringify(localClean) === orderedJsonStringify(backendClean)) {
|
||||
// Local matches backend exactly — silently drop the autosave.
|
||||
flow = backendFlow
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
} else {
|
||||
sendUserToast('Flow loaded from browser storage', false, [
|
||||
flow = localDraft
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendFlow, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
flowHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const flowPath = backendFlow.path
|
||||
const hasBackendDraft = flowWithDraft.draft != undefined
|
||||
notifyRestoredFromLocal(hasBackendDraft, !flowWithDraft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
loadFlow()
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: flowPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// UserDraft.remove only clears localStorage. Drop the
|
||||
// entry's in-memory state too so loadFlow doesn't re-read
|
||||
// the stale autosave and re-fire the same toast.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
nobackenddraft = true
|
||||
loadFlow()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flow = backendFlow
|
||||
flowHandle.setDraftAndMeta(backendFlow, newRevs)
|
||||
}
|
||||
|
||||
if (flowWithDraft.draft != undefined && !nobackenddraft) {
|
||||
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
|
||||
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers'])
|
||||
|
||||
if (!flowWithDraft.draft_only && localDraft == undefined) {
|
||||
const deployed = cleanValueProperties(flowWithDraft)
|
||||
const draft = cleanValueProperties(flow)
|
||||
const reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: flow.path
|
||||
})
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// UserDraft.remove only clears localStorage. The
|
||||
// flowHandle's in-memory state still holds the now-
|
||||
// deleted DB draft + its meta — loadFlow would treat it
|
||||
// as a local autosave and the staleness check would fire
|
||||
// a spurious "newer version was deployed" modal because
|
||||
// remoteDraftRev moved from "defined" to "undefined".
|
||||
// Drop the in-memory state first.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
nobackenddraft = true
|
||||
loadFlow()
|
||||
}
|
||||
sendUserToast('flow loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Discard browser autosave and reload',
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
@@ -179,93 +266,23 @@
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: draftOrDeployed,
|
||||
current: urlScript,
|
||||
title: `${savedFlow?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
|
||||
button: { text: 'Discard autosave', onClick: reloadAction }
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
// Currently there is no way to get version of flow with flow.
|
||||
// So we have to request it here
|
||||
const v = (
|
||||
await FlowService.getFlowLatestVersion({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
).id
|
||||
if (tok !== loadFlowToken) return
|
||||
version = v
|
||||
|
||||
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
if (tok !== loadFlowToken) return
|
||||
savedFlow = {
|
||||
...structuredClone($state.snapshot(flowWithDraft)),
|
||||
draft: flowWithDraft.draft
|
||||
? {
|
||||
...structuredClone($state.snapshot(flowWithDraft.draft)),
|
||||
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
|
||||
}
|
||||
: undefined
|
||||
} as Flow & {
|
||||
draft?: Flow & {
|
||||
draft_triggers?: Trigger[]
|
||||
}
|
||||
}
|
||||
if (flowWithDraft.draft != undefined && !nobackenddraft) {
|
||||
flow = flowWithDraft.draft
|
||||
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
|
||||
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers'])
|
||||
|
||||
if (!flowWithDraft.draft_only) {
|
||||
const deployed = cleanValueProperties(flowWithDraft)
|
||||
const draft = cleanValueProperties(flow)
|
||||
const reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'flow',
|
||||
path: flow.path
|
||||
})
|
||||
nobackenddraft = true
|
||||
loadFlow()
|
||||
}
|
||||
sendUserToast('flow loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Discard draft and load from latest deployed version',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
flow = flowWithDraft
|
||||
flowBuilder?.setDraftTriggers(undefined)
|
||||
}
|
||||
flowBuilder?.setDraftTriggers(undefined)
|
||||
}
|
||||
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
if (tok !== loadFlowToken) return
|
||||
loading = false
|
||||
selectedId = stateLoadedFromUrl?.selectedId ?? page.url.searchParams.get('selected')
|
||||
selectedId = page.url.searchParams.get('selected') ?? 'settings-metadata'
|
||||
flowBuilder?.loadFlowState()
|
||||
}
|
||||
|
||||
@@ -286,6 +303,12 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// Drop the in-memory handle state so loadFlow sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare
|
||||
// the stale in-memory meta against the freshly fetched backend and
|
||||
// fire a spurious modal.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/flows/edit/${savedFlow.draft.path}`)
|
||||
loadFlow()
|
||||
}
|
||||
@@ -303,6 +326,8 @@
|
||||
path: savedFlow.path
|
||||
})
|
||||
}
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/flows/edit/${savedFlow.path}`)
|
||||
loadFlow()
|
||||
}
|
||||
@@ -311,6 +336,12 @@
|
||||
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
{#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>
|
||||
@@ -319,6 +350,7 @@
|
||||
{:else}
|
||||
<FlowBuilder
|
||||
onDeploy={(e) => {
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
if ($workspaceStore) invalidate($workspaceStore, 'flow')
|
||||
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import {
|
||||
globalDraftStore,
|
||||
type WorkspaceItem
|
||||
} from '$lib/components/copilot/chat/global/draftStore.svelte'
|
||||
clearGlobalDrafts,
|
||||
deleteGlobalDraft,
|
||||
listGlobalDrafts
|
||||
} from '$lib/components/copilot/chat/global/userDraftAdapter'
|
||||
import { type WorkspaceItem } from '$lib/components/copilot/chat/global/workspaceItems'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
@@ -20,7 +22,11 @@
|
||||
}
|
||||
})
|
||||
|
||||
let drafts = $derived($workspaceStore ? globalDraftStore.listDrafts($workspaceStore) : [])
|
||||
let refreshRevision = $state(0)
|
||||
let drafts = $derived.by(() => {
|
||||
refreshRevision
|
||||
return $workspaceStore ? listGlobalDrafts($workspaceStore) : []
|
||||
})
|
||||
|
||||
function draftKey(item: WorkspaceItem): string {
|
||||
return `${item.type}:${item.triggerKind ?? '-'}:${item.path}`
|
||||
@@ -28,12 +34,14 @@
|
||||
|
||||
function deleteDraft(item: WorkspaceItem) {
|
||||
if (!$workspaceStore) return
|
||||
globalDraftStore.deleteDraft($workspaceStore, item.type, item.path, item.triggerKind)
|
||||
deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind)
|
||||
refreshRevision += 1
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!$workspaceStore) return
|
||||
globalDraftStore.clearDrafts($workspaceStore)
|
||||
clearGlobalDrafts($workspaceStore)
|
||||
refreshRevision += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -43,7 +51,7 @@
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Global AI drafts</h1>
|
||||
<p class="text-sm text-tertiary">
|
||||
Dev-only inspector for the in-memory global draft store.
|
||||
Dev-only inspector for drafts written by global AI mode.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -83,6 +91,8 @@
|
||||
variant="default"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
iconOnly
|
||||
aria-label={`Delete draft ${draft.path}`}
|
||||
title={`Delete draft ${draft.path}`}
|
||||
onclick={() => deleteDraft(draft)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import type { Schema } from '$lib/common'
|
||||
import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { replaceState } from '$app/navigation'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
@@ -17,6 +16,7 @@
|
||||
import ScriptEditorSkeleton from '$lib/components/ScriptEditorSkeleton.svelte'
|
||||
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
|
||||
import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
type Script = NewScript & {
|
||||
draft_triggers?: Trigger[]
|
||||
@@ -35,25 +35,46 @@
|
||||
const wacParam = page.url.searchParams.get('wac')
|
||||
const importParam = page.url.searchParams.get('import')
|
||||
|
||||
/** Some pages (run/[...run]'s "Fork" action, workspace_settings'
|
||||
* error/success-handler template buttons) base64-JSON-encode a NewScript
|
||||
* payload into the URL hash. That value is an explicit "open this script"
|
||||
* intent and wins over local autosave, templates, hubs, and YAML imports.
|
||||
*
|
||||
* We can't use `decodeState` from utils.ts directly — it fires its own
|
||||
* "Impossible to parse state" toast on failure, which would noise up the
|
||||
* UI when the hash isn't a script payload at all (e.g. a route anchor).
|
||||
*/
|
||||
function decodeUrlScript(): Partial<Script> | undefined {
|
||||
const fragment = page.url.hash.startsWith('#') ? page.url.hash.slice(1) : ''
|
||||
if (!fragment) return undefined
|
||||
try {
|
||||
const decoded = JSON.parse(decodeURIComponent(atob(fragment)))
|
||||
if (decoded && typeof decoded === 'object') return decoded as Partial<Script>
|
||||
} catch {
|
||||
// Hash isn't a valid encoded script — ignore.
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const urlScript = decodeUrlScript()
|
||||
// "+ Script" buttons navigate with ?nodraft=true to signal "start fresh".
|
||||
// Wipe the persisted empty-path autosave and strip the flag from the URL
|
||||
// synchronously so a reload doesn't wipe the freshly-started draft. A
|
||||
// plain reload of /scripts/add (no nodraft) instead restores the
|
||||
// previous session.
|
||||
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
|
||||
UserDraft.remove('script', '')
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('nodraft')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
|
||||
let initialArgs = urlArgs ? decodeState(urlArgs) : (get(initialArgsStore) ?? {})
|
||||
if (get(initialArgsStore)) $initialArgsStore = undefined
|
||||
|
||||
const path = page.url.searchParams.get('path')
|
||||
|
||||
const initialState = page.url.hash != '' ? page.url.hash.slice(1) : undefined
|
||||
|
||||
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
|
||||
|
||||
function decodeStateAndHandleError(state) {
|
||||
try {
|
||||
const decoded = decodeState(state)
|
||||
return decoded
|
||||
} catch (e) {
|
||||
console.error('Error decoding state', e)
|
||||
return defaultScript()
|
||||
}
|
||||
}
|
||||
|
||||
function defaultScript(): Script {
|
||||
return {
|
||||
hash: '',
|
||||
@@ -74,22 +95,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
let script: Script | undefined = $state(
|
||||
templatePath || hubPath
|
||||
? undefined
|
||||
: !path && initialState != undefined
|
||||
? decodeStateAndHandleError(initialState)
|
||||
: defaultScript()
|
||||
)
|
||||
// templatePath/hubPath/import/url-hash flows replace the value before
|
||||
// render, so defaultValue is left undefined for those to avoid flashing a
|
||||
// blank editor.
|
||||
const scriptHandle = UserDraft.use<Script>('script', '', {
|
||||
defaultValue: templatePath || hubPath || urlScript ? undefined : defaultScript()
|
||||
})
|
||||
|
||||
if (urlScript) {
|
||||
scriptHandle.draft = { ...defaultScript(), ...urlScript }
|
||||
sendUserToast('Loaded from URL')
|
||||
// Consume-once: strip the hash so a reload restores the user's
|
||||
// autosave instead of re-applying the seed (which would clobber
|
||||
// any edits they made).
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = ''
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplate(): Promise<void> {
|
||||
if (urlScript) return
|
||||
if (templatePath) {
|
||||
try {
|
||||
const template = await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: templatePath
|
||||
})
|
||||
script = {
|
||||
scriptHandle.draft = {
|
||||
...defaultScript(),
|
||||
summary: !emptyString(template.summary) ? `Copy of ${template.summary}` : '',
|
||||
description: template.description,
|
||||
@@ -99,7 +133,7 @@
|
||||
path: template.path + '_fork'
|
||||
}
|
||||
} catch (err) {
|
||||
script = defaultScript()
|
||||
scriptHandle.draft = defaultScript()
|
||||
console.error('Error loading template', err)
|
||||
sendUserToast('Error loading template: ' + err.message, true)
|
||||
}
|
||||
@@ -107,12 +141,13 @@
|
||||
}
|
||||
|
||||
async function loadHub(): Promise<void> {
|
||||
if (urlScript) return
|
||||
if (hubPath) {
|
||||
try {
|
||||
const { content, language, summary } = await ScriptService.getHubScriptByPath({
|
||||
path: hubPath
|
||||
})
|
||||
script = {
|
||||
scriptHandle.draft = {
|
||||
...defaultScript(),
|
||||
description: `Fork of ${hubPath}`,
|
||||
content: replaceScriptPlaceholderWithItsValues(hubPath, content),
|
||||
@@ -121,7 +156,7 @@
|
||||
path: hubPath + '_fork'
|
||||
}
|
||||
} catch (err) {
|
||||
script = defaultScript()
|
||||
scriptHandle.draft = defaultScript()
|
||||
console.error('Error loading script from hub', err)
|
||||
sendUserToast('Error loading script from hub: ' + err.message, true)
|
||||
}
|
||||
@@ -131,11 +166,11 @@
|
||||
loadHub()
|
||||
|
||||
let importedWacTemplate: 'wac_python' | 'wac_typescript' | undefined = undefined
|
||||
if (importParam && $importScriptStore) {
|
||||
if (!urlScript && importParam && $importScriptStore) {
|
||||
const imported = $importScriptStore
|
||||
$importScriptStore = undefined
|
||||
const isWac = isWorkflowAsCode(imported.content ?? '', imported.language ?? '')
|
||||
script = {
|
||||
scriptHandle.draft = {
|
||||
...defaultScript(),
|
||||
...imported,
|
||||
path: path ?? '',
|
||||
@@ -157,7 +192,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if script}
|
||||
{#if scriptHandle.draft}
|
||||
<ScriptBuilder
|
||||
{initialArgs}
|
||||
bind:this={scriptBuilder}
|
||||
@@ -178,9 +213,8 @@
|
||||
}}
|
||||
onNavigate={(item) => goto(editPathFor(item))}
|
||||
searchParams={page.url.searchParams}
|
||||
bind:script
|
||||
bind:script={scriptHandle.draft}
|
||||
{showMeta}
|
||||
replaceStateFn={(path) => replaceState(path, page.state)}
|
||||
>
|
||||
<UnsavedConfirmationModal
|
||||
getInitialAndModifiedValues={scriptBuilder?.getInitialAndModifiedValues}
|
||||
|
||||
@@ -4,38 +4,109 @@
|
||||
import { initialArgsStore, workspaceStore } from '$lib/stores'
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
|
||||
import { decodeState, cleanValueProperties, orderedJsonStringify } from '$lib/utils'
|
||||
import { cleanValueProperties, orderedJsonStringify } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { replaceState } from '$app/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import type { ScheduleTrigger } from '$lib/components/triggers'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import { get } from 'svelte/store'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
|
||||
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
|
||||
|
||||
// `initialArgs` is intentionally captured once at mount — it's the
|
||||
// session's initial argument set, not per-script. URL-derived state
|
||||
// (`hash`, `topHash`, fragment autosave) is re-read inside `loadScript`
|
||||
// because picker navigation reuses this component without remounting.
|
||||
// session's initial argument set, not per-script.
|
||||
let initialArgs = get(initialArgsStore) ?? {}
|
||||
if (get(initialArgsStore)) $initialArgsStore = undefined
|
||||
|
||||
let script: (NewScript & { draft_triggers?: Trigger[] }) | undefined = $state(undefined)
|
||||
let topHash = page.url.searchParams.get('topHash') ?? undefined
|
||||
|
||||
let initialPath: string = $state('')
|
||||
let hash = page.url.searchParams.get('hash') ?? undefined
|
||||
|
||||
// When viewing a specific historical hash we don't want to load or write a
|
||||
// local draft — that view is read-only relative to drafts.
|
||||
const draftPath = hash ? '' : (page.params.path ?? '')
|
||||
const scriptHandle = UserDraft.use<EditableScript>('script', draftPath)
|
||||
|
||||
/** Some pages base64-JSON-encode a NewScript-like payload into the URL
|
||||
* hash on `/scripts/edit/<path>#…`. Treat it as a one-shot seed that
|
||||
* wins over local autosave + backend draft + deployed: apply, toast,
|
||||
* strip from the URL. Same logic as /scripts/add, kept in this file for
|
||||
* a faithful mirror of its decoder.
|
||||
*
|
||||
* Can't reuse `decodeState` from utils.ts — it fires its own error toast
|
||||
* on parse failure, which would noise up the UI for unrelated anchors.
|
||||
*/
|
||||
function decodeUrlScriptSeed(): Partial<EditableScript> | undefined {
|
||||
const fragment = page.url.hash.startsWith('#') ? page.url.hash.slice(1) : ''
|
||||
if (!fragment) return undefined
|
||||
try {
|
||||
const decoded = JSON.parse(decodeURIComponent(atob(fragment)))
|
||||
if (decoded && typeof decoded === 'object') return decoded as Partial<EditableScript>
|
||||
} catch {
|
||||
// Hash isn't a valid encoded script — ignore.
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
let urlScriptSeed = decodeUrlScriptSeed()
|
||||
|
||||
// Seed from the URL so ScriptBuilder mounts with a populated `initialPath`
|
||||
// even when `scriptHandle.draft` is already defined synchronously from a
|
||||
// local autosave. An empty initialPath flips ScriptBuilder's
|
||||
// `metadataOpen` heuristic (intended for /scripts/add) into "true" and
|
||||
// pops the settings drawer open on /edit.
|
||||
let initialPath: string = $state(hash ? '' : (page.params.path ?? ''))
|
||||
|
||||
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
|
||||
|
||||
let reloadAction: () => Promise<void> = async () => {}
|
||||
|
||||
let savedScript: NewScriptWithDraft | undefined = $state(undefined)
|
||||
let fullyLoaded = $state(false)
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote (deployed or DB
|
||||
// draft) has moved on since the user's autosave was created.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline: { baseline: EditableScript; revs: UserDraftMeta } | undefined = undefined
|
||||
|
||||
function applyBaseline(baseline: EditableScript): void {
|
||||
initialPath = baseline.path
|
||||
scriptBuilder?.setDraftTriggers(baseline.draft_triggers)
|
||||
scriptBuilder?.setCode(baseline.content)
|
||||
if (baseline['primary_schedule']) {
|
||||
savedPrimarySchedule = baseline['primary_schedule']
|
||||
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
}
|
||||
}
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, revs } = pendingBaseline
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(baseline, revs)
|
||||
applyBaseline(baseline)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
scriptHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
/** Increments per `loadScript` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
@@ -43,38 +114,143 @@
|
||||
async function loadScript(): Promise<void> {
|
||||
const tok = ++loadScriptToken
|
||||
fullyLoaded = false
|
||||
if (hash) {
|
||||
const scriptByHash = await ScriptService.getScriptByHash({
|
||||
workspace: $workspaceStore!,
|
||||
hash
|
||||
})
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
|
||||
scriptHandle.draft = { ...scriptByHash, parent_hash: hash, lock: undefined }
|
||||
} else {
|
||||
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptWithDraft))
|
||||
|
||||
// Re-read URL-derived state on every load. The component doesn't
|
||||
// remount when the picker navigates between scripts, so capturing
|
||||
// these at module init would leave them stale.
|
||||
const urlFragment = window.location.hash != '' ? window.location.hash.slice(1) : undefined
|
||||
const scriptLoadedFromUrl = urlFragment != undefined ? decodeState(urlFragment) : undefined
|
||||
const hash = page.url.searchParams.get('hash') ?? undefined
|
||||
const topHash = page.url.searchParams.get('topHash') ?? undefined
|
||||
|
||||
if (scriptLoadedFromUrl != undefined && scriptLoadedFromUrl.path == page.params.path) {
|
||||
script = scriptLoadedFromUrl
|
||||
reloadAction = async () => {
|
||||
goto(`/scripts/edit/${script!.path}`)
|
||||
loadScript()
|
||||
const localDraft = scriptHandle.draft
|
||||
const previousMeta = scriptHandle.meta
|
||||
const backendDraft = scriptWithDraft.draft
|
||||
? ({ ...scriptWithDraft.draft } as EditableScript)
|
||||
: undefined
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: scriptWithDraft.hash,
|
||||
remoteDraftRev: scriptWithDraft.draft_created_at
|
||||
}
|
||||
|
||||
async function compareAutosave() {
|
||||
const sf = await ScriptService.getScriptByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: script!.path
|
||||
})
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = sf
|
||||
// Compute the fully-baked initial value once so the assignment
|
||||
// below is a single write — otherwise post-load mutations like
|
||||
// `parent_hash = ...` would count as a second write under
|
||||
// useLocalStorageValue's saveInitialValue=false contract and get
|
||||
// persisted before the user has touched anything.
|
||||
const baseline = (backendDraft ?? (scriptWithDraft as EditableScript)) as EditableScript
|
||||
const bakedBaseline: EditableScript = {
|
||||
...baseline,
|
||||
parent_hash: topHash ?? scriptWithDraft.hash
|
||||
}
|
||||
|
||||
const draftOrDeployed = cleanValueProperties(savedScript?.draft || savedScript)
|
||||
const urlScript = cleanValueProperties(scriptLoadedFromUrl)
|
||||
if (orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(urlScript)) {
|
||||
reloadAction()
|
||||
if (urlScriptSeed) {
|
||||
// URL hash seed wins over local autosave + backend draft + deployed.
|
||||
// Wipe the stale autosave on disk so the in-memory seed becomes the
|
||||
// only source — the user's subsequent edits will overwrite cleanly.
|
||||
const seeded = { ...bakedBaseline, ...urlScriptSeed } as EditableScript
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(seeded, newRevs)
|
||||
sendUserToast('Loaded from URL')
|
||||
// Consume-once: strip the hash so a reload restores the user's
|
||||
// autosave (which now reflects the seed + edits) instead of
|
||||
// re-applying the seed.
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = ''
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
urlScriptSeed = undefined
|
||||
} else if (localDraft != undefined) {
|
||||
const reference = backendDraft ?? scriptWithDraft
|
||||
const referenceClean = cleanValueProperties(reference)
|
||||
const localClean = cleanValueProperties(localDraft)
|
||||
if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) {
|
||||
// Local matches the saved version — silently drop it and use the saved one.
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
} else {
|
||||
sendUserToast('Script loaded from latest autosave stored in the URL', false, [
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
// Remote moved on since the local autosave was written —
|
||||
// surface the choice via modal. The local draft stays on
|
||||
// screen until the user picks.
|
||||
pendingBaseline = { baseline: bakedBaseline, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry (no meta recorded) — backfill so future
|
||||
// loads can detect staleness even if the user doesn't edit.
|
||||
scriptHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const scriptPath = bakedBaseline.path
|
||||
const hasBackendDraft = !!backendDraft
|
||||
notifyRestoredFromLocal(hasBackendDraft, !scriptWithDraft.draft_only, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
applyBaseline(bakedBaseline)
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
if (hasBackendDraft) {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: scriptPath
|
||||
})
|
||||
}
|
||||
UserDraft.remove('script', draftPath)
|
||||
// UserDraft.remove only clears localStorage. The entry's
|
||||
// in-memory state is kept alive by this route's handle, so
|
||||
// loadScript would re-read the stale autosave and the toast
|
||||
// would fire again. Drop the in-memory state first.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${scriptPath}`)
|
||||
loadScript()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (backendDraft) {
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
if (bakedBaseline['primary_schedule']) {
|
||||
savedPrimarySchedule = bakedBaseline['primary_schedule']
|
||||
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
}
|
||||
scriptBuilder?.setDraftTriggers(bakedBaseline.draft_triggers)
|
||||
|
||||
if (!scriptWithDraft.draft_only) {
|
||||
const reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: bakedBaseline.path
|
||||
})
|
||||
UserDraft.remove('script', draftPath)
|
||||
// UserDraft.remove only clears localStorage. The
|
||||
// scriptHandle's in-memory state still holds the now-
|
||||
// deleted DB draft + its meta — loadScript would treat
|
||||
// it as a local autosave and the staleness check
|
||||
// would fire a spurious "newer version was deployed"
|
||||
// modal because remoteDraftRev moved from "defined"
|
||||
// to "undefined". Drop the in-memory state first.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${bakedBaseline.path}`)
|
||||
loadScript()
|
||||
}
|
||||
const deployed = cleanValueProperties(scriptWithDraft)
|
||||
const draft = cleanValueProperties(bakedBaseline)
|
||||
sendUserToast('Script loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Discard browser stored autosave and reload',
|
||||
label: 'Reset to deployed',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
@@ -83,96 +259,24 @@
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: draftOrDeployed,
|
||||
current: urlScript,
|
||||
title: `${savedScript?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
|
||||
button: { text: 'Discard autosave', onClick: reloadAction }
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
compareAutosave()
|
||||
} else {
|
||||
if (hash) {
|
||||
const scriptByHash = await ScriptService.getScriptByHash({
|
||||
workspace: $workspaceStore!,
|
||||
hash
|
||||
})
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
|
||||
script = { ...scriptByHash, parent_hash: hash, lock: undefined }
|
||||
} else {
|
||||
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
if (tok !== loadScriptToken) return
|
||||
savedScript = structuredClone($state.snapshot(scriptWithDraft))
|
||||
if (scriptWithDraft.draft != undefined) {
|
||||
script = scriptWithDraft.draft
|
||||
scriptBuilder?.setDraftTriggers(script.draft_triggers)
|
||||
if (script['primary_schedule']) {
|
||||
savedPrimarySchedule = script['primary_schedule']
|
||||
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
}
|
||||
|
||||
if (!scriptWithDraft.draft_only) {
|
||||
reloadAction = async () => {
|
||||
await DraftService.deleteDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'script',
|
||||
path: script!.path
|
||||
})
|
||||
goto(`/scripts/edit/${script!.path}`)
|
||||
loadScript()
|
||||
}
|
||||
const deployed = cleanValueProperties(scriptWithDraft)
|
||||
const draft = cleanValueProperties(script)
|
||||
sendUserToast('Script loaded from latest saved draft', false, [
|
||||
{
|
||||
label: 'Discard draft reset to deployed version',
|
||||
callback: reloadAction
|
||||
},
|
||||
{
|
||||
label: 'Show diff',
|
||||
callback: async () => {
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: deployed,
|
||||
current: draft,
|
||||
title: 'Deployed <> Draft',
|
||||
button: { text: 'Discard draft', onClick: reloadAction }
|
||||
})
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
} else {
|
||||
script = scriptWithDraft
|
||||
}
|
||||
script.parent_hash = scriptWithDraft.hash
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
}
|
||||
}
|
||||
// hash
|
||||
// ? await ScriptService.getScriptByHash({
|
||||
// workspace: $workspaceStore!,
|
||||
// hash: page.params.hash
|
||||
// })
|
||||
// : await ScriptService.getScriptByPathWithDraft({
|
||||
// workspace: $workspaceStore!,
|
||||
// path: $page.params.path
|
||||
// })
|
||||
|
||||
if (script) {
|
||||
initialPath = script.path
|
||||
scriptBuilder?.setDraftTriggers(script.draft_triggers)
|
||||
scriptBuilder?.setCode(script.content)
|
||||
if (topHash) {
|
||||
script.parent_hash = topHash
|
||||
}
|
||||
if (scriptHandle.draft) {
|
||||
initialPath = scriptHandle.draft.path
|
||||
scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers)
|
||||
scriptBuilder?.setCode(scriptHandle.draft.content)
|
||||
}
|
||||
fullyLoaded = true
|
||||
}
|
||||
@@ -194,6 +298,12 @@
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('script', draftPath)
|
||||
// Drop the in-memory handle state so loadScript sees no local draft
|
||||
// on the next pass — otherwise the staleness check would compare the
|
||||
// stale in-memory meta against the freshly fetched backend and fire
|
||||
// a spurious modal.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${savedScript.draft.path}`)
|
||||
loadScript()
|
||||
}
|
||||
@@ -211,17 +321,25 @@
|
||||
path: savedScript.path
|
||||
})
|
||||
}
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${savedScript.path}`)
|
||||
loadScript()
|
||||
}
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
|
||||
{#if script}
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
{#if scriptHandle.draft}
|
||||
<ScriptBuilder
|
||||
bind:this={scriptBuilder}
|
||||
{initialPath}
|
||||
bind:script
|
||||
bind:script={scriptHandle.draft}
|
||||
{fullyLoaded}
|
||||
bind:savedScript
|
||||
{initialArgs}
|
||||
@@ -229,6 +347,7 @@
|
||||
{savedPrimarySchedule}
|
||||
searchParams={page.url.searchParams}
|
||||
onDeploy={(e) => {
|
||||
UserDraft.remove('script', draftPath)
|
||||
if ($workspaceStore) invalidate($workspaceStore, 'script')
|
||||
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
|
||||
}}
|
||||
@@ -240,9 +359,6 @@
|
||||
goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`)
|
||||
}}
|
||||
onNavigate={(item) => goto(editPathFor(item))}
|
||||
replaceStateFn={(path) => {
|
||||
replaceState(path, page.state)
|
||||
}}
|
||||
>
|
||||
<UnsavedConfirmationModal
|
||||
{diffDrawer}
|
||||
|
||||
Reference in New Issue
Block a user