Compare commits
1 Commits
main
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dfc4a872a |
@@ -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: []
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
407
frontend/src/lib/components/WorkspaceForkDiffViewer.svelte
Normal file
407
frontend/src/lib/components/WorkspaceForkDiffViewer.svelte
Normal 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}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user