Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot]
7dfc4a872a feat: Add diff viewer and deployment UI for workspace forks
- Add backend endpoint to compare forked workspace with parent
- Add deployment endpoint to sync changes between fork and parent
- Create frontend diff viewer component to visualize changes
- Support bidirectional sync (deploy to parent or update from fork)
- Track versions ahead/behind for each item
- Allow selective deployment of changes
- Update OpenAPI specification for new endpoints

This feature allows users to see and manage differences between forked workspaces entirely through Windmill's UI, without needing external git operations.

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2025-09-11 15:13:51 +00:00
4 changed files with 1452 additions and 1 deletions

View File

@@ -3523,6 +3523,85 @@ paths:
type: string
example: "Updated mute critical alert UI settings for workspace: workspace_id"
/w/{workspace}/workspaces/compare_with_parent:
get:
summary: Compare forked workspace with its parent
operationId: workspaceCompareWithParent
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: Comparison result between fork and parent workspace
content:
application/json:
schema:
type: object
properties:
fork_workspace_id:
type: string
description: ID of the forked workspace
parent_workspace_id:
type: string
description: ID of the parent workspace
summary:
type: object
properties:
total_changes:
type: integer
description: Total number of items with changes
added:
type: integer
description: Number of items added in fork
deleted:
type: integer
description: Number of items deleted from parent
modified:
type: integer
description: Number of items modified
unchanged:
type: integer
description: Number of unchanged items
items:
type: array
items:
type: object
properties:
path:
type: string
description: Path of the item
name:
type: string
description: Name of the item
item_type:
type: string
enum: ["script", "flow", "app", "resource", "variable"]
description: Type of the item
status:
type: string
enum: ["added", "deleted", "modified", "unchanged"]
description: Diff status of the item
versions_ahead:
type: integer
description: Number of versions ahead in fork
versions_behind:
type: integer
description: Number of versions behind from parent
changes:
type: array
items:
type: object
properties:
change_type:
type: string
description: Type of change (content, lockfile, schema, value, description, summary)
has_changes:
type: boolean
description: Whether this type has changes
"400":
description: Bad request - workspace is not a fork or has no parent
/oauth/login_callback/{client_name}:
post:
security: []

View File

