feat: workspace forks merge UI (#7333)

* feat: Add workspace diff viewer and deployment UI for forked workspaces

- Add backend endpoint for comparing two workspaces
- Implement comparison logic for scripts, flows, apps, resources, variables
- Create ForkWorkspaceBanner component to detect and display fork status
- Build WorkspaceComparisonDrawer for detailed diff viewing and deployment
- Add DiffViewer component for line-by-line comparisons
- Support bidirectional deployment (fork to parent or parent to fork)
- Add conflict detection for items that are both ahead and behind
- Include delete fork option when no changes remain

Note: Backend implementation requires sqlx prepare to be run for full functionality

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* Fix banner and use wworkspace_diff table

* satisfactory UI WIP

* UI complete

* Deploy button

* Comaprison and reset tally

* compare all types of items

* Show summaries

* Disable buttons during deployment

* Auto select all on entering page

* Change migration to have 'exists_in' cols

* Show new and deleted items

* frontend fixes

* Block delpoyment if changes don't match (new chagnes detected)

* Message to block whe changes are behind

* Skip workspaces pre-migration

* Remove unused code

* Fix apps comparison

* Only return changes where user has visibility

* No deploy button if no access to all changes

* Prepare sqlx

* Remove redundant message

* CI: update ee repo ref

* eereporef bis

* Small tweaks

* Remove unused struct

* Remove unused refactor component

* Fix npm run check

* Remove unused component

* chore: update ee-repo-ref to bbf406edc222199ca2e6076da12c376fb4ff28c5

This commit updates the EE repository reference after PR #359 was merged in windmill-ee-private.

Previous ee-repo-ref: 6aae845c5629ae32da43dbfbdc4566e5bf90fb1e

New ee-repo-ref: bbf406edc222199ca2e6076da12c376fb4ff28c5

Automated by sync-ee-ref workflow.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
wendrul
2025-12-12 23:52:26 +01:00
committed by GitHub
parent a23f662765
commit fa4f608da2
34 changed files with 2683 additions and 54 deletions

View File

@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_diff SET has_changes = true, exists_in_source = $5, exists_in_fork = $6\n WHERE path = $3 AND kind = $4 AND (\n (source_workspace_id = $1 AND fork_workspace_id = $2)\n OR (source_workspace_id = $2 AND fork_workspace_id =$1)\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "034a8519198daf30e0eb8a74ed92f896c83bb39e1cb52fe3c29c1a224c3859c2"
}

View File

@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff\n WHERE source_workspace_id = $1 AND fork_workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "kind",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "ahead",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "behind",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "has_changes",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "exists_in_source",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "exists_in_fork",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
true
]
},
"hash": "0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM workspace_diff WHERE path = $3 AND kind = $4 AND (\n (source_workspace_id = $1 AND fork_workspace_id = $2)\n OR (source_workspace_id = $2 AND fork_workspace_id =$1)\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "0f689b9bd1c9a24f3c6cdafef0215f102122665bc3cc15718831b991052b4caf"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM script\n WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "2d1ba3c92c0385c530934082284cf548a50d533dc1bef58dfd0ecc163c9920f3"
}

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, is_secret, description\n FROM variable\n WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_secret",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM workspace WHERE parent_workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "4d04e436d46530f2f4c9dbc5c6c472581b9d90d330957539c0019ec2e7d9a68a"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM app\n WHERE workspace_id = $1 AND path = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "50ef2eac143273c7b7b7414b9be3c89130c709a57aca712e2af996bd4a4fa101"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM resource\n WHERE workspace_id = $1 AND path = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "5169d793f3820095839bb69d6c7c8cbea692ef6c1a838b970f382ee72db492d3"
}

View File

@@ -15,7 +15,7 @@
]
},
"nullable": [
true
null
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT app.summary, app.policy, app_version.value\n FROM app\n JOIN app_version\n ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "policy",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "value",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e"
}

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, description, resource_type\n FROM resource\n WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "resource_type",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
true,
false
]
},
"hash": "819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n SELECT $1, unnest($2::varchar[]), $3, $4, 0, 1, NULL\n ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind)\n DO UPDATE SET\n behind = workspace_diff.behind + 1,\n has_changes = NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"VarcharArray",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "90a1797f8d5ef7f9b67557c1fb919d1165860c5daf0de76fb2ac2201de11553c"
}

View File

@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT hash, created_at, content, summary, description, lock, schema\n FROM script\n WHERE workspace_id = $1 AND path = $2 AND archived = false\n ORDER BY created_at DESC\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "hash",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "summary",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "lock",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "schema",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true
]
},
"hash": "a6fe41e36e06c88e1387abaea17107ceab2c5b17258958bde03b3e492bb2790e"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ($1, $2, $3, $4, 1, 0, NULL)\n ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind)\n DO UPDATE SET\n ahead = workspace_diff.ahead + 1,\n has_changes = NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "ac3001bd72248efe36ae3c96af5450f0721f888d18475fae862d675d40779a30"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM workspace_diff WHERE has_changes = false AND (\n (source_workspace_id = $1 AND fork_workspace_id = $2)\n OR (source_workspace_id = $2 AND fork_workspace_id =$1)\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "af6aea925527c486e56375b6cd215b0e36938a5853285be485647bc8cc846973"
}

