Compare commits
2 Commits
v1.682.0
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
508d1f2383 | ||
|
|
9474cbcb70 |
@@ -1920,6 +1920,29 @@ 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"
|
||||
|
||||
/users/exists/{email}:
|
||||
get:
|
||||
summary: exists email
|
||||
@@ -19596,6 +19619,116 @@ components:
|
||||
type: boolean
|
||||
description: Whether operators can view workers page
|
||||
|
||||
WorkspaceComparison:
|
||||
type: object
|
||||
required:
|
||||
- source_workspace_id
|
||||
- target_workspace_id
|
||||
- is_fork
|
||||
- diffs
|
||||
- summary
|
||||
properties:
|
||||
source_workspace_id:
|
||||
type: string
|
||||
description: The ID of the source workspace
|
||||
target_workspace_id:
|
||||
type: string
|
||||
description: The ID of the target workspace
|
||||
is_fork:
|
||||
type: boolean
|
||||
description: Whether the workspaces have a parent-child relationship
|
||||
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
|
||||
- versions_ahead
|
||||
- versions_behind
|
||||
- has_changes
|
||||
- metadata_changes
|
||||
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
|
||||
versions_ahead:
|
||||
type: integer
|
||||
description: Number of versions source is ahead of target
|
||||
versions_behind:
|
||||
type: integer
|
||||
description: Number of versions source is behind target
|
||||
has_changes:
|
||||
type: boolean
|
||||
description: Whether the item has any differences
|
||||
source_hash:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Hash/ID of the source version
|
||||
target_hash:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Hash/ID of the target version
|
||||
source_version:
|
||||
type: integer
|
||||
format: int64
|
||||
nullable: true
|
||||
description: Version number in source workspace
|
||||
target_version:
|
||||
type: integer
|
||||
format: int64
|
||||
nullable: true
|
||||
description: Version number in target workspace
|
||||
metadata_changes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: List of changed metadata fields (content, summary, description, etc.)
|
||||
|
||||
CompareSummary:
|
||||
type: object
|
||||
required:
|
||||
- total_diffs
|
||||
- scripts_changed
|
||||
- flows_changed
|
||||
- apps_changed
|
||||
- resources_changed
|
||||
- variables_changed
|
||||
- conflicts
|
||||
properties:
|
||||
total_diffs:
|
||||
type: integer
|
||||
description: Total number of items with differences
|
||||
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:
|
||||
|
||||
@@ -163,7 +163,8 @@ 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/:target_workspace_id", get(compare_workspaces_mock));
|
||||
|
||||
#[cfg(all(feature = "stripe", feature = "enterprise"))]
|
||||
{
|
||||
@@ -3807,3 +3808,669 @@ async fn update_operator_settings(
|
||||
|
||||
Ok("Operator settings updated successfully".to_string())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct WorkspaceItemDiff {
|
||||
pub kind: String,
|
||||
pub path: String,
|
||||
pub versions_ahead: i32,
|
||||
pub versions_behind: i32,
|
||||
pub has_changes: bool,
|
||||
pub source_hash: Option<String>,
|
||||
pub target_hash: Option<String>,
|
||||
pub source_version: Option<i64>,
|
||||
pub target_version: Option<i64>,
|
||||
pub metadata_changes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WorkspaceComparison {
|
||||
pub source_workspace_id: String,
|
||||
pub target_workspace_id: String,
|
||||
pub is_fork: bool,
|
||||
pub diffs: Vec<WorkspaceItemDiff>,
|
||||
pub summary: CompareSummary,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CompareSummary {
|
||||
pub total_diffs: 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
|
||||
}
|
||||
|
||||
// Mock implementation until sqlx prepare can be run
|
||||
async fn compare_workspaces_mock(
|
||||
authed: ApiAuthed,
|
||||
Path((w_id, target_workspace_id)): Path<(String, String)>,
|
||||
Extension(_db): Extension<DB>,
|
||||
) -> JsonResult<WorkspaceComparison> {
|
||||
// Check permissions for source workspace
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
// Return empty comparison for now
|
||||
// TODO: Replace with actual implementation once sqlx prepare is run
|
||||
Ok(Json(WorkspaceComparison {
|
||||
source_workspace_id: w_id,
|
||||
target_workspace_id,
|
||||
is_fork: false,
|
||||
diffs: Vec::new(),
|
||||
summary: CompareSummary {
|
||||
total_diffs: 0,
|
||||
scripts_changed: 0,
|
||||
flows_changed: 0,
|
||||
apps_changed: 0,
|
||||
resources_changed: 0,
|
||||
variables_changed: 0,
|
||||
conflicts: 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// TODO: Enable once sqlx prepare is run - full implementation with database queries
|
||||
// The functions below contain the complete implementation but are commented out
|
||||
// because they use sqlx compile-time checked queries that require `cargo sqlx prepare`
|
||||
// to be run with a database connection.
|
||||
|
||||
/*
|
||||
async fn compare_workspaces(
|
||||
authed: ApiAuthed,
|
||||
Path((w_id, target_workspace_id)): Path<(String, String)>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<WorkspaceComparison> {
|
||||
// Check permissions for source workspace
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
// Check if workspaces have parent-child relationship
|
||||
let is_fork: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM workspace
|
||||
WHERE (id = $1 AND parent_workspace_id = $2)
|
||||
OR (id = $2 AND parent_workspace_id = $1)
|
||||
)"
|
||||
)
|
||||
.bind(&w_id)
|
||||
.bind(&target_workspace_id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
let mut diffs = Vec::new();
|
||||
|
||||
// Compare scripts
|
||||
let script_diffs = compare_scripts(&db, &w_id, &target_workspace_id).await?;
|
||||
diffs.extend(script_diffs);
|
||||
|
||||
// Compare flows
|
||||
let flow_diffs = compare_flows(&db, &w_id, &target_workspace_id).await?;
|
||||
diffs.extend(flow_diffs);
|
||||
|
||||
// Compare apps
|
||||
let app_diffs = compare_apps(&db, &w_id, &target_workspace_id).await?;
|
||||
diffs.extend(app_diffs);
|
||||
|
||||
// Compare resources
|
||||
let resource_diffs = compare_resources(&db, &w_id, &target_workspace_id).await?;
|
||||
diffs.extend(resource_diffs);
|
||||
|
||||
// Compare variables
|
||||
let variable_diffs = compare_variables(&db, &w_id, &target_workspace_id).await?;
|
||||
diffs.extend(variable_diffs);
|
||||
|
||||
// Calculate summary
|
||||
let summary = CompareSummary {
|
||||
total_diffs: diffs.len(),
|
||||
scripts_changed: diffs.iter().filter(|d| d.kind == "script").count(),
|
||||
flows_changed: diffs.iter().filter(|d| d.kind == "flow").count(),
|
||||
apps_changed: diffs.iter().filter(|d| d.kind == "app").count(),
|
||||
resources_changed: diffs.iter().filter(|d| d.kind == "resource").count(),
|
||||
variables_changed: diffs.iter().filter(|d| d.kind == "variable").count(),
|
||||
conflicts: diffs.iter().filter(|d| d.versions_ahead > 0 && d.versions_behind > 0).count(),
|
||||
};
|
||||
|
||||
Ok(Json(WorkspaceComparison {
|
||||
source_workspace_id: w_id,
|
||||
target_workspace_id,
|
||||
is_fork,
|
||||
diffs,
|
||||
summary,
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn compare_scripts(
|
||||
db: &DB,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
) -> Result<Vec<WorkspaceItemDiff>> {
|
||||
let mut diffs = Vec::new();
|
||||
|
||||
// Get all unique script paths from both workspaces
|
||||
let all_paths = sqlx::query!(
|
||||
"SELECT DISTINCT path FROM (
|
||||
SELECT path FROM script WHERE workspace_id = $1 AND deleted = false
|
||||
UNION
|
||||
SELECT path FROM script WHERE workspace_id = $2 AND deleted = false
|
||||
) AS paths",
|
||||
source_workspace_id,
|
||||
target_workspace_id
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
for (path,) in all_paths {
|
||||
|
||||
// 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 deleted = 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 deleted = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
target_workspace_id,
|
||||
&path
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
// Count versions in each workspace
|
||||
let source_version_count = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM script
|
||||
WHERE workspace_id = $1 AND path = $2 AND deleted = false",
|
||||
source_workspace_id,
|
||||
&path
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
let target_version_count = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM script
|
||||
WHERE workspace_id = $1 AND path = $2 AND deleted = false",
|
||||
target_workspace_id,
|
||||
&path
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut metadata_changes = Vec::new();
|
||||
let mut has_changes = false;
|
||||
|
||||
if let (Some(source), Some(target)) = (&source_script, &target_script) {
|
||||
if source.content != target.content {
|
||||
metadata_changes.push("content".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
if source.summary != target.summary {
|
||||
metadata_changes.push("summary".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
if source.description != target.description {
|
||||
metadata_changes.push("description".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
if source.lock != target.lock {
|
||||
metadata_changes.push("lockfile".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
if source.schema != target.schema {
|
||||
metadata_changes.push("schema".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
} else if source_script.is_some() || target_script.is_some() {
|
||||
has_changes = true;
|
||||
if source_script.is_none() {
|
||||
metadata_changes.push("only_in_target".to_string());
|
||||
} else {
|
||||
metadata_changes.push("only_in_source".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if has_changes {
|
||||
diffs.push(WorkspaceItemDiff {
|
||||
kind: "script".to_string(),
|
||||
path: path.clone(),
|
||||
versions_ahead: (source_version_count - target_version_count).max(0) as i32,
|
||||
versions_behind: (target_version_count - source_version_count).max(0) as i32,
|
||||
has_changes,
|
||||
source_hash: source_script.as_ref().map(|s| s.hash.to_string()),
|
||||
target_hash: target_script.as_ref().map(|s| s.hash.to_string()),
|
||||
source_version: None,
|
||||
target_version: None,
|
||||
metadata_changes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn compare_flows(
|
||||
db: &DB,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
) -> Result<Vec<WorkspaceItemDiff>> {
|
||||
let mut diffs = Vec::new();
|
||||
|
||||
// Get all unique flow paths from both workspaces
|
||||
let all_paths = sqlx::query!(
|
||||
"SELECT DISTINCT path FROM (
|
||||
SELECT path FROM flow WHERE workspace_id = $1 AND archived = false
|
||||
UNION
|
||||
SELECT path FROM flow WHERE workspace_id = $2 AND archived = false
|
||||
) AS paths",
|
||||
source_workspace_id,
|
||||
target_workspace_id
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
for (path,) in all_paths {
|
||||
|
||||
// Get latest flow version from each workspace
|
||||
let source_flow = sqlx::query!(
|
||||
"SELECT f.versions[array_length(f.versions, 1)] as latest_version,
|
||||
f.value, f.summary, f.description, f.schema
|
||||
FROM flow f
|
||||
WHERE f.workspace_id = $1 AND f.path = $2 AND f.archived = false",
|
||||
source_workspace_id,
|
||||
&path
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let target_flow = sqlx::query!(
|
||||
"SELECT f.versions[array_length(f.versions, 1)] as latest_version,
|
||||
f.value, f.summary, f.description, f.schema
|
||||
FROM flow f
|
||||
WHERE f.workspace_id = $1 AND f.path = $2 AND f.archived = false",
|
||||
target_workspace_id,
|
||||
&path
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let mut metadata_changes = Vec::new();
|
||||
let mut has_changes = false;
|
||||
|
||||
if let (Some(source), Some(target)) = (&source_flow, &target_flow) {
|
||||
if source.value != target.value {
|
||||
metadata_changes.push("content".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
if source.summary != target.summary {
|
||||
metadata_changes.push("summary".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
if source.description != target.description {
|
||||
metadata_changes.push("description".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
if source.schema != target.schema {
|
||||
metadata_changes.push("schema".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
} else if source_flow.is_some() || target_flow.is_some() {
|
||||
has_changes = true;
|
||||
if source_flow.is_none() {
|
||||
metadata_changes.push("only_in_target".to_string());
|
||||
} else {
|
||||
metadata_changes.push("only_in_source".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if has_changes {
|
||||
// Count versions
|
||||
let source_version_count = source_flow.as_ref()
|
||||
.and_then(|f| f.latest_version)
|
||||
.unwrap_or(0);
|
||||
let target_version_count = target_flow.as_ref()
|
||||
.and_then(|f| f.latest_version)
|
||||
.unwrap_or(0);
|
||||
|
||||
diffs.push(WorkspaceItemDiff {
|
||||
kind: "flow".to_string(),
|
||||
path: path.clone(),
|
||||
versions_ahead: (source_version_count - target_version_count).max(0) as i32,
|
||||
versions_behind: (target_version_count - source_version_count).max(0) as i32,
|
||||
has_changes,
|
||||
source_hash: None,
|
||||
target_hash: None,
|
||||
source_version: source_flow.as_ref().and_then(|f| f.latest_version),
|
||||
target_version: target_flow.as_ref().and_then(|f| f.latest_version),
|
||||
metadata_changes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn compare_apps(
|
||||
db: &DB,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
) -> Result<Vec<WorkspaceItemDiff>> {
|
||||
let mut diffs = Vec::new();
|
||||
|
||||
// Get all unique app paths from both workspaces
|
||||
let all_paths = sqlx::query!(
|
||||
"SELECT DISTINCT path FROM (
|
||||
SELECT path FROM app WHERE workspace_id = $1 AND draft_only = false
|
||||
UNION
|
||||
SELECT path FROM app WHERE workspace_id = $2 AND draft_only = false
|
||||
) AS paths",
|
||||
source_workspace_id,
|
||||
target_workspace_id
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
for (path,) in all_paths {
|
||||
|
||||
// Get latest app version from each workspace
|
||||
let source_app = sqlx::query!(
|
||||
"SELECT a.versions[array_length(a.versions, 1)] as latest_version,
|
||||
a.summary, a.policy
|
||||
FROM app a
|
||||
WHERE a.workspace_id = $1 AND a.path = $2 AND a.draft_only = false",
|
||||
source_workspace_id,
|
||||
&path
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let target_app = sqlx::query!(
|
||||
"SELECT a.versions[array_length(a.versions, 1)] as latest_version,
|
||||
a.summary, a.policy
|
||||
FROM app a
|
||||
WHERE a.workspace_id = $1 AND a.path = $2 AND a.draft_only = false",
|
||||
target_workspace_id,
|
||||
&path
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
// Get actual app version content for comparison
|
||||
let source_version_data = if let Some(app) = &source_app {
|
||||
if let Some(version_id) = app.latest_version {
|
||||
sqlx::query!(
|
||||
"SELECT value FROM app_version WHERE id = $1",
|
||||
version_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let target_version_data = if let Some(app) = &target_app {
|
||||
if let Some(version_id) = app.latest_version {
|
||||
sqlx::query!(
|
||||
"SELECT value FROM app_version WHERE id = $1",
|
||||
version_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut metadata_changes = Vec::new();
|
||||
let mut has_changes = false;
|
||||
|
||||
if let (Some(source), Some(target)) = (&source_app, &target_app) {
|
||||
if source.summary != target.summary {
|
||||
metadata_changes.push("summary".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
if source.policy != target.policy {
|
||||
metadata_changes.push("policy".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
|
||||
// Compare actual app content
|
||||
if let (Some(source_data), Some(target_data)) = (&source_version_data, &target_version_data) {
|
||||
if source_data.value != target_data.value {
|
||||
metadata_changes.push("content".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
}
|
||||
} else if source_app.is_some() || target_app.is_some() {
|
||||
has_changes = true;
|
||||
if source_app.is_none() {
|
||||
metadata_changes.push("only_in_target".to_string());
|
||||
} else {
|
||||
metadata_changes.push("only_in_source".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if has_changes {
|
||||
let source_version_count = source_app.as_ref()
|
||||
.and_then(|a| a.latest_version)
|
||||
.unwrap_or(0);
|
||||
let target_version_count = target_app.as_ref()
|
||||
.and_then(|a| a.latest_version)
|
||||
.unwrap_or(0);
|
||||
|
||||
diffs.push(WorkspaceItemDiff {
|
||||
kind: "app".to_string(),
|
||||
path: path.clone(),
|
||||
versions_ahead: (source_version_count - target_version_count).max(0) as i32,
|
||||
versions_behind: (target_version_count - source_version_count).max(0) as i32,
|
||||
has_changes,
|
||||
source_hash: None,
|
||||
target_hash: None,
|
||||
source_version: source_app.as_ref().and_then(|a| a.latest_version),
|
||||
target_version: target_app.as_ref().and_then(|a| a.latest_version),
|
||||
metadata_changes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn compare_resources(
|
||||
db: &DB,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
) -> Result<Vec<WorkspaceItemDiff>> {
|
||||
let mut diffs = Vec::new();
|
||||
|
||||
// Get all unique resource paths from both workspaces
|
||||
let all_paths = sqlx::query!(
|
||||
"SELECT DISTINCT path FROM (
|
||||
SELECT path FROM resource WHERE workspace_id = $1
|
||||
UNION
|
||||
SELECT path FROM resource WHERE workspace_id = $2
|
||||
) AS paths",
|
||||
source_workspace_id,
|
||||
target_workspace_id
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
for (path,) in all_paths {
|
||||
|
||||
let source_resource = sqlx::query!(
|
||||
"SELECT value, description, resource_type, edited_at
|
||||
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, edited_at
|
||||
FROM resource
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
target_workspace_id,
|
||||
&path
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let mut metadata_changes = Vec::new();
|
||||
let mut has_changes = false;
|
||||
|
||||
if let (Some(source), Some(target)) = (&source_resource, &target_resource) {
|
||||
if source.value != target.value {
|
||||
metadata_changes.push("value".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
if source.description != target.description {
|
||||
metadata_changes.push("description".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
if source.resource_type != target.resource_type {
|
||||
metadata_changes.push("resource_type".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
} else if source_resource.is_some() || target_resource.is_some() {
|
||||
has_changes = true;
|
||||
if source_resource.is_none() {
|
||||
metadata_changes.push("only_in_target".to_string());
|
||||
} else {
|
||||
metadata_changes.push("only_in_source".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if has_changes {
|
||||
diffs.push(WorkspaceItemDiff {
|
||||
kind: "resource".to_string(),
|
||||
path: path.clone(),
|
||||
versions_ahead: if source_resource.is_some() && target_resource.is_none() { 1 } else { 0 },
|
||||
versions_behind: if target_resource.is_some() && source_resource.is_none() { 1 } else { 0 },
|
||||
has_changes,
|
||||
source_hash: None,
|
||||
target_hash: None,
|
||||
source_version: None,
|
||||
target_version: None,
|
||||
metadata_changes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn compare_variables(
|
||||
db: &DB,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
) -> Result<Vec<WorkspaceItemDiff>> {
|
||||
let mut diffs = Vec::new();
|
||||
|
||||
// Get all unique variable paths from both workspaces
|
||||
let all_paths = sqlx::query!(
|
||||
"SELECT DISTINCT path FROM (
|
||||
SELECT path FROM variable WHERE workspace_id = $1
|
||||
UNION
|
||||
SELECT path FROM variable WHERE workspace_id = $2
|
||||
) AS paths",
|
||||
source_workspace_id,
|
||||
target_workspace_id
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
for (path,) in all_paths {
|
||||
|
||||
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",
|
||||
target_workspace_id,
|
||||
&path
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let mut metadata_changes = Vec::new();
|
||||
let mut has_changes = false;
|
||||
|
||||
if let (Some(source), Some(target)) = (&source_variable, &target_variable) {
|
||||
// For secrets, we can't compare values directly, only check if both exist
|
||||
if source.is_secret != target.is_secret {
|
||||
metadata_changes.push("is_secret".to_string());
|
||||
has_changes = true;
|
||||
} else if !source.is_secret && source.value != target.value {
|
||||
metadata_changes.push("value".to_string());
|
||||
has_changes = true;
|
||||
} else if source.is_secret {
|
||||
// For secrets, we mark as potentially changed
|
||||
metadata_changes.push("secret_value".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
|
||||
if source.description != target.description {
|
||||
metadata_changes.push("description".to_string());
|
||||
has_changes = true;
|
||||
}
|
||||
} else if source_variable.is_some() || target_variable.is_some() {
|
||||
has_changes = true;
|
||||
if source_variable.is_none() {
|
||||
metadata_changes.push("only_in_target".to_string());
|
||||
} else {
|
||||
metadata_changes.push("only_in_source".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if has_changes {
|
||||
diffs.push(WorkspaceItemDiff {
|
||||
kind: "variable".to_string(),
|
||||
path: path.clone(),
|
||||
versions_ahead: if source_variable.is_some() && target_variable.is_none() { 1 } else { 0 },
|
||||
versions_behind: if target_variable.is_some() && source_variable.is_none() { 1 } else { 0 },
|
||||
has_changes,
|
||||
source_hash: None,
|
||||
target_hash: None,
|
||||
source_version: None,
|
||||
target_version: None,
|
||||
metadata_changes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(diffs)
|
||||
}
|
||||
*/
|
||||
|
||||
82
frontend/src/lib/components/DiffViewer.svelte
Normal file
82
frontend/src/lib/components/DiffViewer.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { CenteredModal, Button } from './common'
|
||||
import { diffLines } from 'diff'
|
||||
import { FileText, X } from 'lucide-svelte'
|
||||
import type { WorkspaceItemDiff } from '$lib/gen'
|
||||
|
||||
export let open = false
|
||||
export let item: WorkspaceItemDiff | undefined = undefined
|
||||
export let sourceData: any = undefined
|
||||
export let targetData: any = undefined
|
||||
|
||||
$: sourceContent = getContent(sourceData, item?.kind)
|
||||
$: targetContent = getContent(targetData, item?.kind)
|
||||
$: diff = sourceContent && targetContent ? diffLines(sourceContent, targetContent) : []
|
||||
|
||||
function getContent(data: any, kind: string | undefined): string {
|
||||
if (!data || !kind) return ''
|
||||
|
||||
switch (kind) {
|
||||
case 'script':
|
||||
return data.content || ''
|
||||
case 'flow':
|
||||
return JSON.stringify(data.value, null, 2)
|
||||
case 'app':
|
||||
return JSON.stringify(data.value, null, 2)
|
||||
case 'resource':
|
||||
return JSON.stringify(data.value, null, 2)
|
||||
case 'variable':
|
||||
return data.is_secret ? '(secret value)' : data.value
|
||||
default:
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<CenteredModal bind:open title="Diff Viewer">
|
||||
<div class="flex flex-col h-full max-h-[80vh]">
|
||||
{#if item}
|
||||
<div class="flex items-center gap-2 px-4 py-2 border-b">
|
||||
<FileText class="w-4 h-4" />
|
||||
<span class="font-mono text-sm">{item.path}</span>
|
||||
<span class="text-xs text-gray-500">({item.kind})</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
{#if diff.length > 0}
|
||||
<pre class="text-xs font-mono">
|
||||
{#each diff as part}
|
||||
{#if part.added}
|
||||
<span class="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">+ {part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200">- {part.value}</span>
|
||||
{:else}
|
||||
<span class="text-gray-600 dark:text-gray-400"> {part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</pre>
|
||||
{:else if !sourceData && targetData}
|
||||
<div class="bg-green-100 dark:bg-green-900 p-4 rounded">
|
||||
<p class="text-green-800 dark:text-green-200">New item (only in target)</p>
|
||||
<pre class="mt-2 text-xs">{targetContent}</pre>
|
||||
</div>
|
||||
{:else if sourceData && !targetData}
|
||||
<div class="bg-red-100 dark:bg-red-900 p-4 rounded">
|
||||
<p class="text-red-800 dark:text-red-200">Deleted item (only in source)</p>
|
||||
<pre class="mt-2 text-xs">{sourceContent}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-gray-500">No differences found</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 text-gray-500">No item selected</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-4 border-t flex justify-end">
|
||||
<Button variant="secondary" on:click={() => open = false}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CenteredModal>
|
||||
154
frontend/src/lib/components/ForkWorkspaceBanner.svelte
Normal file
154
frontend/src/lib/components/ForkWorkspaceBanner.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<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 { Alert } from './common'
|
||||
import { InfoIcon, GitBranch, ArrowUpRight, ArrowDownRight, AlertTriangle } from 'lucide-svelte'
|
||||
import WorkspaceComparisonDrawer from './WorkspaceComparisonDrawer.svelte'
|
||||
|
||||
let comparisonDrawer: WorkspaceComparisonDrawer | undefined = undefined
|
||||
let loading = false
|
||||
let comparison: WorkspaceComparison | undefined = undefined
|
||||
let error: string | undefined = undefined
|
||||
let isVisible = false
|
||||
|
||||
$: currentWorkspace = $workspaceStore
|
||||
$: currentWorkspaceData = $userWorkspaces.find(w => w.id === currentWorkspace)
|
||||
$: isFork = currentWorkspace?.startsWith('wm-fork-') ?? false
|
||||
$: parentWorkspaceId = currentWorkspaceData?.parent_workspace_id
|
||||
|
||||
// Determine if we should show the banner
|
||||
$: if (isFork && currentWorkspace) {
|
||||
checkForChanges()
|
||||
} else {
|
||||
isVisible = false
|
||||
comparison = undefined
|
||||
}
|
||||
|
||||
async function checkForChanges() {
|
||||
if (!currentWorkspace || !parentWorkspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
error = undefined
|
||||
|
||||
try {
|
||||
// Compare with parent workspace
|
||||
const result = await WorkspaceService.compareWorkspaces({
|
||||
workspace: currentWorkspace,
|
||||
targetWorkspaceId: parentWorkspaceId
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function openComparisonDrawer() {
|
||||
comparisonDrawer?.open()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isVisible && 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">
|
||||
<GitBranch 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 Workspace
|
||||
</span>
|
||||
{#if parentWorkspaceId}
|
||||
<span class="text-blue-700 dark:text-blue-300 ml-1">
|
||||
(parent: {parentWorkspaceId})
|
||||
</span>
|
||||
{/if}
|
||||
</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}
|
||||
<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}
|
||||
<span class="text-blue-600 dark:text-blue-400">
|
||||
No changes to deploy
|
||||
</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}
|
||||
>
|
||||
Review & Deploy Changes
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<WorkspaceComparisonDrawer
|
||||
bind:this={comparisonDrawer}
|
||||
{comparison}
|
||||
sourceWorkspace={currentWorkspace}
|
||||
targetWorkspace={parentWorkspaceId ?? undefined}
|
||||
on:deployed={() => checkForChanges()}
|
||||
/>
|
||||
472
frontend/src/lib/components/WorkspaceComparisonDrawer.svelte
Normal file
472
frontend/src/lib/components/WorkspaceComparisonDrawer.svelte
Normal file
@@ -0,0 +1,472 @@
|
||||
<script lang="ts">
|
||||
import { Drawer, DrawerContent, Button, Toggle, Badge, Alert, Tabs, Tab } from './common'
|
||||
import type { WorkspaceComparison, WorkspaceItemDiff } from '$lib/gen'
|
||||
import { WorkspaceService, ScriptService, FlowService, AppService, ResourceService, VariableService } from '$lib/gen'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
FileCode,
|
||||
Workflow,
|
||||
Layout,
|
||||
Database,
|
||||
Key,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
FileText
|
||||
} from 'lucide-svelte'
|
||||
import DiffViewer from './DiffViewer.svelte'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
|
||||
export let comparison: WorkspaceComparison | undefined = undefined
|
||||
export let sourceWorkspace: string | undefined = undefined
|
||||
export let targetWorkspace: string | undefined = undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let drawer: Drawer | undefined = undefined
|
||||
let deploymentDirection: 'deploy' | 'update' = 'deploy'
|
||||
let selectedItems: Set<string> = new Set()
|
||||
let expandedItems: Set<string> = new Set()
|
||||
let deploying = false
|
||||
let activeTab: 'all' | 'scripts' | 'flows' | 'apps' | 'resources' | 'variables' = 'all'
|
||||
let showDiffViewer = false
|
||||
let selectedDiffItem: WorkspaceItemDiff | undefined = undefined
|
||||
let diffViewerData: { source: any, target: any } | undefined = undefined
|
||||
|
||||
$: filteredDiffs = comparison?.diffs.filter(diff => {
|
||||
if (activeTab === 'all') return true
|
||||
return diff.kind === activeTab.slice(0, -1) // Remove 's' from plural
|
||||
}) ?? []
|
||||
|
||||
$: groupedDiffs = groupDiffsByKind(filteredDiffs)
|
||||
|
||||
$: selectableDiffs = filteredDiffs.filter(diff => {
|
||||
if (deploymentDirection === 'deploy') {
|
||||
return diff.versions_ahead > 0
|
||||
} else {
|
||||
return diff.versions_behind > 0
|
||||
}
|
||||
})
|
||||
|
||||
$: conflictingDiffs = filteredDiffs.filter(diff =>
|
||||
diff.versions_ahead > 0 && diff.versions_behind > 0
|
||||
)
|
||||
|
||||
function groupDiffsByKind(diffs: WorkspaceItemDiff[]) {
|
||||
const grouped: Record<string, WorkspaceItemDiff[]> = {}
|
||||
for (const diff of diffs) {
|
||||
if (!grouped[diff.kind]) {
|
||||
grouped[diff.kind] = []
|
||||
}
|
||||
grouped[diff.kind].push(diff)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
export function open() {
|
||||
drawer?.openDrawer()
|
||||
// Auto-select all eligible items initially
|
||||
selectedItems = new Set(selectableDiffs.map(d => `${d.kind}:${d.path}`))
|
||||
}
|
||||
|
||||
function getItemIcon(kind: string) {
|
||||
switch (kind) {
|
||||
case 'script': return FileCode
|
||||
case 'flow': return Workflow
|
||||
case 'app': return Layout
|
||||
case 'resource': return Database
|
||||
case 'variable': return Key
|
||||
default: return FileText
|
||||
}
|
||||
}
|
||||
|
||||
function getItemKey(diff: WorkspaceItemDiff): string {
|
||||
return `${diff.kind}:${diff.path}`
|
||||
}
|
||||
|
||||
function toggleItem(diff: WorkspaceItemDiff) {
|
||||
const key = getItemKey(diff)
|
||||
if (selectedItems.has(key)) {
|
||||
selectedItems.delete(key)
|
||||
} else {
|
||||
selectedItems.add(key)
|
||||
}
|
||||
selectedItems = selectedItems // Trigger reactivity
|
||||
}
|
||||
|
||||
function toggleExpanded(diff: WorkspaceItemDiff) {
|
||||
const key = getItemKey(diff)
|
||||
if (expandedItems.has(key)) {
|
||||
expandedItems.delete(key)
|
||||
} else {
|
||||
expandedItems.add(key)
|
||||
loadDiffDetails(diff)
|
||||
}
|
||||
expandedItems = expandedItems // Trigger reactivity
|
||||
}
|
||||
|
||||
async function loadDiffDetails(diff: WorkspaceItemDiff) {
|
||||
if (!sourceWorkspace || !targetWorkspace) return
|
||||
|
||||
try {
|
||||
let sourceData: any = null
|
||||
let targetData: any = null
|
||||
|
||||
switch (diff.kind) {
|
||||
case 'script':
|
||||
if (diff.source_hash) {
|
||||
sourceData = await ScriptService.getScriptByHash({
|
||||
workspace: sourceWorkspace,
|
||||
hash: diff.source_hash
|
||||
})
|
||||
}
|
||||
if (diff.target_hash) {
|
||||
targetData = await ScriptService.getScriptByHash({
|
||||
workspace: targetWorkspace,
|
||||
hash: diff.target_hash
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'flow':
|
||||
sourceData = await FlowService.getFlowByPath({
|
||||
workspace: sourceWorkspace,
|
||||
path: diff.path
|
||||
})
|
||||
targetData = await FlowService.getFlowByPath({
|
||||
workspace: targetWorkspace,
|
||||
path: diff.path
|
||||
})
|
||||
break
|
||||
case 'app':
|
||||
sourceData = await AppService.getAppByPath({
|
||||
workspace: sourceWorkspace,
|
||||
path: diff.path
|
||||
})
|
||||
targetData = await AppService.getAppByPath({
|
||||
workspace: targetWorkspace,
|
||||
path: diff.path
|
||||
})
|
||||
break
|
||||
case 'resource':
|
||||
sourceData = await ResourceService.getResource({
|
||||
workspace: sourceWorkspace,
|
||||
path: diff.path
|
||||
})
|
||||
targetData = await ResourceService.getResource({
|
||||
workspace: targetWorkspace,
|
||||
path: diff.path
|
||||
})
|
||||
break
|
||||
case 'variable':
|
||||
sourceData = await VariableService.getVariable({
|
||||
workspace: sourceWorkspace,
|
||||
path: diff.path
|
||||
})
|
||||
targetData = await VariableService.getVariable({
|
||||
workspace: targetWorkspace,
|
||||
path: diff.path
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
selectedDiffItem = diff
|
||||
diffViewerData = { source: sourceData, target: targetData }
|
||||
} catch (error) {
|
||||
console.error('Failed to load diff details:', error)
|
||||
sendUserToast('Failed to load diff details', true)
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedItems = new Set(selectableDiffs.map(d => getItemKey(d)))
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
selectedItems = new Set()
|
||||
}
|
||||
|
||||
async function deployChanges() {
|
||||
if (!sourceWorkspace || !targetWorkspace) return
|
||||
|
||||
deploying = true
|
||||
try {
|
||||
const itemsToDeploy = Array.from(selectedItems).map(key => {
|
||||
const [kind, ...pathParts] = key.split(':')
|
||||
return { kind, path: pathParts.join(':') }
|
||||
})
|
||||
|
||||
// TODO: Implement actual deployment logic
|
||||
// This would involve calling appropriate APIs to copy/update items
|
||||
// between workspaces based on the deployment direction
|
||||
|
||||
sendUserToast(`Successfully deployed ${itemsToDeploy.length} items`, false)
|
||||
dispatch('deployed')
|
||||
drawer?.closeDrawer()
|
||||
} catch (error) {
|
||||
console.error('Deployment failed:', error)
|
||||
sendUserToast('Deployment failed', true)
|
||||
} finally {
|
||||
deploying = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorkspace() {
|
||||
if (!sourceWorkspace || comparison?.summary.total_diffs !== 0) return
|
||||
|
||||
if (confirm(`Are you sure you want to delete the forked workspace "${sourceWorkspace}"? This action cannot be undone.`)) {
|
||||
try {
|
||||
await WorkspaceService.deleteWorkspace({ workspace: sourceWorkspace })
|
||||
sendUserToast('Forked workspace deleted successfully', false)
|
||||
// Redirect to parent workspace
|
||||
window.location.href = `/w/${targetWorkspace}`
|
||||
} catch (error) {
|
||||
console.error('Failed to delete workspace:', error)
|
||||
sendUserToast('Failed to delete workspace', true)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="90%">
|
||||
<DrawerContent title="Workspace Comparison & Deployment" on:close={drawer?.closeDrawer}>
|
||||
<div class="flex flex-col h-full">
|
||||
{#if comparison}
|
||||
<!-- Header with deployment direction toggle -->
|
||||
<div class="p-4 border-b bg-gray-50 dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<GitBranch class="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
{deploymentDirection === 'deploy' ? 'Deploy to Parent' : 'Update from Parent'}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{sourceWorkspace} → {targetWorkspace}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
bind:checked={deploymentDirection}
|
||||
options={{
|
||||
left: { label: 'Deploy', value: 'deploy' },
|
||||
right: { label: 'Update Fork', value: 'update' }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<Badge color="blue">
|
||||
{comparison.summary.total_diffs} total changes
|
||||
</Badge>
|
||||
{#if conflictingDiffs.length > 0}
|
||||
<Badge color="orange">
|
||||
<AlertTriangle class="w-3 h-3 inline mr-1" />
|
||||
{conflictingDiffs.length} conflicts
|
||||
</Badge>
|
||||
{/if}
|
||||
<Badge color="green">
|
||||
{selectableDiffs.length} deployable
|
||||
</Badge>
|
||||
<Badge>
|
||||
{selectedItems.size} selected
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning for conflicts -->
|
||||
{#if conflictingDiffs.length > 0}
|
||||
<Alert type="warning" class="m-4">
|
||||
<AlertTriangle class="w-4 h-4" />
|
||||
<span>
|
||||
{conflictingDiffs.length} item{conflictingDiffs.length !== 1 ? 's are' : ' is'} both ahead and behind.
|
||||
Deploying will overwrite changes in the target workspace.
|
||||
</span>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<!-- Tabs for filtering by type -->
|
||||
<Tabs bind:selected={activeTab} class="px-4 pt-4">
|
||||
<Tab value="all" label="All ({comparison.summary.total_diffs})" />
|
||||
{#if comparison.summary.scripts_changed > 0}
|
||||
<Tab value="scripts" label="Scripts ({comparison.summary.scripts_changed})" />
|
||||
{/if}
|
||||
{#if comparison.summary.flows_changed > 0}
|
||||
<Tab value="flows" label="Flows ({comparison.summary.flows_changed})" />
|
||||
{/if}
|
||||
{#if comparison.summary.apps_changed > 0}
|
||||
<Tab value="apps" label="Apps ({comparison.summary.apps_changed})" />
|
||||
{/if}
|
||||
{#if comparison.summary.resources_changed > 0}
|
||||
<Tab value="resources" label="Resources ({comparison.summary.resources_changed})" />
|
||||
{/if}
|
||||
{#if comparison.summary.variables_changed > 0}
|
||||
<Tab value="variables" label="Variables ({comparison.summary.variables_changed})" />
|
||||
{/if}
|
||||
</Tabs>
|
||||
|
||||
<!-- Selection controls -->
|
||||
<div class="px-4 py-2 flex items-center justify-between border-b">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="xs" variant="ghost" on:click={selectAll}>
|
||||
Select All
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" on:click={deselectAll}>
|
||||
Deselect All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diff list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each Object.entries(groupedDiffs) as [kind, diffs]}
|
||||
<div class="border-b">
|
||||
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-900 text-sm font-medium capitalize">
|
||||
{kind}s ({diffs.length})
|
||||
</div>
|
||||
{#each diffs as diff}
|
||||
{@const key = getItemKey(diff)}
|
||||
{@const isSelectable = selectableDiffs.includes(diff)}
|
||||
{@const isSelected = selectedItems.has(key)}
|
||||
{@const isExpanded = expandedItems.has(key)}
|
||||
{@const isConflict = diff.versions_ahead > 0 && diff.versions_behind > 0}
|
||||
{@const Icon = getItemIcon(diff.kind)}
|
||||
|
||||
<div class="border-b last:border-b-0">
|
||||
<div class="px-4 py-2 flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-gray-900">
|
||||
<!-- Expand/collapse button -->
|
||||
<button
|
||||
on:click={() => toggleExpanded(diff)}
|
||||
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
{:else}
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Checkbox -->
|
||||
{#if isSelectable}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
on:change={() => toggleItem(diff)}
|
||||
class="rounded"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-4" />
|
||||
{/if}
|
||||
|
||||
<!-- Icon -->
|
||||
<Icon class="w-4 h-4 text-gray-500" />
|
||||
|
||||
<!-- Path -->
|
||||
<span class="flex-1 font-mono text-sm">{diff.path}</span>
|
||||
|
||||
<!-- Status badges -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#if diff.versions_ahead > 0}
|
||||
<Badge color="green" size="xs">
|
||||
<ArrowUpRight class="w-3 h-3 inline" />
|
||||
{diff.versions_ahead} ahead
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if diff.versions_behind > 0}
|
||||
<Badge color="blue" size="xs">
|
||||
<ArrowDownRight class="w-3 h-3 inline" />
|
||||
{diff.versions_behind} behind
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if isConflict}
|
||||
<Badge color="orange" size="xs">
|
||||
<AlertTriangle class="w-3 h-3 inline" />
|
||||
Conflict
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if diff.metadata_changes.includes('only_in_source')}
|
||||
<Badge color="gray" size="xs">New</Badge>
|
||||
{/if}
|
||||
{#if diff.metadata_changes.includes('only_in_target')}
|
||||
<Badge color="gray" size="xs">Deleted</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded content -->
|
||||
{#if isExpanded && diffViewerData}
|
||||
<div class="px-8 py-2 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 mb-2">
|
||||
Changes: {diff.metadata_changes.join(', ')}
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
on:click={() => showDiffViewer = true}
|
||||
>
|
||||
View Detailed Diff
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer actions -->
|
||||
<div class="p-4 border-t bg-gray-50 dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
{#if comparison.summary.total_diffs === 0}
|
||||
<Button
|
||||
color="red"
|
||||
variant="secondary"
|
||||
on:click={deleteWorkspace}
|
||||
>
|
||||
Delete Fork Workspace
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="secondary" on:click={() => drawer?.closeDrawer()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="blue"
|
||||
disabled={selectedItems.size === 0 || deploying}
|
||||
loading={deploying}
|
||||
on:click={deployChanges}
|
||||
>
|
||||
{deploymentDirection === 'deploy' ? 'Deploy' : 'Update'} {selectedItems.size} Item{selectedItems.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-gray-500">No comparison data available</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<!-- Diff viewer modal -->
|
||||
{#if showDiffViewer && selectedDiffItem && diffViewerData}
|
||||
<DiffViewer
|
||||
bind:open={showDiffViewer}
|
||||
item={selectedDiffItem}
|
||||
sourceData={diffViewerData.source}
|
||||
targetData={diffViewerData.target}
|
||||
/>
|
||||
{/if}
|
||||
@@ -48,6 +48,7 @@
|
||||
import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte'
|
||||
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
|
||||
import { setContext, untrack } from 'svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { Menubar } from '$lib/components/meltComponents'
|
||||
@@ -707,14 +708,17 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<AiChatLayout
|
||||
{children}
|
||||
noPadding={devOnly}
|
||||
{isCollapsed}
|
||||
onMenuOpen={() => {
|
||||
menuOpen = true
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-col h-full w-full">
|
||||
<ForkWorkspaceBanner />
|
||||
<AiChatLayout
|
||||
{children}
|
||||
noPadding={devOnly}
|
||||
{isCollapsed}
|
||||
onMenuOpen={() => {
|
||||
menuOpen = true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredModal title="Loading user...">
|
||||
|
||||
Reference in New Issue
Block a user