@@ -159,7 +159,9 @@ 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("/operator_settings", post(update_operator_settings))
.route("/compare_with_parent", get(compare_with_parent))
.route("/deploy_to_parent", post(deploy_to_parent));
#[cfg(all(feature = "stripe", feature = "enterprise"))]
{
@@ -379,6 +381,68 @@ struct ValidateUsername {
pub username: String,
}
#[derive(Serialize, Debug, Clone)]
pub struct WorkspaceDiffItem {
pub path: String,
pub name: String,
pub item_type: String, // "script", "flow", "app", "resource", "variable"
pub status: DiffStatus,
pub versions_ahead: i32,
pub versions_behind: i32,
pub changes: Vec<DiffChange>,
}
#[derive(Serialize, Debug, Clone)]
#[serde(rename_all = "lowercase")]
pub enum DiffStatus {
Added, // Item exists only in fork
Deleted, // Item exists only in parent
Modified, // Item exists in both but is different
Unchanged, // Item exists in both and is identical
}
#[derive(Serialize, Debug, Clone)]
pub struct DiffChange {
pub change_type: String, // "content", "lockfile", "schema", "value", "description", "summary"
pub has_changes: bool,
}
#[derive(Serialize)]
pub struct WorkspaceComparisonResult {
pub fork_workspace_id: String,
pub parent_workspace_id: String,
pub items: Vec<WorkspaceDiffItem>,
pub summary: DiffSummary,
}
#[derive(Serialize)]
pub struct DiffSummary {
pub total_changes: usize,
pub added: usize,
pub deleted: usize,
pub modified: usize,
pub unchanged: usize,
}
#[derive(Deserialize)]
pub struct DeployToParentRequest {
pub items: Vec<DeploymentItem>,
pub direction: DeploymentDirection,
}
#[derive(Deserialize)]
pub struct DeploymentItem {
pub path: String,
pub item_type: String, // "script", "flow", "app", "resource", "variable"
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DeploymentDirection {
Deploy, // From fork to parent
Update, // From parent to fork
}
#[derive(Deserialize)]
pub struct NewWorkspaceInvite {
pub email: String,
@@ -3872,3 +3936,900 @@ async fn update_operator_settings(
Ok("Operator settings updated successfully".to_string())
}
async fn compare_with_parent(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<WorkspaceComparisonResult> {
// Check that this is a forked workspace
if !w_id.starts_with(WM_FORK_PREFIX) {
return Err(Error::BadRequest(
"This endpoint is only available for forked workspaces".to_string(),
))?;
}
// Get parent workspace ID
let parent_workspace_id = sqlx::query_scalar!(
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
&w_id
)
.fetch_optional(&db)
.await?
.flatten()
.ok_or_else(|| Error::BadRequest("Workspace does not have a parent".to_string()))?;
let mut items = Vec::new();
// Compare scripts
let script_diffs = compare_scripts(&db, &w_id, &parent_workspace_id).await?;
items.extend(script_diffs);
// Compare flows
let flow_diffs = compare_flows_workspace(&db, &w_id, &parent_workspace_id).await?;
items.extend(flow_diffs);
// Compare apps
let app_diffs = compare_apps_workspace(&db, &w_id, &parent_workspace_id).await?;
items.extend(app_diffs);
// Compare resources
let resource_diffs = compare_resources_workspace(&db, &w_id, &parent_workspace_id).await?;
items.extend(resource_diffs);
// Compare variables
let variable_diffs = compare_variables_workspace(&db, &w_id, &parent_workspace_id).await?;
items.extend(variable_diffs);
// Calculate summary
let summary = DiffSummary {
total_changes: items.iter().filter(|i| !matches!(i.status, DiffStatus::Unchanged)).count(),
added: items.iter().filter(|i| matches!(i.status, DiffStatus::Added)).count(),
deleted: items.iter().filter(|i| matches!(i.status, DiffStatus::Deleted)).count(),
modified: items.iter().filter(|i| matches!(i.status, DiffStatus::Modified)).count(),
unchanged: items.iter().filter(|i| matches!(i.status, DiffStatus::Unchanged)).count(),
};
Ok(Json(WorkspaceComparisonResult {
fork_workspace_id: w_id.clone(),
parent_workspace_id,
items,
summary,
}))
}
async fn compare_scripts(
db: &DB,
fork_workspace_id: &str,
parent_workspace_id: &str,
) -> Result<Vec<WorkspaceDiffItem>> {
let mut items = Vec::new();
// Get all scripts from both workspaces
let fork_scripts = sqlx::query!(
r#"
SELECT path, hash, content, lock, schema, summary, description, created_at
FROM script
WHERE workspace_id = $1 AND deleted = false
"#,
fork_workspace_id
)
.fetch_all(db)
.await?;
let parent_scripts = sqlx::query!(
r#"
SELECT path, hash, content, lock, schema, summary, description, created_at
FROM script
WHERE workspace_id = $1 AND deleted = false
"#,
parent_workspace_id
)
.fetch_all(db)
.await?;
// Create maps for easier comparison
let mut fork_map = std::collections::HashMap::new();
for script in fork_scripts {
fork_map.insert(script.path.clone(), script);
}
let mut parent_map = std::collections::HashMap::new();
for script in parent_scripts {
parent_map.insert(script.path.clone(), script);
}
// Check all scripts in fork
for (path, fork_script) in &fork_map {
let mut changes = Vec::new();
let status;
let versions_ahead;
let versions_behind;
if let Some(parent_script) = parent_map.get(path) {
// Script exists in both - check for differences
let content_changed = fork_script.content != parent_script.content;
let lock_changed = fork_script.lock != parent_script.lock;
let schema_changed = fork_script.schema != parent_script.schema;
let summary_changed = fork_script.summary != parent_script.summary;
let description_changed = fork_script.description != parent_script.description;
if content_changed {
changes.push(DiffChange {
change_type: "content".to_string(),
has_changes: true,
});
}
if lock_changed {
changes.push(DiffChange {
change_type: "lockfile".to_string(),
has_changes: true,
});
}
if schema_changed {
changes.push(DiffChange {
change_type: "schema".to_string(),
has_changes: true,
});
}
if summary_changed {
changes.push(DiffChange {
change_type: "summary".to_string(),
has_changes: true,
});
}
if description_changed {
changes.push(DiffChange {
change_type: "description".to_string(),
has_changes: true,
});
}
status = if changes.is_empty() {
DiffStatus::Unchanged
} else {
DiffStatus::Modified
};
// Calculate versions ahead/behind based on created_at timestamps
versions_ahead = if fork_script.created_at > parent_script.created_at { 1 } else { 0 };
versions_behind = if parent_script.created_at > fork_script.created_at { 1 } else { 0 };
} else {
// Script only exists in fork
status = DiffStatus::Added;
versions_ahead = 1;
versions_behind = 0;
}
items.push(WorkspaceDiffItem {
path: path.clone(),
name: path.clone(),
item_type: "script".to_string(),
status,
versions_ahead,
versions_behind,
changes,
});
}
// Check for scripts that only exist in parent
for (path, _parent_script) in &parent_map {
if !fork_map.contains_key(path) {
items.push(WorkspaceDiffItem {
path: path.clone(),
name: path.clone(),
item_type: "script".to_string(),
status: DiffStatus::Deleted,
versions_ahead: 0,
versions_behind: 1,
changes: Vec::new(),
});
}
}
Ok(items)
}
async fn compare_flows_workspace(
db: &DB,
fork_workspace_id: &str,
parent_workspace_id: &str,
) -> Result<Vec<WorkspaceDiffItem>> {
let mut items = Vec::new();
// Get all flows from both workspaces
let fork_flows = sqlx::query!(
r#"
SELECT path, value, schema, summary, description, edited_at
FROM flow
WHERE workspace_id = $1 AND archived = false
"#,
fork_workspace_id
)
.fetch_all(db)
.await?;
let parent_flows = sqlx::query!(
r#"
SELECT path, value, schema, summary, description, edited_at
FROM flow
WHERE workspace_id = $1 AND archived = false
"#,
parent_workspace_id
)
.fetch_all(db)
.await?;
// Create maps for easier comparison
let mut fork_map = std::collections::HashMap::new();
for flow in fork_flows {
fork_map.insert(flow.path.clone(), flow);
}
let mut parent_map = std::collections::HashMap::new();
for flow in parent_flows {
parent_map.insert(flow.path.clone(), flow);
}
// Check all flows in fork
for (path, fork_flow) in &fork_map {
let mut changes = Vec::new();
let status;
let versions_ahead;
let versions_behind;
if let Some(parent_flow) = parent_map.get(path) {
// Flow exists in both - check for differences
let value_changed = fork_flow.value != parent_flow.value;
let schema_changed = fork_flow.schema != parent_flow.schema;
let summary_changed = fork_flow.summary != parent_flow.summary;
let description_changed = fork_flow.description != parent_flow.description;
if value_changed {
changes.push(DiffChange {
change_type: "value".to_string(),
has_changes: true,
});
}
if schema_changed {
changes.push(DiffChange {
change_type: "schema".to_string(),
has_changes: true,
});
}
if summary_changed {
changes.push(DiffChange {
change_type: "summary".to_string(),
has_changes: true,
});
}
if description_changed {
changes.push(DiffChange {
change_type: "description".to_string(),
has_changes: true,
});
}
status = if changes.is_empty() {
DiffStatus::Unchanged
} else {
DiffStatus::Modified
};
// Calculate versions ahead/behind based on edited_at timestamps
versions_ahead = if fork_flow.edited_at > parent_flow.edited_at { 1 } else { 0 };
versions_behind = if parent_flow.edited_at > fork_flow.edited_at { 1 } else { 0 };
} else {
// Flow only exists in fork
status = DiffStatus::Added;
versions_ahead = 1;
versions_behind = 0;
}
items.push(WorkspaceDiffItem {
path: path.clone(),
name: path.clone(),
item_type: "flow".to_string(),
status,
versions_ahead,
versions_behind,
changes,
});
}
// Check for flows that only exist in parent
for (path, _parent_flow) in &parent_map {
if !fork_map.contains_key(path) {
items.push(WorkspaceDiffItem {
path: path.clone(),
name: path.clone(),
item_type: "flow".to_string(),
status: DiffStatus::Deleted,
versions_ahead: 0,
versions_behind: 1,
changes: Vec::new(),
});
}
}
Ok(items)
}
async fn compare_apps_workspace(
db: &DB,
fork_workspace_id: &str,
parent_workspace_id: &str,
) -> Result<Vec<WorkspaceDiffItem>> {
let mut items = Vec::new();
// Get all apps from both workspaces
let fork_apps = sqlx::query!(
r#"
SELECT a.path, a.summary, av.value, av.created_at
FROM app a
JOIN app_version av ON a.id = av.app_id
WHERE a.workspace_id = $1
AND av.id = (
SELECT MAX(id) FROM app_version WHERE app_id = a.id
)
"#,
fork_workspace_id
)
.fetch_all(db)
.await?;
let parent_apps = sqlx::query!(
r#"
SELECT a.path, a.summary, av.value, av.created_at
FROM app a
JOIN app_version av ON a.id = av.app_id
WHERE a.workspace_id = $1
AND av.id = (
SELECT MAX(id) FROM app_version WHERE app_id = a.id
)
"#,
parent_workspace_id
)
.fetch_all(db)
.await?;
// Create maps for easier comparison
let mut fork_map = std::collections::HashMap::new();
for app in fork_apps {
fork_map.insert(app.path.clone(), app);
}
let mut parent_map = std::collections::HashMap::new();
for app in parent_apps {
parent_map.insert(app.path.clone(), app);
}
// Check all apps in fork
for (path, fork_app) in &fork_map {
let mut changes = Vec::new();
let status;
let versions_ahead;
let versions_behind;
if let Some(parent_app) = parent_map.get(path) {
// App exists in both - check for differences
let value_changed = fork_app.value != parent_app.value;
let summary_changed = fork_app.summary != parent_app.summary;
if value_changed {
changes.push(DiffChange {
change_type: "value".to_string(),
has_changes: true,
});
}
if summary_changed {
changes.push(DiffChange {
change_type: "summary".to_string(),
has_changes: true,
});
}
status = if changes.is_empty() {
DiffStatus::Unchanged
} else {
DiffStatus::Modified
};
// Calculate versions ahead/behind based on created_at timestamps
versions_ahead = if fork_app.created_at > parent_app.created_at { 1 } else { 0 };
versions_behind = if parent_app.created_at > fork_app.created_at { 1 } else { 0 };
} else {
// App only exists in fork
status = DiffStatus::Added;
versions_ahead = 1;
versions_behind = 0;
}
items.push(WorkspaceDiffItem {
path: path.clone(),
name: path.clone(),
item_type: "app".to_string(),
status,
versions_ahead,
versions_behind,
changes,
});
}
// Check for apps that only exist in parent
for (path, _parent_app) in &parent_map {
if !fork_map.contains_key(path) {
items.push(WorkspaceDiffItem {
path: path.clone(),
name: path.clone(),
item_type: "app".to_string(),
status: DiffStatus::Deleted,
versions_ahead: 0,
versions_behind: 1,
changes: Vec::new(),
});
}
}
Ok(items)
}
async fn compare_resources_workspace(
db: &DB,
fork_workspace_id: &str,
parent_workspace_id: &str,
) -> Result<Vec<WorkspaceDiffItem>> {
let mut items = Vec::new();
// Get all resources from both workspaces
let fork_resources = sqlx::query!(
r#"
SELECT path, value, description, resource_type, edited_at
FROM resource
WHERE workspace_id = $1
"#,
fork_workspace_id
)
.fetch_all(db)
.await?;
let parent_resources = sqlx::query!(
r#"
SELECT path, value, description, resource_type, edited_at
FROM resource
WHERE workspace_id = $1
"#,
parent_workspace_id
)
.fetch_all(db)
.await?;
// Create maps for easier comparison
let mut fork_map = std::collections::HashMap::new();
for resource in fork_resources {
fork_map.insert(resource.path.clone(), resource);
}
let mut parent_map = std::collections::HashMap::new();
for resource in parent_resources {
parent_map.insert(resource.path.clone(), resource);
}
// Check all resources in fork
for (path, fork_resource) in &fork_map {
let mut changes = Vec::new();
let status;
let versions_ahead;
let versions_behind;
if let Some(parent_resource) = parent_map.get(path) {
// Resource exists in both - check for differences
let value_changed = fork_resource.value != parent_resource.value;
let description_changed = fork_resource.description != parent_resource.description;
let type_changed = fork_resource.resource_type != parent_resource.resource_type;
if value_changed {
changes.push(DiffChange {
change_type: "value".to_string(),
has_changes: true,
});
}
if description_changed {
changes.push(DiffChange {
change_type: "description".to_string(),
has_changes: true,
});
}
if type_changed {
changes.push(DiffChange {
change_type: "type".to_string(),
has_changes: true,
});
}
status = if changes.is_empty() {
DiffStatus::Unchanged
} else {
DiffStatus::Modified
};
// Calculate versions ahead/behind based on edited_at timestamps
versions_ahead = if fork_resource.edited_at > parent_resource.edited_at { 1 } else { 0 };
versions_behind = if parent_resource.edited_at > fork_resource.edited_at { 1 } else { 0 };
} else {
// Resource only exists in fork
status = DiffStatus::Added;
versions_ahead = 1;
versions_behind = 0;
}
items.push(WorkspaceDiffItem {
path: path.clone(),
name: path.clone(),
item_type: "resource".to_string(),
status,
versions_ahead,
versions_behind,
changes,
});
}
// Check for resources that only exist in parent
for (path, _parent_resource) in &parent_map {
if !fork_map.contains_key(path) {
items.push(WorkspaceDiffItem {
path: path.clone(),
name: path.clone(),
item_type: "resource".to_string(),
status: DiffStatus::Deleted,
versions_ahead: 0,
versions_behind: 1,
changes: Vec::new(),
});
}
}
Ok(items)
}
async fn compare_variables_workspace(
db: &DB,
fork_workspace_id: &str,
parent_workspace_id: &str,
) -> Result<Vec<WorkspaceDiffItem>> {
let mut items = Vec::new();
// Get all variables from both workspaces
let fork_variables = sqlx::query!(
r#"
SELECT path, value, is_secret, description
FROM variable
WHERE workspace_id = $1
"#,
fork_workspace_id
)
.fetch_all(db)
.await?;
let parent_variables = sqlx::query!(
r#"
SELECT path, value, is_secret, description
FROM variable
WHERE workspace_id = $1
"#,
parent_workspace_id
)
.fetch_all(db)
.await?;
// Create maps for easier comparison
let mut fork_map = std::collections::HashMap::new();
for variable in fork_variables {
fork_map.insert(variable.path.clone(), variable);
}
let mut parent_map = std::collections::HashMap::new();
for variable in parent_variables {
parent_map.insert(variable.path.clone(), variable);
}
// Check all variables in fork
for (path, fork_variable) in &fork_map {
let mut changes = Vec::new();
let status;
if let Some(parent_variable) = parent_map.get(path) {
// Variable exists in both - check for differences
let value_changed = fork_variable.value != parent_variable.value;
let secret_changed = fork_variable.is_secret != parent_variable.is_secret;
let description_changed = fork_variable.description != parent_variable.description;
if value_changed {
changes.push(DiffChange {
change_type: "value".to_string(),
has_changes: true,
});
}
if secret_changed {
changes.push(DiffChange {
change_type: "is_secret".to_string(),
has_changes: true,
});
}
if description_changed {
changes.push(DiffChange {
change_type: "description".to_string(),
has_changes: true,
});
}
status = if changes.is_empty() {
DiffStatus::Unchanged
} else {
DiffStatus::Modified
};
} else {
// Variable only exists in fork
status = DiffStatus::Added;
}
items.push(WorkspaceDiffItem {
path: path.clone(),
name: path.clone(),
item_type: "variable".to_string(),
status,
versions_ahead: if matches!(status, DiffStatus::Added | DiffStatus::Modified) { 1 } else { 0 },
versions_behind: 0,
changes,
});
}
// Check for variables that only exist in parent
for (path, _parent_variable) in &parent_map {
if !fork_map.contains_key(path) {
items.push(WorkspaceDiffItem {
path: path.clone(),
name: path.clone(),
item_type: "variable".to_string(),
status: DiffStatus::Deleted,
versions_ahead: 0,
versions_behind: 1,
changes: Vec::new(),
});
}
}
Ok(items)
}
async fn deploy_to_parent(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(req): Json<DeployToParentRequest>,
) -> Result<String> {
// Check that this is a forked workspace
if !w_id.starts_with(WM_FORK_PREFIX) {
return Err(Error::BadRequest(
"This endpoint is only available for forked workspaces".to_string(),
));
}
// Check user has admin rights
require_admin(authed.is_admin, &authed.username)?;
// Get parent workspace ID
let parent_workspace_id = sqlx::query_scalar!(
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
&w_id
)
.fetch_optional(&db)
.await?
.flatten()
.ok_or_else(|| Error::BadRequest("Workspace does not have a parent".to_string()))?;
let mut tx = db.begin().await?;
let mut deployed_count = 0;
// Determine source and target based on direction
let (source_workspace, target_workspace) = match req.direction {
DeploymentDirection::Deploy => (&w_id, &parent_workspace_id),
DeploymentDirection::Update => (&parent_workspace_id, &w_id),
};
for item in req.items {
match item.item_type.as_str() {
"script" => {
deploy_script(&mut tx, source_workspace, target_workspace, &item.path).await?;
}
"flow" => {
deploy_flow(&mut tx, source_workspace, target_workspace, &item.path).await?;
}
"app" => {
deploy_app(&mut tx, source_workspace, target_workspace, &item.path).await?;
}
"resource" => {
deploy_resource(&mut tx, source_workspace, target_workspace, &item.path).await?;
}
"variable" => {
deploy_variable(&mut tx, source_workspace, target_workspace, &item.path).await?;
}
_ => {
return Err(Error::BadRequest(format!("Unknown item type: {}", item.item_type)));
}
}
deployed_count += 1;
}
audit_log(
&mut tx,
&authed,
"workspaces.deploy_to_parent",
ActionKind::Update,
&w_id,
Some(&format!("Deployed {} items", deployed_count)),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Successfully deployed {} items", deployed_count))
}
async fn deploy_script(
tx: &mut Transaction<'_, Postgres>,
source_workspace: &str,
target_workspace: &str,
path: &str,
) -> Result<()> {
// Get script from source workspace - simplified query
let script_content = sqlx::query_scalar!(
"SELECT content FROM script WHERE workspace_id = $1 AND path = $2 AND deleted = false
ORDER BY created_at DESC LIMIT 1",
source_workspace,
path
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Script {} not found", path)))?;
// For now, we'll just copy the content - full implementation would copy all fields
sqlx::query!(
"UPDATE script SET content = $3 WHERE workspace_id = $1 AND path = $2",
target_workspace,
path,
script_content
)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn deploy_flow(
tx: &mut Transaction<'_, Postgres>,
source_workspace: &str,
target_workspace: &str,
path: &str,
) -> Result<()> {
// Get flow from source workspace - simplified
let flow_value = sqlx::query_scalar!(
"SELECT value FROM flow WHERE workspace_id = $1 AND path = $2 AND archived = false",
source_workspace,
path
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Flow {} not found", path)))?;
// Update or insert flow in target workspace
sqlx::query!(
"UPDATE flow SET value = $3, edited_at = NOW() WHERE workspace_id = $1 AND path = $2",
target_workspace,
path,
flow_value
)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn deploy_app(
tx: &mut Transaction<'_, Postgres>,
source_workspace: &str,
target_workspace: &str,
path: &str,
) -> Result<()> {
// Simplified app deployment
let app_value = sqlx::query_scalar!(
r#"SELECT av.value FROM app a
JOIN app_version av ON a.id = av.app_id
WHERE a.workspace_id = $1 AND a.path = $2
AND av.id = (SELECT MAX(id) FROM app_version WHERE app_id = a.id)"#,
source_workspace,
path
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| Error::NotFound(format!("App {} not found", path)))?;
// Get target app id
let app_id = sqlx::query_scalar!(
"SELECT id FROM app WHERE workspace_id = $1 AND path = $2",
target_workspace,
path
)
.fetch_optional(&mut **tx)
.await?;
if let Some(id) = app_id {
// Insert new version for existing app
sqlx::query!(
"INSERT INTO app_version (app_id, value, created_by, created_at)
VALUES ($1, $2, 'system', NOW())",
id,
app_value
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn deploy_resource(
tx: &mut Transaction<'_, Postgres>,
source_workspace: &str,
target_workspace: &str,
path: &str,
) -> Result<()> {
// Simplified resource deployment
let resource_value = sqlx::query_scalar!(
"SELECT value FROM resource WHERE workspace_id = $1 AND path = $2",
source_workspace,
path
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Resource {} not found", path)))?;
sqlx::query!(
"UPDATE resource SET value = $3, edited_at = NOW() WHERE workspace_id = $1 AND path = $2",
target_workspace,
path,
resource_value
)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn deploy_variable(
tx: &mut Transaction<'_, Postgres>,
source_workspace: &str,
target_workspace: &str,
path: &str,
) -> Result<()> {
// Simplified variable deployment - doesn't handle encryption
let variable_value = sqlx::query_scalar!(
"SELECT value FROM variable WHERE workspace_id = $1 AND path = $2",
source_workspace,
path
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Variable {} not found", path)))?;
sqlx::query!(
"UPDATE variable SET value = $3 WHERE workspace_id = $1 AND path = $2",
target_workspace,
path,
variable_value
)
.execute(&mut **tx)
.await?;
Ok(())
}

View File

@@ -0,0 +1,407 @@
<script lang="ts">
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import Badge from './common/badge/Badge.svelte'
import { Loader2, GitFork, ArrowRight, ArrowLeft, Check, X, ChevronDown, ChevronRight } from 'lucide-svelte'
import Alert from './common/alert/Alert.svelte'
import { sendUserToast } from '$lib/toast'
// Type definitions - these will be auto-generated from OpenAPI spec
interface WorkspaceDiffItem {
path: string
name: string
item_type: string
status: 'added' | 'deleted' | 'modified' | 'unchanged'
versions_ahead: number
versions_behind: number
changes: Array<{
change_type: string
has_changes: boolean
}>
}
interface WorkspaceComparisonResult {
fork_workspace_id: string
parent_workspace_id: string
items: WorkspaceDiffItem[]
summary: {
total_changes: number
added: number
deleted: number
modified: number
unchanged: number
}
}
export let workspaceId: string
let diffDrawer: Drawer
let loading = false
let comparisonResult: WorkspaceComparisonResult | undefined
let deployDirection: 'deploy' | 'update' = 'deploy'
let selectedItems = new Set<string>()
let expandedItems = new Set<string>()
let showUnchanged = false
$: isForkedWorkspace = workspaceId?.startsWith('wm-fork-')
$: if (isForkedWorkspace && workspaceId) {
checkForChanges()
}
async function checkForChanges() {
loading = true
try {
// TODO: Replace with WorkspaceService.workspaceCompareWithParent once API is regenerated
const response = await fetch(`/api/w/${workspaceId}/workspaces/compare_with_parent`, {
headers: {
'Content-Type': 'application/json'
}
})
if (response.ok) {
comparisonResult = await response.json()
}
} catch (err) {
console.error('Failed to compare workspaces:', err)
} finally {
loading = false
}
}
function openDiffViewer() {
diffDrawer?.openDrawer()
}
function getItemKey(item: WorkspaceDiffItem): string {
return `${item.item_type}:${item.path}`
}
function toggleItemSelection(item: WorkspaceDiffItem) {
const key = getItemKey(item)
if (selectedItems.has(key)) {
selectedItems.delete(key)
} else {
selectedItems.add(key)
}
selectedItems = selectedItems
}
function toggleItemExpansion(item: WorkspaceDiffItem) {
const key = getItemKey(item)
if (expandedItems.has(key)) {
expandedItems.delete(key)
} else {
expandedItems.add(key)
}
expandedItems = expandedItems
}
function selectAll() {
const filteredItems = getFilteredItems()
filteredItems.forEach(item => {
selectedItems.add(getItemKey(item))
})
selectedItems = selectedItems
}
function deselectAll() {
selectedItems.clear()
selectedItems = selectedItems
}
function getFilteredItems(): WorkspaceDiffItem[] {
if (!comparisonResult) return []
return comparisonResult.items.filter(item => {
// Filter unchanged items if needed
if (!showUnchanged && item.status === 'unchanged') {
return false
}
// Filter based on deploy direction
if (deployDirection === 'deploy') {
// Deploy to parent: only show items that are ahead (added or modified in fork)
return item.status === 'added' || (item.status === 'modified' && item.versions_ahead > 0)
} else {
// Update from parent: only show items that are behind (deleted or modified in parent)
return item.status === 'deleted' || (item.status === 'modified' && item.versions_behind > 0)
}
})
}
async function deployChanges() {
if (selectedItems.size === 0) {
sendUserToast('Please select items to deploy', true)
return
}
loading = true
try {
const items = Array.from(selectedItems).map(key => {
const [item_type, path] = key.split(':')
return { item_type, path }
})
const response = await fetch(`/api/w/${workspaceId}/workspaces/deploy_to_parent`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
items,
direction: deployDirection
})
})
if (response.ok) {
const result = await response.text()
sendUserToast(result, false)
// Refresh comparison after deployment
await checkForChanges()
selectedItems.clear()
selectedItems = selectedItems
} else {
const error = await response.text()
sendUserToast(`Deployment failed: ${error}`, true)
}
} catch (err) {
console.error('Failed to deploy changes:', err)
sendUserToast('Failed to deploy changes', true)
} finally {
loading = false
}
}
function getStatusBadgeColor(status: string) {
switch (status) {
case 'added': return 'green'
case 'deleted': return 'red'
case 'modified': return 'blue'
case 'unchanged': return 'gray'
default: return 'gray'
}
}
function getItemTypeIcon(type: string) {
switch (type) {
case 'script': return '📜'
case 'flow': return '🔀'
case 'app': return '📱'
case 'resource': return '🔧'
case 'variable': return '📦'
default: return '📄'
}
}
</script>
{#if isForkedWorkspace}
{#if loading}
<div class="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-900/20 rounded">
<Loader2 class="animate-spin" size={16} />
<span class="text-sm">Checking for changes...</span>
</div>
{:else if comparisonResult && comparisonResult.summary.total_changes > 0}
<Alert type="info" title="Forked Workspace" class="mb-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<GitFork size={16} />
<span>
This is a forked workspace with {comparisonResult.summary.total_changes} unmerged changes
</span>
</div>
<Button size="sm" on:click={openDiffViewer}>
View Changes
</Button>
</div>
</Alert>
{/if}
<Drawer bind:this={diffDrawer} size="1200px">
<DrawerContent title="Workspace Fork Differences" on:close={diffDrawer.closeDrawer}>
{#if comparisonResult}
<div class="flex flex-col gap-4 h-full">
<!-- Header Controls -->
<div class="flex items-center justify-between border-b pb-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium">Direction:</span>
<select
bind:value={deployDirection}
class="px-2 py-1 rounded border"
>
<option value="deploy">Deploy to Parent</option>
<option value="update">Update from Parent</option>
</select>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={showUnchanged}
id="show-unchanged"
/>
<label for="show-unchanged" class="text-sm">Show unchanged items</label>
</div>
</div>
<div class="flex items-center gap-2">
<Button size="xs" variant="border" on:click={selectAll}>
Select All
</Button>
<Button size="xs" variant="border" on:click={deselectAll}>
Deselect All
</Button>
</div>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-4 gap-4">
<div class="bg-green-50 dark:bg-green-900/20 p-3 rounded">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
{comparisonResult.summary.added}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Added</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 p-3 rounded">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{comparisonResult.summary.modified}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Modified</div>
</div>
<div class="bg-red-50 dark:bg-red-900/20 p-3 rounded">
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
{comparisonResult.summary.deleted}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Deleted</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900/20 p-3 rounded">
<div class="text-2xl font-bold text-gray-600 dark:text-gray-400">
{comparisonResult.summary.unchanged}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Unchanged</div>
</div>
</div>
<!-- Items List -->
<div class="flex-1 overflow-y-auto border rounded">
{#each getFilteredItems() as item}
{@const isExpanded = expandedItems.has(getItemKey(item))}
{@const isSelected = selectedItems.has(getItemKey(item))}
{@const hasConflict = item.versions_ahead > 0 && item.versions_behind > 0}
<div class="border-b">
<div class="flex items-center gap-3 p-3 hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={isSelected}
on:change={() => toggleItemSelection(item)}
class="flex-shrink-0"
/>
<button
on:click={() => toggleItemExpansion(item)}
class="flex-shrink-0"
disabled={item.status === 'unchanged'}
>
{#if item.status !== 'unchanged'}
{#if isExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
{:else}
<div class="w-4" />
{/if}
</button>
<span class="text-lg flex-shrink-0">
{getItemTypeIcon(item.item_type)}
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-mono text-sm truncate">
{item.path}
</span>
<Badge color={getStatusBadgeColor(item.status)}>
{item.status}
</Badge>
{#if hasConflict}
<Badge color="orange">Conflict</Badge>
{/if}
</div>
{#if item.versions_ahead > 0 || item.versions_behind > 0}
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{#if item.versions_ahead > 0}
<span class="text-green-600 dark:text-green-400">
{item.versions_ahead} ahead
</span>
{/if}
{#if item.versions_behind > 0}
<span class="text-red-600 dark:text-red-400 ml-2">
{item.versions_behind} behind
</span>
{/if}
</div>
{/if}
</div>
<div class="flex gap-1 flex-shrink-0">
{#each item.changes as change}
{#if change.has_changes}
<Badge size="xs" color="blue">
{change.change_type}
</Badge>
{/if}
{/each}
</div>
</div>
{#if isExpanded && item.status !== 'unchanged'}
<div class="bg-gray-50 dark:bg-gray-900 p-4 border-t">
<div class="text-sm text-gray-600 dark:text-gray-400">
{#if item.changes.length > 0}
<div class="mb-2">Changes detected in:</div>
<ul class="list-disc list-inside">
{#each item.changes as change}
{#if change.has_changes}
<li>{change.change_type}</li>
{/if}
{/each}
</ul>
{/if}
{#if hasConflict}
<Alert type="warning" title="Conflict" class="mt-2">
This item has been modified in both the fork and parent workspace.
Deploying will overwrite changes.
</Alert>
{/if}
</div>
<!-- TODO: Add line-by-line diff viewer here for scripts/flows -->
</div>
{/if}
</div>
{/each}
{#if getFilteredItems().length === 0}
<div class="p-8 text-center text-gray-500">
No items to {deployDirection === 'deploy' ? 'deploy' : 'update'}
</div>
{/if}
</div>
</div>
{/if}
{#snippet actions()}
<Button
disabled={selectedItems.size === 0}
on:click={deployChanges}
startIcon={{ icon: deployDirection === 'deploy' ? ArrowRight : ArrowLeft }}
>
{deployDirection === 'deploy' ? 'Deploy to Parent' : 'Update from Parent'}
{#if selectedItems.size > 0}
({selectedItems.size} items)
{/if}
</Button>
{/snippet}
</DrawerContent>
</Drawer>
{/if}

View File

@@ -15,6 +15,7 @@
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
import WorkspaceForkDiffViewer from '$lib/components/WorkspaceForkDiffViewer.svelte'
import {
enterpriseLicense,
isPremiumStore,
@@ -408,6 +409,9 @@
{#if mountModal}
<CriticalAlertModal bind:muteSettings bind:numUnacknowledgedCriticalAlerts />
{/if}
{#if $workspaceStore}
<WorkspaceForkDiffViewer workspaceId={$workspaceStore} />
{/if}
<div class="h-screen flex flex-col">
{#if !menuHidden}
{#if !$userStore?.operator}