View File

@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
false,
true
true,
false
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM flow\n WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "c5897802334f09596a0297f9b9a1e24f3c6f8b1628965438a9360c073d70924e"
}

View File

@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, summary, description, schema\n FROM flow\n WHERE workspace_id = $1 AND path = $2 AND archived = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "summary",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "schema",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "dc6ffc9946325e794f3a9d1cf621a9c07d211351195c399f6ff25c1dc7adb01a"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM variable\n WHERE workspace_id = $1 AND path = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "ff24cfb1f603b4ea612e48cee5880fc516207c8a474446a1ee09e8324f85ba09"
}

View File

@@ -1 +1 @@
55e8296c3758b031e50d7ebdaef7c33666f02385
bbf406edc222199ca2e6076da12c376fb4ff28c5

View File

@@ -0,0 +1,3 @@
-- Add down migration script here
DROP TABLE IF EXISTS skip_workspace_diff_tally;
DROP TABLE IF EXISTS workspace_diff;

View File

@@ -0,0 +1,24 @@
-- Add up migration script here
CREATE TABLE workspace_diff (
source_workspace_id VARCHAR(50) NOT NULL,
fork_workspace_id VARCHAR(50) NOT NULL,
path VARCHAR(255) NOT NULL,
kind VARCHAR(50) NOT NULL,
ahead INTEGER NOT NULL DEFAULT 0,
behind INTEGER NOT NULL DEFAULT 0,
has_changes BOOLEAN DEFAULT NULL,
exists_in_source BOOLEAN DEFAULT NULL,
exists_in_fork BOOLEAN DEFAULT NULL,
PRIMARY KEY (source_workspace_id, fork_workspace_id, path, kind)
);
-- Create table to track workspaces that should be excluded from diff tallying
-- Old workspaces that are linked but have already diverged need to be skipped
CREATE TABLE skip_workspace_diff_tally (
workspace_id VARCHAR(50) PRIMARY KEY,
added_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Populate with all existing workspaces to exclude them from new tallying logic
INSERT INTO skip_workspace_diff_tally (workspace_id)
SELECT id FROM workspace;

View File

@@ -1 +1,2 @@
-- Add down migration script here
ALTER TABLE email_trigger DROP COLUMN enabled;

View File

@@ -2024,6 +2024,52 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/compare/{target_workspace_id}:
get:
operationId: compareWorkspaces
summary: Compare two workspaces
description: Compares the current workspace with a target workspace to find differences in scripts, flows, apps, resources, and variables. Returns information about items that are ahead, behind, or in conflict.
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: target_workspace_id
in: path
required: true
schema:
type: string
description: The ID of the workspace to compare with
responses:
"200":
description: Workspace comparison results
content:
application/json:
schema:
$ref: "#/components/schemas/WorkspaceComparison"
/w/{workspace}/workspaces/reset_diff_tally/{fork_workspace_id}:
post:
operationId: resetDiffTally
summary: Resets the ahead and behind deployement counter after a deployement
description: This endpoint should be called after a fork deployement
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: fork_workspace_id
in: path
required: true
schema:
type: string
description: The ID of the workspace to compare with
responses:
"200":
description: status
content:
application/json:
schema: {}
/users/exists/{email}:
get:
summary: exists email
@@ -20927,6 +20973,108 @@ components:
type: boolean
description: Whether operators can view workers page
WorkspaceComparison:
type: object
required:
- all_ahead_items_visible
- all_behind_items_visible
- skipped_comparison
- diffs
- summary
properties:
all_ahead_items_visible:
type: boolean
description: All items with changes ahead are visible by the user of the request.
all_behind_items_visible:
type: boolean
description: All items with changes behind are visible by the user of the request.
skipped_comparison:
type: boolean
description: Whether the comparison was skipped. This happens with old forks that where not being kept track of
diffs:
type: array
description: List of differences found between workspaces
items:
$ref: "#/components/schemas/WorkspaceItemDiff"
summary:
$ref: "#/components/schemas/CompareSummary"
description: Summary statistics of the comparison
WorkspaceItemDiff:
type: object
required:
- kind
- path
- ahead
- behind
- has_changes
- exists_in_source
- exists_in_fork
properties:
kind:
type: string
enum: ["script", "flow", "app", "resource", "variable"]
description: Type of the item
path:
type: string
description: Path of the item in the workspace
ahead:
type: integer
description: Number of versions source is ahead of target
behind:
type: integer
description: Number of versions source is behind target
has_changes:
type: boolean
description: Whether the item has any differences
exists_in_source:
type: boolean
description: If the item exists in the source workspace
exists_in_fork:
type: boolean
description: If the item exists in the fork workspace
CompareSummary:
type: object
required:
- total_diffs
- total_ahead
- total_behind
- scripts_changed
- flows_changed
- apps_changed
- resources_changed
- variables_changed
- conflicts
properties:
total_diffs:
type: integer
description: Total number of items with differences
total_ahead:
type: integer
description: Total number of ahead changes
total_behind:
type: integer
description: Total number of behind changes
scripts_changed:
type: integer
description: Number of scripts with differences
flows_changed:
type: integer
description: Number of flows with differences
apps_changed:
type: integer
description: Number of apps with differences
resources_changed:
type: integer
description: Number of resources with differences
variables_changed:
type: integer
description: Number of variables with differences
conflicts:
type: integer
description: Number of items that are both ahead and behind (conflicts)
TeamInfo:
type: object
required:

View File

@@ -29,7 +29,7 @@ use regex::Regex;
use hex;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
@@ -172,11 +172,16 @@ pub fn workspaced_service() -> Router {
post(acknowledge_all_critical_alerts),
)
.route("/critical_alerts/mute", post(mute_critical_alerts))
.route("/operator_settings", post(update_operator_settings))
.route(
"/create_workspace_fork_branch",
post(create_workspace_fork_branch),
)
.route("/operator_settings", post(update_operator_settings));
.route(
"/reset_diff_tally/:fork_workspace_id",
post(reset_workspace_diffs),
)
.route("/compare/:target_workspace_id", get(compare_workspaces));
#[cfg(all(feature = "stripe", feature = "enterprise"))]
{
@@ -4042,3 +4047,611 @@ async fn update_operator_settings(
Ok("Operator settings updated successfully".to_string())
}
#[derive(Serialize)]
pub struct WorkspaceComparison {
pub all_ahead_items_visible: bool,
pub all_behind_items_visible: bool,
pub skipped_comparison: bool,
pub diffs: Vec<WorkspaceDiffRow>,
pub summary: CompareSummary,
}
#[derive(Serialize, Default)]
pub struct CompareSummary {
pub total_diffs: usize,
pub total_ahead: usize,
pub total_behind: usize,
pub scripts_changed: usize,
pub flows_changed: usize,
pub apps_changed: usize,
pub resources_changed: usize,
pub variables_changed: usize,
pub conflicts: usize, // Items that are both ahead and behind
}
async fn reset_workspace_diffs(
authed: ApiAuthed,
Path((w_id, target_workspace_id)): Path<(String, String)>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<()> {
// Needed to compute the has_changes: Option<bool>. Otherwise it will be None, and the query will not hit the items
let _ = compare_workspaces(
authed,
Path((w_id.clone(), target_workspace_id.clone())),
Extension(db.clone()),
Extension(user_db),
)
.await?;
sqlx::query!(
"DELETE FROM workspace_diff WHERE has_changes = false AND (
(source_workspace_id = $1 AND fork_workspace_id = $2)
OR (source_workspace_id = $2 AND fork_workspace_id =$1)
)",
target_workspace_id,
w_id,
)
.execute(&db)
.await?;
Ok(Json(()))
}
#[derive(Serialize, Debug, Clone, Default)]
pub struct WorkspaceDiffRow {
kind: String,
path: String,
ahead: i32,
behind: i32,
has_changes: Option<bool>,
exists_in_source: Option<bool>,
exists_in_fork: Option<bool>,
}
async fn compare_workspaces(
authed: ApiAuthed,
Path((source_workspace_id, fork_workspace_id)): Path<(String, String)>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<WorkspaceComparison> {
// require_admin(authed.is_admin, &authed.username)?;
let skipped_comparison: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM skip_workspace_diff_tally
WHERE workspace_id = $1
)",
)
.bind(&fork_workspace_id)
.fetch_one(&db)
.await?;
if skipped_comparison {
return Ok(Json(WorkspaceComparison {
all_ahead_items_visible: true,
all_behind_items_visible: true,
skipped_comparison,
diffs: vec![],
summary: Default::default(),
}));
}
let diff_items = sqlx::query_as!(
WorkspaceDiffRow,
"SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff
WHERE source_workspace_id = $1 AND fork_workspace_id = $2",
source_workspace_id,
fork_workspace_id,
)
.fetch_all(&db)
.await?;
let mut confirmed_diffs = vec![];
for item in diff_items {
if let Some(has_changes) = item.has_changes {
if has_changes {
confirmed_diffs.push(item);
}
continue;
}
let item_comparison = match item.kind.as_str() {
"script" => Some(
compare_two_scripts(&db, &source_workspace_id, &fork_workspace_id, &item.path)
.await?,
),
"flow" => Some(
compare_two_flows(&db, &source_workspace_id, &fork_workspace_id, &item.path)
.await?,
),
"app" => Some(
compare_two_apps(&db, &source_workspace_id, &fork_workspace_id, &item.path).await?,
),
"resource" => Some(
compare_two_resources(&db, &source_workspace_id, &fork_workspace_id, &item.path)
.await?,
),
"variable" => Some(
compare_two_variables(&db, &source_workspace_id, &fork_workspace_id, &item.path)
.await?,
),
k => {
tracing::error!("Received unrecognized item kind `{k}` with path: `{}` while computing diff of {fork_workspace_id} and {source_workspace_id} workspaces. Skipping this item", item.path);
None
// Some(ItemComparison {
// has_changes: true,
// exists_in_source: true,
// exists_in_fork: true,
// })
}
};
if let Some(item_comparison) = item_comparison {
if item_comparison.has_changes {
sqlx::query!(
"UPDATE workspace_diff SET has_changes = true, exists_in_source = $5, exists_in_fork = $6
WHERE path = $3 AND kind = $4 AND (
(source_workspace_id = $1 AND fork_workspace_id = $2)
OR (source_workspace_id = $2 AND fork_workspace_id =$1)
)",
source_workspace_id,
fork_workspace_id,
item.path,
item.kind,
item_comparison.exists_in_source,
item_comparison.exists_in_fork,
)
.execute(&db)
.await?;
confirmed_diffs.push(WorkspaceDiffRow {
has_changes: Some(item_comparison.has_changes),
exists_in_source: Some(item_comparison.exists_in_source),
exists_in_fork: Some(item_comparison.exists_in_fork),
..item
});
} else {
sqlx::query!(
"DELETE FROM workspace_diff WHERE path = $3 AND kind = $4 AND (
(source_workspace_id = $1 AND fork_workspace_id = $2)
OR (source_workspace_id = $2 AND fork_workspace_id =$1)
)",
source_workspace_id,
fork_workspace_id,
item.path,
item.kind,
)
.execute(&db)
.await?;
}
}
}
let visible_diffs = filter_visible_diffs(
&confirmed_diffs,
&source_workspace_id,
&fork_workspace_id,
user_db.begin(&authed).await?,
)
.await?;
let summary = CompareSummary {
total_diffs: visible_diffs.len(),
total_ahead: visible_diffs
.iter()
.map(|s| s.ahead)
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0)),
total_behind: visible_diffs
.iter()
.map(|s| s.behind)
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0)),
scripts_changed: visible_diffs.iter().filter(|s| s.kind == "script").count(),
flows_changed: visible_diffs.iter().filter(|s| s.kind == "flow").count(),
apps_changed: visible_diffs.iter().filter(|s| s.kind == "app").count(),
resources_changed: visible_diffs
.iter()
.filter(|s| s.kind == "resource")
.count(),
variables_changed: visible_diffs
.iter()
.filter(|s| s.kind == "variable")
.count(),
conflicts: visible_diffs
.iter()
.filter(|s| s.ahead > 0 && s.behind > 0)
.count(),
};
let all_ahead_items_visible = summary.total_ahead
== confirmed_diffs
.iter()
.map(|s| s.ahead)
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0));
let all_behind_items_visible = summary.total_behind
== confirmed_diffs
.iter()
.map(|s| s.behind)
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0));
return Ok(Json(WorkspaceComparison {
all_ahead_items_visible,
all_behind_items_visible,
skipped_comparison: false,
diffs: visible_diffs,
summary,
}));
}
async fn filter_visible_diffs(
confirmed_diffs: &[WorkspaceDiffRow],
source_workspace_id: &str,
fork_workspace_id: &str,
mut tx: Transaction<'static, Postgres>,
) -> Result<Vec<WorkspaceDiffRow>> {
// Step 1: Group paths by (workspace, kind)
let mut source_items: HashMap<&str, Vec<&str>> = HashMap::new();
let mut fork_items: HashMap<&str, Vec<&str>> = HashMap::new();
for diff in confirmed_diffs {
if diff.exists_in_source.unwrap_or(false) {
source_items.entry(&diff.kind).or_default().push(&diff.path);
}
if diff.exists_in_fork.unwrap_or(false) {
fork_items.entry(&diff.kind).or_default().push(&diff.path);
}
}
// Step 2: Batch query for each (workspace, kind) combination
let source_visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?;
let fork_visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?;
// Step 3: Filter diffs based on visibility
let visible_diffs: Vec<WorkspaceDiffRow> = confirmed_diffs
.iter()
.filter(|diff| {
let v = (diff.kind.to_string(), diff.path.to_string());
let source_ok = !diff.exists_in_source.unwrap_or(false) || source_visible.contains(&v);
let fork_ok = !diff.exists_in_fork.unwrap_or(false) || fork_visible.contains(&v);
source_ok && fork_ok
})
.cloned()
.collect();
Ok(visible_diffs)
}
async fn query_visible_items<'c>(
tx: &mut Transaction<'c, Postgres>,
workspace_id: &str,
items_by_kind: &HashMap<&str, Vec<&str>>,
) -> Result<HashSet<(String, String)>> {
let mut visible = HashSet::new();
for (kind, paths) in items_by_kind {
let paths_vec: Vec<String> = paths.iter().map(|s| s.to_string()).collect();
let results = match *kind {
"script" => {
sqlx::query_scalar!(
"SELECT path FROM script
WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
workspace_id,
&paths_vec
)
.fetch_all(&mut **tx)
.await?
}
"flow" => {
sqlx::query_scalar!(
"SELECT path FROM flow
WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
workspace_id,
&paths_vec
)
.fetch_all(&mut **tx)
.await?
}
"app" => {
sqlx::query_scalar!(
"SELECT path FROM app
WHERE workspace_id = $1 AND path = ANY($2)",
workspace_id,
&paths_vec
)
.fetch_all(&mut **tx)
.await?
}
"resource" => {
sqlx::query_scalar!(
"SELECT path FROM resource
WHERE workspace_id = $1 AND path = ANY($2)",
workspace_id,
&paths_vec
)
.fetch_all(&mut **tx)
.await?
}
"variable" => {
sqlx::query_scalar!(
"SELECT path FROM variable
WHERE workspace_id = $1 AND path = ANY($2)",
workspace_id,
&paths_vec
)
.fetch_all(&mut **tx)
.await?
}
_ => vec![], // Unknown kind
};
for path in results {
visible.insert((kind.to_string(), path));
}
}
Ok(visible)
}
#[derive(Debug)]
struct ItemComparison {
has_changes: bool,
exists_in_source: bool,
exists_in_fork: bool,
}
async fn compare_two_scripts(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
// Get latest script from each workspace
let source_script = sqlx::query!(
"SELECT hash, created_at, content, summary, description, lock, schema
FROM script
WHERE workspace_id = $1 AND path = $2 AND archived = false
ORDER BY created_at DESC
LIMIT 1",
source_workspace_id,
path
)
.fetch_optional(db)
.await?;
let target_script = sqlx::query!(
"SELECT hash, created_at, content, summary, description, lock, schema
FROM script
WHERE workspace_id = $1 AND path = $2 AND archived = false
ORDER BY created_at DESC
LIMIT 1",
fork_workspace_id,
path
)
.fetch_optional(db)
.await?;
let mut has_changes = false;
// Check metadata differences
if let (Some(source), Some(target)) = (&source_script, &target_script) {
if source.content != target.content
|| source.summary != target.summary
|| source.description != target.description
|| source.lock != target.lock
|| source.schema != target.schema
{
has_changes = true;
}
} else if source_script.is_some() || target_script.is_some() {
// The script exists in one of source or target, but not the other, this is considered as a change
has_changes = true
}
return Ok(ItemComparison {
has_changes,
exists_in_source: source_script.is_some(),
exists_in_fork: target_script.is_some(),
});
}
async fn compare_two_flows(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
// Get latest flow from each workspace
let source_flow = sqlx::query!(
"SELECT value, summary, description, schema
FROM flow
WHERE workspace_id = $1 AND path = $2 AND archived = false",
source_workspace_id,
path
)
.fetch_optional(db)
.await?;
let target_flow = sqlx::query!(
"SELECT value, summary, description, schema
FROM flow
WHERE workspace_id = $1 AND path = $2 AND archived = false",
fork_workspace_id,
path
)
.fetch_optional(db)
.await?;
let mut has_changes = false;
// Check metadata differences
if let (Some(source), Some(target)) = (&source_flow, &target_flow) {
if source.value != target.value
|| source.summary != target.summary
|| source.description != target.description
|| source.schema != target.schema
{
has_changes = true;
}
} else if source_flow.is_some() || target_flow.is_some() {
// The flow exists in one of source or target, but not the other, this is considered as a change
has_changes = true
}
return Ok(ItemComparison {
has_changes,
exists_in_source: source_flow.is_some(),
exists_in_fork: target_flow.is_some(),
});
}
async fn compare_two_apps(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
// Get app with its latest version data from source workspace
let source_app = sqlx::query!(
"SELECT app.summary, app.policy, app_version.value
FROM app
JOIN app_version
ON app_version.id = app.versions[array_upper(app.versions, 1)]
WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false",
source_workspace_id,
path
)
.fetch_optional(db)
.await?;
let target_app = sqlx::query!(
"SELECT app.summary, app.policy, app_version.value
FROM app
JOIN app_version
ON app_version.id = app.versions[array_upper(app.versions, 1)]
WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false",
fork_workspace_id,
path
)
.fetch_optional(db)
.await?;
let mut has_changes = false;
// Check metadata and content differences
if let (Some(source), Some(target)) = (&source_app, &target_app) {
if source.summary != target.summary
|| source.policy != target.policy
|| source.value != target.value
{
has_changes = true;
}
} else if source_app.is_some() || target_app.is_some() {
// The app exists in one of source or target, but not the other, this is considered as a change
has_changes = true
}
return Ok(ItemComparison {
has_changes,
exists_in_source: source_app.is_some(),
exists_in_fork: target_app.is_some(),
});
}
async fn compare_two_resources(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
// Get resource from each workspace
let source_resource = sqlx::query!(
"SELECT value, description, resource_type
FROM resource
WHERE workspace_id = $1 AND path = $2",
source_workspace_id,
path
)
.fetch_optional(db)
.await?;
let target_resource = sqlx::query!(
"SELECT value, description, resource_type
FROM resource
WHERE workspace_id = $1 AND path = $2",
fork_workspace_id,
path
)
.fetch_optional(db)
.await?;
let mut has_changes = false;
// Check metadata differences
if let (Some(source), Some(target)) = (&source_resource, &target_resource) {
if source.value != target.value
|| source.description != target.description
|| source.resource_type != target.resource_type
{
has_changes = true;
}
} else if source_resource.is_some() || target_resource.is_some() {
// The resource exists in one of source or target, but not the other, this is considered as a change
has_changes = true
}
return Ok(ItemComparison {
has_changes,
exists_in_source: source_resource.is_some(),
exists_in_fork: target_resource.is_some(),
});
}
async fn compare_two_variables(
db: &DB,
source_workspace_id: &str,
fork_workspace_id: &str,
path: &str,
) -> Result<ItemComparison> {
// Get variable from each workspace
let source_variable = sqlx::query!(
"SELECT value, is_secret, description
FROM variable
WHERE workspace_id = $1 AND path = $2",
source_workspace_id,
path
)
.fetch_optional(db)
.await?;
let target_variable = sqlx::query!(
"SELECT value, is_secret, description
FROM variable
WHERE workspace_id = $1 AND path = $2",
fork_workspace_id,
path
)
.fetch_optional(db)
.await?;
let mut has_changes = false;
// Check metadata differences
if let (Some(source), Some(target)) = (&source_variable, &target_variable) {
if source.is_secret != target.is_secret
|| source.value != target.value
|| source.description != target.description
{
has_changes = true;
}
} else if source_variable.is_some() || target_variable.is_some() {
// The variable exists in one of source or target, but not the other, this is considered as a change
has_changes = true
}
return Ok(ItemComparison {
has_changes,
exists_in_source: source_variable.is_some(),
exists_in_fork: target_variable.is_some(),
});
}

View File

@@ -102,4 +102,30 @@ impl DeployedObject {
DeployedObject::Key { .. } => None,
}
}
pub fn get_kind(&self) -> String {
match self {
DeployedObject::Script { .. } => "script",
DeployedObject::Flow { .. } => "flow",
DeployedObject::App { .. } => "app",
DeployedObject::Folder { .. } => "folder",
DeployedObject::Resource { .. } => "resource",
DeployedObject::Variable { .. } => "variable",
DeployedObject::Schedule { .. } => "schedule",
DeployedObject::ResourceType { .. } => "resource_type",
DeployedObject::User { .. } => "user",
DeployedObject::Group { .. } => "group",
DeployedObject::HttpTrigger { .. } => "http_trigger",
DeployedObject::WebsocketTrigger { .. } => "websocket_trigger",
DeployedObject::KafkaTrigger { .. } => "kafka_trigger",
DeployedObject::NatsTrigger { .. } => "nats_trigger",
DeployedObject::PostgresTrigger { .. } => "postgres_trigger",
DeployedObject::MqttTrigger { .. } => "mqtt_trigger",
DeployedObject::SqsTrigger { .. } => "sqs_trigger",
DeployedObject::GcpTrigger { .. } => "gcp_trigger",
DeployedObject::EmailTrigger { .. } => "email_trigger",
DeployedObject::Settings { .. } => "settings",
DeployedObject::Key { .. } => "key",
}.to_string()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,192 @@
<script lang="ts">
import { workspaceStore, userWorkspaces } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import type { WorkspaceComparison } from '$lib/gen'
import { Button } from './common'
import { AlertTriangle, GitFork } from 'lucide-svelte'
import { goto } from '$app/navigation'
import { onMount, untrack } from 'svelte'
let loading = $state(false)
let comparison: WorkspaceComparison | undefined = $state(undefined)
let error: string | undefined = $state(undefined)
let isFork = $derived($workspaceStore?.startsWith('wm-fork-') ?? false)
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
$effect(() => {
;[$workspaceStore, parentWorkspaceId]
untrack(() => {
if (isFork && $workspaceStore) {
checkForChanges()
} else {
comparison = undefined
}
})
})
onMount(() => {
if (isFork && $workspaceStore) {
checkForChanges()
} else {
comparison = undefined
}
})
async function checkForChanges() {
if (!$workspaceStore || !parentWorkspaceId) {
return
}
loading = true
error = undefined
try {
// Compare with parent workspace
const result = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: $workspaceStore
})
comparison = result
} catch (e) {
console.error('Failed to compare workspaces:', e)
error = `Failed to check for changes: ${e}`
// Still show banner if there's an error, but with error message
} finally {
loading = false
}
}
function openComparisonDrawer() {
if (parentWorkspaceId && $workspaceStore) {
goto('/forks/compare?workspace_id=' + encodeURIComponent($workspaceStore), {
replaceState: true
})
}
}
function forkAheadBehindMessage(
changesAhead: number,
changesBehind: number,
) {
let msg: string[] = []
if (changesAhead > 0 || changesBehind > 0) {
msg.push('This fork is ')
if (changesAhead > 0)
msg.push(`${changesAhead} change${changesAhead > 1 ? 's' : ''} ahead of `)
if (changesAhead > 0 && changesBehind > 0) msg.push('and ')
if (changesBehind > 0)
msg.push(`${changesBehind} change${changesBehind > 1 ? 's' : ''} behind `)
}
return msg.join('')
}
</script>
{#if isFork}
<div class="w-full bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800">
<div class="px-4 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<GitFork class="w-4 h-4 text-blue-600 dark:text-blue-400" />
<div class="text-sm">
<span class="font-medium text-blue-900 dark:text-blue-100">
Fork of <b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
</span>
</div>
{#if loading}
<span class="text-xs text-blue-600 dark:text-blue-400"> Checking for changes... </span>
{:else if error}
<span class="text-xs text-red-600 dark:text-red-400">
{error}
</span>
{:else if comparison}
<div class="flex items-center gap-4 text-xs">
{#if comparison.summary.total_diffs > 0}
<span>
{forkAheadBehindMessage(
comparison.summary.total_ahead,
comparison.summary.total_behind,
)}
<span class="font-semibold underline">{parentWorkspaceId}</span> over {comparison.summary
.total_diffs} items:
</span>
<div class="flex items-center gap-2">
{#if comparison.summary.scripts_changed > 0}
<span class="text-blue-700 dark:text-blue-300">
{comparison.summary.scripts_changed} script{comparison.summary
.scripts_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.flows_changed > 0}
<span class="text-blue-700 dark:text-blue-300">
{comparison.summary.flows_changed} flow{comparison.summary.flows_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.apps_changed > 0}
<span class="text-blue-700 dark:text-blue-300">
{comparison.summary.apps_changed} app{comparison.summary.apps_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.resources_changed > 0}
<span class="text-blue-700 dark:text-blue-300">
{comparison.summary.resources_changed} resource{comparison.summary
.resources_changed !== 1
? 's'
: ''}
</span>
{/if}
{#if comparison.summary.variables_changed > 0}
<span class="text-blue-700 dark:text-blue-300">
{comparison.summary.variables_changed} variable{comparison.summary
.variables_changed !== 1
? 's'
: ''}
</span>
{/if}
</div>
{#if comparison.summary.conflicts > 0}
-
<div class="flex items-center gap-1 text-orange-600 dark:text-orange-400">
<AlertTriangle class="w-3 h-3" />
<span
>{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1
? 's'
: ''}</span
>
</div>
{/if}
{:else if comparison.skipped_comparison}
<span class="text-blue-600 dark:text-blue-400"> This fork was created before the addition of certain windmill features, and therefore the changes with its parent workspace cannot be displayed.</span>
{:else}
<span class="text-blue-600 dark:text-blue-400"> Everything is up to date </span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2">
{#if comparison && comparison.summary.total_diffs > 0}
<Button size="xs" color="blue" on:click={openComparisonDrawer}>
{#if comparison.summary.total_ahead > 0}
Review & Deploy Changes
{:else}
Review & Update fork
{/if}
</Button>
{/if}
</div>
</div>
</div>
</div>
{/if}

View File

@@ -12,74 +12,91 @@
interface Props {
marked: string | undefined
starred: boolean
selected?: boolean
disabled?: boolean
canFavorite?: boolean
isSelectable?: boolean
alignWithSelectable?: boolean
errorHandlerMuted?: boolean
aiId?: string | undefined
aiDescription?: string | undefined
kind?: 'script' | 'flow' | 'app' | 'raw_app'
kind?: 'script' | 'flow' | 'app' | 'raw_app' | 'resource' | 'variable'
summary?: string | undefined
path: string
href: string
href?: string
workspaceId: string
depth?: number
badges?: import('svelte').Snippet
actions?: import('svelte').Snippet
customSummary?: import('svelte').Snippet
onSelect?: (
e: Event & {
currentTarget: EventTarget & HTMLInputElement
}
) => void
}
let {
marked,
starred,
selected = false,
disabled = false,
canFavorite = true,
isSelectable = false,
alignWithSelectable = false,
errorHandlerMuted = false,
aiId = undefined,
aiDescription = undefined,
kind = 'script',
summary = undefined,
path,
href,
href = undefined,
workspaceId,
depth = 0,
badges,
actions
actions,
customSummary,
onSelect = () => {}
}: Props = $props()
let displayPath: string = (depth === 0 ? path : path?.split('/')?.slice(-1)?.[0]) ?? ''
</script>
<div
style="display: none"
use:triggerableByAI={{
id: aiId,
description: aiDescription,
callback: () => {
goto(href)
}
}}
></div>
{#if href}
<div
style="display: none"
use:triggerableByAI={{
id: aiId,
description: aiDescription,
callback: () => {
goto(href)
}
}}
></div>
{/if}
<div
class={twMerge(
'hover:bg-surface-hover w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-3 border-b last:border-b-0',
depth > 0 ? '!rounded-none' : ''
'w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-3 border-b last:border-b-0',
depth > 0 ? '!rounded-none' : '',
disabled ? 'opacity-25' : 'hover:bg-surface-hover',
selected ? 'bg-surface-accent-selected' : ''
)}
style={depth > 0 ? `padding-left: ${depth * 32}px;` : ''}
>
<a {href} class="min-w-0 grow hover:underline decoration-gray-400 inline-flex items-center gap-4">
<div class="shrink">
<RowIcon {href} {kind} />
</div>
<div class="grow">
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? displayPath : summary}
{/if}
</div>
<div class="text-hint text-3xs truncate text-left font-normal">
{path}
</div>
</div>
</a>
{#if isSelectable}
<input type="checkbox" checked={selected} onchange={onSelect} class="rounded max-w-4 w-full" />
{:else if alignWithSelectable}
<div class="rounded max-w-4 w-full"></div>
{/if}
{#if href}
<a {href} class="min-w-0 grow hover:underline decoration-gray-400 inline-flex items-center gap-4">
{@render rowContent()}
</a>
{:else}
{@render rowContent()}
{/if}
{#if errorHandlerMuted}
<BellOff class="w-8 opacity-60" size={12} fill="currentcolor" />
@@ -91,8 +108,8 @@
</div>
{/if}
{#if canFavorite}
<div class="center-center h-full text-xs font-semibold text-secondary">
{#if canFavorite && kind !== 'resource' && kind !== 'variable'}
<div class="center-center h-full text-xs font-semibold text-secondary w-9">
<Star
{kind}
{path}
@@ -111,3 +128,25 @@
{@render actions?.()}
</div>
</div>
{#snippet rowContent()}
<div class="shrink">
<RowIcon {kind} />
</div>
<div class="grow">
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
{#if customSummary}
{@render customSummary?.()}
{:else}
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? displayPath : summary}
{/if}
{/if}
</div>
<div class="text-hint text-3xs truncate text-left font-normal">
{path}
</div>
</div>
{/snippet}

View File

@@ -1,17 +1,22 @@
<script lang="ts">
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import { Code2, LayoutDashboard } from 'lucide-svelte'
import { Boxes, Code2, DollarSign, LayoutDashboard } from 'lucide-svelte'
export let kind: 'script' | 'flow' | 'app' | 'raw_app'
export let href: string = '#'
export let kind: 'script' | 'flow' | 'app' | 'raw_app' | 'resource' | 'variable'
</script>
<a {href} class="flex justify-center items-center">
<div class="flex justify-center items-center">
{#if kind === 'flow'}
<BarsStaggered size={16} class="text-teal-500" />
{:else if kind === 'app' || kind === 'raw_app'}
<LayoutDashboard size={16} class="text-orange-500" />
{:else if kind === 'script'}
<Code2 size={16} class="text-blue-500" />
{:else if kind === 'variable'}
<DollarSign size={16} class="text-gray-400" />
{:else if kind === 'resource'}
<Boxes size={16} class="text-gray-400" />
{:else}
<div class="w-[16px]"></div>
{/if}
</a>
</div>

View File

@@ -709,14 +709,16 @@
</div>
</div>
{/if}
<AiChatLayout
{children}
noPadding={devOnly}
{isCollapsed}
onMenuOpen={() => {
menuOpen = true
}}
/>
<div class="flex flex-col h-full w-full">
<AiChatLayout
{children}
noPadding={devOnly}
{isCollapsed}
onMenuOpen={() => {
menuOpen = true
}}
/>
</div>
</div>
{:else}
<CenteredModal title="Loading user...">

View File

@@ -34,6 +34,7 @@
import { setQuery } from '$lib/navigation'
import { page } from '$app/stores'
import { goto, replaceState } from '$app/navigation'
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte'
import { onMount, setContext } from 'svelte'
import { tutorialsToDo } from '$lib/stores'
@@ -253,6 +254,7 @@
</DrawerContent>
</Drawer>
<ForkWorkspaceBanner />
<div class="max-w-7xl mx-auto px-4 sm:px-8 md:px-8 h-fit">
{#if $workspaceStore == 'admins'}
<div class="my-4"></div>

View File

@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Compare / Deploy to main workspace' }
}
}

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import CompareWorkspaces from "$lib/components/CompareWorkspaces.svelte"
import { WorkspaceService, type WorkspaceComparison } from "$lib/gen"
import { page } from '$app/state'
import { userWorkspaces } from "$lib/stores"
import { untrack } from "svelte"
import CenteredPage from "$lib/components/CenteredPage.svelte"
import PageHeader from "$lib/components/PageHeader.svelte"
let comparison: WorkspaceComparison | undefined = $state(undefined)
let currentWorkspaceId: string | undefined = $state(page.url.searchParams.get('workspace_id') ?? undefined)
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === currentWorkspaceId))
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
async function checkForChanges() { if (!currentWorkspaceId || !parentWorkspaceId) {
return
}
// loading = true
// error = undefined
try {
// Compare with parent workspace
const result = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: currentWorkspaceId
})
comparison = result
// isVisible = result.summary.total_diffs > 0
} catch (e) {
console.error('Failed to compare workspaces:', e)
// error = 'Failed to check for changes'
// Still show banner if there's an error, but with error message
// isVisible = true
} finally {
// loading = false
}
}
$effect(() => {
[
currentWorkspaceId,
parentWorkspaceId,
];
untrack(() => checkForChanges())
})
</script>
<CenteredPage>
<PageHeader
title="Merge workspaces"
/>
{#if currentWorkspaceId && parentWorkspaceId}
<!-- <WorkspaceComparisonDrawer -->
<!-- {comparison} -->
<!-- sourceWorkspace={currentWorkspaceId} -->
<!-- targetWorkspace={parentWorkspaceId} -->
<!-- on:deployed={() => { -->
<!-- sendUserToast('Changes deployed successfully') -->
<!-- }} -->
<!-- /> -->
<CompareWorkspaces {currentWorkspaceId} {parentWorkspaceId} {comparison}/>
{/if}
{#if !currentWorkspaceId}
No workspace selected
{:else if !parentWorkspaceId}
workspace {currentWorkspaceId} has no parent workspace
{/if}
</CenteredPage>