Files
windmill/frontend/src/lib/components/CompareWorkspaces.svelte
T
hugocasaandClaude Opus 4.8 b4b0c6a93e feat: add dev workspaces paired with a lockable prod workspace (#9793)
* feat: add dev workspaces paired with a lockable prod workspace

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: gate dev-workspace prod-lock on admin and prevent attach cycles

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: redirect locked-prod edits into the dev workspace

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: make dev-workspace settings tab available on CE (was EE-gated)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: lock prod against forking too and funnel edits to the dev workspace

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: open dev item page on edit and tailor dev-workspace lock messages

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: prevent nested dev workspaces and hide dev option when one exists

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: drop the redundant already-has-dev hint on the fork form

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: badge dev workspaces and sort them ahead of forks in the tree/switcher

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: label dev workspaces as 'Dev workspace of X' instead of 'Fork of X'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: label edit as 'Edit in <dev>', cover editor headers, auto-expand dev in tree

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: split prod lock into separate block-deploy and prevent-forking toggles

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: make resources/variables workspace-specific from compare page

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: steer AI-chat sessions to the dev workspace

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: refine session fork options and lock guidance for dev/prod

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: session picker reads prod's real rules, default to current ws

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: copy members into forks and clarify dev-workspace root labeling

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: place the workspace id field under the fork name

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: address dev-workspace review findings and harden fork detection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: regenerate sqlx offline cache

Restores entries dropped during the origin/main merge and adds the
dev-workspace queries (is_dev_workspace, ws_specific, has_parent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: address second-round dev-workspace review findings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: address Pi and Codex review findings on dev-workspace endpoints

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: gate locked-dev git-branch fork on admin and validate ws_specific path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: clear prod dev-lock when deleting an attached dev workspace

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: consolidate dev-workspace migration and scope all-group join to attach

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: restore dev-workspace CHECK into consolidated migration and scope all-group join

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: drop copy_members from the dev-workspace attach path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: dev-workspace lifecycle/auth fixes from Codex review round

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: explicit create-in-other for workspace-specific items

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: make create-in-other strictly create-only (never overwrite target)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: return 403 (not 401) for dev-workspace permission denials

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: allow attaching a same-family fork as a dev workspace

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: emphasize the go-to-dev action in the no-direct-deploy alert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: seed a resource's linked variables when creating it in the other workspace

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: judge workspace deploy/fork locks against the user's identity in that workspace

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: clarify create-in help text in workspace-specific panel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: admin-gate dev-workspace creation and harden lock/seed edges

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: preserve a staged fork's source on picker create-mode re-entry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: clear dev flag on archive and check dev existence server-side

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: make create-in-other atomically create-only via direct create

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: create-only resource insert, ws-specific list scopes, archive lock guard

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: reserve the dev_workspace_lock protection-rule name from the public API

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: reattach create_protection_rule doc comment to its function

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: make dev-archive pairing teardown atomic with the archive

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: follow deploy_to on root rename; show dev pairing to non-member prod admins

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: copy creator metadata on fork; invalidate fork routing cache on rename

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: accept g/ paths in set_ws_specific; gate copy_members to dev workspaces

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:01:07 +00:00

1465 lines
51 KiB
Svelte

<script lang="ts">
import {
AlertTriangle,
ArrowDownRight,
ArrowRight,
ArrowUpRight,
Building,
CircleCheck,
CircleX,
DiffIcon,
FileJson,
FlaskConical,
GitFork,
Loader2,
UserPlus
} from 'lucide-svelte'
import type { CiTestResult } from '$lib/gen'
import { Alert, Badge } from './common'
import {
AppService,
FlowService,
FolderService,
ResourceService,
ScriptService,
UserService,
VariableService,
WorkspaceService,
type WorkspaceComparison,
type WorkspaceItemDiff
} from '$lib/gen'
import Button from './common/button/Button.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import DiffDrawer from './DiffDrawer.svelte'
import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import type { Kind } from '$lib/utils_deployable'
import {
deployItem,
deleteItemInWorkspace,
getItemValue,
getOnBehalfOf,
type DeployResult
} from '$lib/utils_workspace_deploy'
import { isTriggerOrScheduleKind } from 'windmill-utils-internal'
import Tooltip from './Tooltip.svelte'
import OnBehalfOfSelector, {
needsOnBehalfOfSelection,
type OnBehalfOfChoice,
type OnBehalfOfDetails
} from './OnBehalfOfSelector.svelte'
import { sendUserToast } from '$lib/toast'
import { deepEqual } from 'fast-equals'
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
import DeploymentRequestPanel from './deploymentRequest/DeploymentRequestPanel.svelte'
import { userStore } from '$lib/stores'
import { base } from '$lib/base'
import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte'
import { editUrlFor } from './sessions/forkEditUrl'
import DatatableSchemaDiff from './DatatableSchemaDiff.svelte'
interface Props {
currentWorkspaceId: string
parentWorkspaceId: string
comparison: WorkspaceComparison | undefined
/** Initial merge direction; lets the page restore the chosen direction when
* switching back from draft mode (deploy_to → true, update → false). */
initialMergeIntoParent?: boolean
/** Per-direction counts for the merged toggle badges (the page owns them). */
deployCount?: number
updateCount?: number
/** Draft count for the merged toggle's badge (the page owns it). */
draftCount?: number
/** Keys (`kind:path`) of fork items that are deployed *and* have a pending
* draft (has_draft). Such rows get a "+Draft" warning badge and are left
* out of the default selection — deploying/updating moves the deployed
* version, not the draft. The page derives this from the fork drafts. */
draftKeys?: Set<string>
/** Selecting `draft` asks the page to swap us out for CompareDrafts;
* deploy_to/update are handled internally but reported so the page can
* remember the direction. */
onModeSelected?: (v: CompareMode) => void
/** Fired after a deploy/update so the page re-fetches the comparison and
* draft count, keeping the toggle badges in sync with the new state. */
onChanged?: () => void
}
let {
currentWorkspaceId,
parentWorkspaceId,
comparison,
initialMergeIntoParent = true,
deployCount = 0,
updateCount = 0,
draftCount = 0,
draftKeys = new Set<string>(),
onModeSelected,
onChanged
}: Props = $props()
// Workspace-specific ("pinned") resources/variables. They keep their own
// value per environment and are suppressed from the diff, so the page fetches
// them separately to keep them visible and un-pinnable.
// `onCurrent`/`onParent` track which side carries the ws_specific marker — which, because marks
// are existence-guarded, also tells us which side the item exists on. An item present on only one
// side can be seeded onto the other via "Create in <other>".
type PinnedItem = { item_kind: string; path: string; onCurrent: boolean; onParent: boolean }
let pinnedItems = $state<PinnedItem[]>([])
let pinBusy = $state<Record<string, boolean>>({})
let createConfirm = $state<PinnedItem | undefined>(undefined)
// Whether this item is already workspace-specific (marked on either side); used to avoid offering
// to pin it again from a diff row.
const isPinned = (kind: string, path: string) =>
pinnedItems.some((it) => it.item_kind === kind && it.path === path)
// Bumped on each load so a response that resolves after the compared pair
// changed can't overwrite the newer pair's items.
let pinnedReqSeq = 0
async function loadPinned() {
const seq = ++pinnedReqSeq
try {
const [cur, par] = await Promise.all([
WorkspaceService.listWsSpecific({ workspace: currentWorkspaceId }),
WorkspaceService.listWsSpecific({ workspace: parentWorkspaceId })
])
if (seq !== pinnedReqSeq) return
const map = new Map<string, PinnedItem>()
const mark = (it: { item_kind: string; path: string }, side: 'cur' | 'par') => {
const k = `${it.item_kind}:${it.path}`
const e = map.get(k) ?? {
item_kind: it.item_kind,
path: it.path,
onCurrent: false,
onParent: false
}
if (side === 'cur') e.onCurrent = true
else e.onParent = true
map.set(k, e)
}
for (const it of cur) mark(it, 'cur')
for (const it of par) mark(it, 'par')
pinnedItems = [...map.values()].sort((a, b) =>
`${a.item_kind}:${a.path}`.localeCompare(`${b.item_kind}:${b.path}`)
)
} catch (e) {
console.error('Failed to load workspace-specific items', e)
}
}
$effect(() => {
// Re-fetch whenever the compared pair changes.
currentWorkspaceId
parentWorkspaceId
loadPinned()
})
// Flag both environments so each side marks the item workspace-specific, not
// only the workspace the compare is viewed from. `value=false` makes it
// shared again.
async function setPinned(kind: 'resource' | 'variable', path: string, value: boolean) {
const k = `${kind}:${path}`
pinBusy[k] = true
try {
const targets = [currentWorkspaceId, parentWorkspaceId]
const results = await Promise.allSettled(
targets.map((ws) =>
WorkspaceService.setWsSpecific({
workspace: ws,
requestBody: { item_kind: kind, path, value }
})
)
)
const verb = value ? 'workspace specific' : 'shared'
const failed = results.filter((r) => r.status === 'rejected').length
if (failed === targets.length) {
sendUserToast(`Failed to update workspace-specific for ${path}`, true)
} else if (failed > 0) {
sendUserToast(`Made ${path} ${verb} in one environment only (no access to the other)`)
} else {
sendUserToast(`Made ${path} ${verb}`)
}
await loadPinned()
onChanged?.()
} finally {
pinBusy[k] = false
}
}
// Collect `$var:` variable references from a resource value (mirrors the backend `collect_var_refs`).
function collectVarRefs(value: unknown, out: string[]) {
if (typeof value === 'string') {
if (value.startsWith('$var:')) out.push(value.slice('$var:'.length))
} else if (Array.isArray(value)) {
for (const v of value) collectVarRefs(v, out)
} else if (value && typeof value === 'object') {
for (const v of Object.values(value)) collectVarRefs(v, out)
}
}
// Create the item on the target from the source value, strictly create-only: the backend create
// endpoints reject an existing path (`check_path_conflict`), so unlike `deployItem` — which updates
// on conflict — this never overwrites a target created concurrently. Returns 'created', 'conflict'
// (already present on the target), or throws for a real error.
async function createItemOnly(
kind: 'resource' | 'variable',
path: string,
from: string,
to: string
): Promise<'created' | 'conflict'> {
try {
if (kind === 'resource') {
const r = await ResourceService.getResource({ workspace: from, path })
await ResourceService.createResource({
workspace: to,
requestBody: {
path,
value: r.value ?? '',
description: r.description ?? '',
resource_type: r.resource_type
}
})
} else {
const v = await VariableService.getVariable({ workspace: from, path, decryptSecret: true })
await VariableService.createVariable({
workspace: to,
requestBody: {
path,
value: v.value ?? '',
is_secret: v.is_secret ?? false,
description: v.description ?? ''
}
})
}
return 'created'
} catch (e) {
// Don't parse the error message: re-check existence. If the target now exists, the create
// lost a race (the backend create is create-only and rejected it), so report a conflict and
// leave it untouched rather than overwriting.
const existsNow =
kind === 'resource'
? await ResourceService.existsResource({ workspace: to, path })
: await VariableService.existsVariable({ workspace: to, path })
if (existsNow) return 'conflict'
throw e
}
}
// Seed a resource's linked `$var:` variables into the target environment, copying each one's value
// (incl. secrets) only where the target lacks it (create-only). Mirrors the backend's
// `mark_linked_variables_ws_specific` cascade so a seeded resource resolves its references and its
// secrets stay per-environment. Returns the paths that failed to seed.
async function seedMissingLinkedVars(
resourcePath: string,
from: string,
to: string
): Promise<string[]> {
const resource = await ResourceService.getResource({ workspace: from, path: resourcePath })
const refs: string[] = []
collectVarRefs(resource.value, refs)
const failed: string[] = []
for (const varPath of Array.from(new Set(refs))) {
if (await VariableService.existsVariable({ workspace: to, path: varPath })) continue
try {
// 'created' and 'conflict' (now present, created concurrently) are both the desired end
// state; only a real error counts as a failure to seed.
await createItemOnly('variable', varPath, from, to)
} catch (e) {
failed.push(varPath)
}
}
return failed
}
// Seed a workspace-specific item onto a side that lacks it, copying its current value (incl.
// secret values). Strictly create-only: markers can be one-sided (a pin can fail on a locked
// side), so a missing marker doesn't prove the item is missing — re-check actual existence and,
// if the target already has it, mark it workspace-specific there instead of overwriting its value.
async function createOnRemote(it: PinnedItem) {
const from = it.onCurrent ? currentWorkspaceId : parentWorkspaceId
const to = it.onCurrent ? parentWorkspaceId : currentWorkspaceId
const k = `${it.item_kind}:${it.path}`
const kindCast = it.item_kind as 'resource' | 'variable'
pinBusy[k] = true
try {
const existsOnTarget =
kindCast === 'resource'
? await ResourceService.existsResource({ workspace: to, path: it.path })
: await VariableService.existsVariable({ workspace: to, path: it.path })
if (existsOnTarget) {
// Don't overwrite — just mark the existing target item workspace-specific.
await WorkspaceService.setWsSpecific({
workspace: to,
requestBody: { item_kind: kindCast, path: it.path, value: true }
})
sendUserToast(
`${it.path} already exists in ${to} — marked it workspace-specific instead of overwriting`
)
await loadPinned()
onChanged?.()
return
}
// A resource owns its `$var:` secrets, so seed any linked variables the target lacks before
// creating the resource — otherwise it would reference variables that don't exist there. If
// any linked variable fails to seed, abort rather than create a resource with dangling refs.
const linkedFailed =
kindCast === 'resource' ? await seedMissingLinkedVars(it.path, from, to) : []
if (linkedFailed.length > 0) {
sendUserToast(
`Did not create ${it.path} in ${to}: failed to seed linked variable(s) ${linkedFailed.join(', ')}`,
true
)
return
}
// Create-only: a 'conflict' means the target appeared between the existence check above and
// the create, so it was left untouched rather than overwritten.
const result = await createItemOnly(kindCast, it.path, from, to)
// Marking cascades to mark a resource's now-present linked variables workspace-specific.
await WorkspaceService.setWsSpecific({
workspace: to,
requestBody: { item_kind: kindCast, path: it.path, value: true }
})
sendUserToast(
result === 'conflict'
? `${it.path} already exists in ${to}, marked it workspace-specific instead of overwriting`
: `Created ${it.path} in ${to}`
)
await loadPinned()
onChanged?.()
} catch (e) {
sendUserToast(`Failed to create ${it.path}: ${e}`, true)
} finally {
pinBusy[k] = false
}
}
// A fork row has a pending draft when its key is in the page-provided set.
function hasDraft(diff: WorkspaceItemDiff): boolean {
return draftKeys.has(getItemKey(diff))
}
let currentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == currentWorkspaceId))
let parentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == parentWorkspaceId))
let mergeIntoParent = $state(initialMergeIntoParent)
let deploying = $state(false)
let hasAutoSelected = $state(false)
let canDeployToParent = $state(true)
let canPreserveInParent = $state(false)
let canPreserveInCurrent = $state(false)
let canPreserveOnBehalfOf = $derived(mergeIntoParent ? canPreserveInParent : canPreserveInCurrent)
let selectableDiffs = $derived(
comparison?.diffs.filter((diff) => {
if (mergeIntoParent) {
return diff.ahead > 0
} else {
return diff.behind > 0
}
}) ?? []
)
let selectedItems = $state<string[]>([])
// Selected items that carry a pending draft. They're opt-in (excluded from the
// default selection), so a non-empty list means the user explicitly picked an
// item whose draft won't be included — confirm before deploying.
let selectedDraftKeys = $derived(selectedItems.filter((k) => draftKeys.has(k)))
let draftConfirmOpen = $state(false)
function requestDeploy() {
if (selectedDraftKeys.length > 0) {
draftConfirmOpen = true
} else {
deployChanges()
}
}
// Nothing actionable in the current direction (no items ahead to deploy, or
// none behind to update). When so we show a message instead of a table of
// greyed, non-actionable rows.
let nothingToAct = $derived(selectableDiffs.length === 0)
let emptyDeployMessage = $derived(
(comparison?.diffs.length ?? 0) === 0
? 'No changes between this fork and its parent.'
: mergeIntoParent
? `Nothing to deploy — ${parentWorkspaceId} already has every change from this fork.`
: `Nothing to update — this fork is up to date with ${parentWorkspaceId}.`
)
let conflictingDiffs = $derived(
comparison?.diffs.filter((diff) => diff.ahead > 0 && diff.behind > 0) ?? []
)
let itemsWithBehindChanges = $derived(
comparison?.diffs.filter((diff) => {
const status = deploymentStatus[getItemKey(diff)]?.status
return diff && diff.behind > 0 && diff.ahead == 0 && !(status && status == 'deployed')
}) ?? []
)
let itemsWithAheadChanges = $derived(
comparison?.diffs.filter((diff) => {
const status = deploymentStatus[getItemKey(diff)]?.status
return diff && diff.ahead > 0 && !(status && status == 'deployed')
}) ?? []
)
let hasBehindChanges = $derived(mergeIntoParent && itemsWithBehindChanges.length > 0)
let hasAheadChanges = $derived(mergeIntoParent && itemsWithAheadChanges.length > 0)
// Summary cache: stores summaries from both workspaces
type SummaryCache = Record<string, { current?: string; parent?: string; loading?: boolean }>
let summaryCache = $state<SummaryCache>({})
// On-behalf-of tracking for flows and scripts
// Source workspace on_behalf_of emails (keyed by workspace/kind:path)
let onBehalfOfInfo = $state<Record<string, string | undefined>>({})
let onBehalfOfChoice = $state<Record<string, OnBehalfOfChoice>>({})
let customOnBehalfOf = $state<Record<string, OnBehalfOfDetails>>({})
let deployTargetWorkspace = $derived(mergeIntoParent ? parentWorkspaceId : currentWorkspaceId)
function getItemKey(diff: WorkspaceItemDiff): string {
return `${diff.kind}:${diff.path}`
}
async function fetchSummary(
kind: string,
path: string,
workspace: string
): Promise<string | undefined> {
try {
if (kind === 'script') {
const script = await ScriptService.getScriptByPath({ workspace, path })
return script.summary
} else if (kind === 'flow') {
const flow = await FlowService.getFlowByPath({ workspace, path })
return flow.summary
} else if (kind === 'app' || kind === 'raw_app') {
const app = await AppService.getAppByPath({ workspace, path })
return app.summary
} else if (kind === 'folder') {
const folder = await FolderService.getFolder({ workspace, name: path.replace(/^f\//, '') })
return folder.summary
}
} catch (error) {
console.error(`Failed to fetch summary for ${kind}:${path}`, error)
}
return undefined
}
async function fetchSummaries(diffs: WorkspaceItemDiff[]) {
// Only fetch summaries for scripts, flows, and apps
const itemsToFetch = diffs.filter((diff) =>
['script', 'flow', 'app', 'raw_app', 'folder'].includes(diff.kind)
)
for (const diff of itemsToFetch) {
const key = getItemKey(diff)
// Skip if already cached or loading
if (summaryCache[key]) continue
// Mark as loading
summaryCache[key] = { loading: true }
// Fetch from both workspaces in parallel
const [currentSummary, parentSummary] = await Promise.all([
fetchSummary(diff.kind, diff.path, currentWorkspaceId),
fetchSummary(diff.kind, diff.path, parentWorkspaceId)
])
summaryCache[key] = {
current: currentSummary,
parent: parentSummary,
loading: false
}
}
}
async function fetchOnBehalfOfInfo(diffs: WorkspaceItemDiff[]) {
// Runnables carry an email; triggers/schedules carry `permissioned_as`.
// `getOnBehalfOf` handles the dispatch — we just need to skip kinds that
// have no on_behalf_of concept (resource, variable, folder, resource_type).
const itemsWithOnBehalfOf = diffs.filter(
(d) =>
['flow', 'script', 'app', 'raw_app'].includes(d.kind) || isTriggerOrScheduleKind(d.kind)
)
for (const diff of itemsWithOnBehalfOf) {
for (const workspace of [currentWorkspaceId, parentWorkspaceId]) {
const workspacedKey = getWorkspacedKey(workspace, getItemKey(diff))
if (onBehalfOfInfo[workspacedKey] !== undefined) continue
try {
onBehalfOfInfo[workspacedKey] = await getOnBehalfOf(
diff.kind as Kind,
diff.path,
workspace
)
} catch {
onBehalfOfInfo[workspacedKey] = undefined
}
}
}
}
// Get source workspace on_behalf_of value for an item (email for runnables, permissioned_as for triggers)
function getSourceOnBehalfOf(itemKey: string): string | undefined {
const sourceWorkspace = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId
return onBehalfOfInfo[getWorkspacedKey(sourceWorkspace, itemKey)]
}
// Get target workspace on_behalf_of value for an item (existing item in destination)
function getTargetOnBehalfOf(itemKey: string): string | undefined {
const targetWorkspace = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId
return onBehalfOfInfo[getWorkspacedKey(targetWorkspace, itemKey)]
}
// Check if an item needs on_behalf_of selection
function itemNeedsOnBehalfOfSelection(itemKey: string, kind: string): boolean {
return needsOnBehalfOfSelection(kind, getSourceOnBehalfOf(itemKey))
}
// Check if all required on_behalf_of selections are made
let hasUnselectedOnBehalfOf = $derived(
selectedItems.some((itemKey) => {
const diff = selectableDiffs.find((d) => getItemKey(d) === itemKey)
if (!diff) return false
return (
itemNeedsOnBehalfOfSelection(itemKey, diff.kind) && onBehalfOfChoice[itemKey] === undefined
)
})
)
/**
* Get the on_behalf_of value for deployment based on user's choice.
* Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules.
*/
function getOnBehalfOfForDeploy(itemKey: string, kind: Kind): string | undefined {
const choice = onBehalfOfChoice[itemKey]
if (choice === 'target') return getTargetOnBehalfOf(itemKey)
if (choice === 'custom') {
const details = customOnBehalfOf[itemKey]
const wantsPermissionedAs = kind === 'trigger' || isTriggerOrScheduleKind(kind)
return wantsPermissionedAs ? details?.permissionedAs : details?.email
}
// 'me' or undefined = don't pass, backend will use deploying user's identity
return undefined
}
let diffDrawer: DiffDrawer | undefined = $state(undefined)
let isFlow = $state(true)
async function showDiff(kind: Kind, path: string) {
const workspaceTo = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId
const workspaceFrom = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId
if (diffDrawer) {
isFlow = kind == 'flow'
diffDrawer?.openDrawer()
let values = await Promise.all([
getItemValue(kind, path, workspaceTo),
getItemValue(kind, path, workspaceFrom)
])
diffDrawer?.setDiff({
mode: 'simple',
original: values?.[0] as any,
current: values?.[1] as any,
title: `${workspaceFrom} <> ${workspaceTo}`
})
}
}
// All *diff* items selected. Trigger items are opt-in and don't count
// toward "all selected" — see item merge below in deployableItems.
let allSelected = $derived(
selectableDiffs.length > 0 &&
selectableDiffs.every((d) => selectedItems.includes(getItemKey(d)))
)
async function selectAll() {
selectedItems = selectableDiffs
.map((d) => getItemKey(d))
.filter((k) => !(deploymentStatus[k]?.status == 'deployed'))
}
function deselectAll() {
selectedItems = []
}
const deploymentStatus: Record<
string,
{ status: 'loading' | 'deployed' | 'failed'; error?: string }
> = $state({})
function getWorkspacedKey(workspace: string, key: string): string {
return `${workspace}/${key}`
}
async function deploy(
kind: Kind,
path: string,
workspaceToDeployTo: string,
workspaceFrom: string,
statusKey: string
) {
deploymentStatus[statusKey] = { status: 'loading' }
// Check if the item was deleted in the source workspace.
// If so, archive/delete it in the target workspace instead of copying.
const diff = comparison?.diffs.find((d) => getItemKey(d) === statusKey)
const itemDeletedInSource = diff
? mergeIntoParent
? diff.exists_in_fork === false
: diff.exists_in_source === false
: false
let result: DeployResult
if (itemDeletedInSource) {
result = await deleteItemInWorkspace(kind, path, workspaceToDeployTo)
} else {
result = await deployItem({
kind,
path,
workspaceFrom,
workspaceTo: workspaceToDeployTo,
onBehalfOf: getOnBehalfOfForDeploy(statusKey, kind)
})
}
if (result.success) {
deploymentStatus[statusKey] = { status: 'deployed' }
} else {
deploymentStatus[statusKey] = { status: 'failed', error: result.error }
sendUserToast(`Failed to deploy ${statusKey}: ${result.error}`)
}
}
let deploymentErrorMessage = $state('')
let allowBehindChangesOverride = $state(false)
async function isComparisonUpToDate(): Promise<boolean> {
if (!comparison) {
return false
}
try {
const result = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: currentWorkspaceId
})
const nonDeployedChanges = comparison.diffs.filter(
(e) => !(deploymentStatus[getItemKey(e)]?.status == 'deployed')
)
if (!deepEqual(nonDeployedChanges, result.diffs)) {
deploymentErrorMessage = `New changes detected. Please reload the page and review the new changes before ${mergeIntoParent ? 'deploying' : 'updating'}`
return false
}
} catch (e) {
deploymentErrorMessage = `Failed to check for new changes before deployment: ${e}`
console.error('Failed to compare workspaces:', e)
return false
}
return true
}
async function deployChanges() {
deploying = true
if (!(await isComparisonUpToDate())) {
deploying = false
return
}
const parent = parentWorkspaceId
const current = currentWorkspaceId
const sortedItems = [...selectedItems].sort((a, b) => {
const aIsFolder = a.startsWith('folder:')
const bIsFolder = b.startsWith('folder:')
if (aIsFolder && !bIsFolder) return -1
if (!aIsFolder && bIsFolder) return 1
return 0
})
let anyFailed = false
for (const itemKey of sortedItems) {
const deployable = deployableItems.find((d) => d.key === itemKey)
if (!deployable) {
sendUserToast(`Undeployable item: ${itemKey}`, true)
continue
}
const to = mergeIntoParent ? parent : current
const from = mergeIntoParent ? current : parent
await deploy(deployable.kind, deployable.path, to, from, itemKey)
if (deploymentStatus[itemKey]?.status === 'failed') {
anyFailed = true
}
}
deploying = false
deselectAll()
// If every selected item deployed cleanly and the direction was
// merge-into-parent, close any open deployment request for this fork.
if (!anyFailed && mergeIntoParent) {
try {
const open = await WorkspaceService.getOpenDeploymentRequest({
workspace: currentWorkspaceId
})
if (open) {
await WorkspaceService.closeDeploymentRequestMerged({
workspace: currentWorkspaceId,
id: open.id
})
deploymentRequestPanel?.refresh()
}
} catch (e) {
console.error('Failed to close open deployment request after merge', e)
}
}
// Deployed items are now in sync and should drop off the comparison; ask
// the page to re-fetch so the list and toggle badges reflect the new state.
onChanged?.()
}
function toggleKey(key: string) {
if (selectedItems.includes(key)) {
selectedItems = selectedItems.filter((i) => i !== key)
} else {
selectedItems.push(key)
}
}
function selectDefault() {
// Triggers and schedules are opt-in: they often share runtime state with the
// parent (kafka group_id, postgres replication slot, schedule firing time)
// and pushing them by default would surprise users running a routine "Deploy
// to parent" flow. The user picks them à la carte by clicking the row.
// Items with a pending draft are also left out by default: the deployed
// version (not the draft) is what moves, so we make the user opt in.
const filtered = selectableDiffs.filter((d) => !isTriggerOrScheduleKind(d.kind) && !hasDraft(d))
const conflictSafe = mergeIntoParent
? filtered
: filtered.filter((d) => !(d.ahead > 0 && d.behind > 0))
selectedItems = conflictSafe
.map((d) => getItemKey(d))
.filter((k) => !(deploymentStatus[k]?.status == 'deployed'))
}
function toggleDeploymentDirection(v: string) {
deselectAll()
mergeIntoParent = v == 'deploy_to'
selectDefault()
}
// Merged toggle: deploy_to/update flip the direction in place; draft asks the
// page to swap us out for CompareDrafts. Either way report it up so the page
// remembers the chosen direction across mode switches.
function onToggleMode(v: CompareMode) {
onModeSelected?.(v)
if (v === 'draft') return
toggleDeploymentDirection(v)
}
// Fetch user permissions for both workspaces
$effect(() => {
;[currentWorkspaceId, parentWorkspaceId]
async function fetchPermissions() {
try {
const parentUser = await UserService.whoami({ workspace: parentWorkspaceId })
canPreserveInParent =
parentUser.is_admin || parentUser.groups?.includes('wm_deployers') || false
} catch {
canPreserveInParent = false
}
try {
const currentUser = await UserService.whoami({ workspace: currentWorkspaceId })
canPreserveInCurrent =
currentUser.is_admin || currentUser.groups?.includes('wm_deployers') || false
} catch {
canPreserveInCurrent = false
}
}
fetchPermissions()
})
// Fetch summaries and on_behalf_of_email when comparison data loads
$effect(() => {
if (comparison?.diffs) {
fetchSummaries(comparison.diffs)
fetchOnBehalfOfInfo(comparison.diffs)
}
})
// Auto-select items on initial load
$effect(() => {
if (comparison?.diffs && !hasAutoSelected && selectableDiffs.length > 0) {
selectDefault()
hasAutoSelected = true
}
})
// Reset override when selection or direction changes
$effect(() => {
;[selectedItems, mergeIntoParent]
allowBehindChangesOverride = false
})
// Trigger and schedule rows now flow through `comparison.diffs` like every
// other deployable kind — the backend's `compareWorkspaces` populates them
// from `workspace_diff`, with runtime fields ignored by `compare_two_*`.
let deployableItems = $derived.by(() => {
return (comparison?.diffs ?? [])
.filter((diff) => {
const key = getItemKey(diff)
const isSelectable = selectableDiffs.includes(diff)
const isDeployedAndIrrelevant =
deploymentStatus[key]?.status === 'deployed' && !isSelectable
return !isDeployedAndIrrelevant
})
.map((diff) => ({
key: getItemKey(diff),
path: diff.path,
kind: diff.kind as Kind,
diff
}))
})
let ciTestResults = $state<Record<string, CiTestResult[]>>({})
async function fetchCiTests() {
if (!comparison?.diffs || !currentWorkspaceId) return
const items = comparison.diffs
.filter((d) => d.kind === 'script' || d.kind === 'flow' || d.kind === 'resource')
.map((d) => ({ path: d.path, kind: d.kind as 'script' | 'flow' | 'resource' }))
if (items.length === 0) return
try {
ciTestResults = await ScriptService.getCiTestResultsBatch({
workspace: currentWorkspaceId,
requestBody: { items }
})
} catch (e) {
console.error('Failed to fetch CI test results:', e)
}
}
$effect(() => {
if (comparison && comparison.diffs.length > 0) {
fetchCiTests()
}
})
function getCiTestStatus(diff: WorkspaceItemDiff): 'pass' | 'fail' | 'running' | null {
const key = `${diff.kind}:${diff.path}`
const results = ciTestResults[key]
if (!results || results.length === 0) return null
if (results.some((r) => r.status === 'failure' || r.status === 'canceled')) return 'fail'
if (results.some((r) => r.status === 'running' || (r.job_id && !r.status))) return 'running'
if (results.every((r) => r.status === 'success')) return 'pass'
return null
}
// Deduplicated list of all CI tests across all items
let allCiTests = $derived.by(() => {
const seen = new Map<string, CiTestResult>()
for (const results of Object.values(ciTestResults)) {
for (const r of results) {
const existing = seen.get(r.test_script_path)
if (
!existing ||
(r.started_at && (!existing.started_at || r.started_at > existing.started_at))
) {
seen.set(r.test_script_path, r)
}
}
}
return [...seen.values()]
})
let deploymentRequestPanel: DeploymentRequestPanel | undefined = $state(undefined)
let hasOpenDeploymentRequest = $state(false)
/** Display labels for trigger/schedule kinds in the merge UI. */
const KIND_DISPLAY_NAMES: Record<string, string> = {
schedule: 'Schedule',
http_trigger: 'HTTP route',
websocket_trigger: 'Websocket trigger',
kafka_trigger: 'Kafka trigger',
nats_trigger: 'NATS trigger',
postgres_trigger: 'Postgres trigger',
mqtt_trigger: 'MQTT trigger',
sqs_trigger: 'SQS trigger',
gcp_trigger: 'GCP trigger',
azure_trigger: 'Azure trigger',
email_trigger: 'Email trigger'
}
</script>
{#if $workspaceStore != currentWorkspaceId}
<Alert title="Targetting different workspace" type="info" class="my-2">
<span>
You are currently seeing the deployement page of workspace <b
>{currentWorkspaceInfo?.name} ({currentWorkspaceInfo?.id})</b
> which is not your currently selected workspace.
</span>
<a href="/">Click here to go home ({$workspaceStore})</a>
</Alert>
{/if}
{#if comparison}
{@const selectedConflicts = conflictingDiffs.filter((e) =>
selectedItems.includes(getItemKey(e))
).length}
<div class="flex flex-col gap-4">
<div class="bg-surface-tertiary p-4 rounded-md border">
<WorkspaceDeployLayout
items={nothingToAct ? [] : deployableItems}
{selectedItems}
{deploymentStatus}
selectablePredicate={(item) => selectableDiffs.some((d) => getItemKey(d) === item.key)}
{allSelected}
onToggleItem={(item) => toggleKey(item.key)}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
emptyMessage={emptyDeployMessage}
>
{#snippet header()}
<div class="flex items-center justify-between bg-surface-tertiary">
<div class="flex flex-col gap-2 w-full pb-4">
<div class="flex flex-wrap gap-1 items-center">
<CompareModeToggle
selected={mergeIntoParent ? 'deploy_to' : 'update'}
isFork={true}
{parentWorkspaceId}
{deployCount}
{updateCount}
{draftCount}
disabled={deploying}
onSelected={onToggleMode}
/>
{#if currentWorkspaceInfo && parentWorkspaceInfo}
<div class="flex-1 flex gap-1 items-center">
<Badge
color="transparent"
class="ml-5 font-semibold"
title={mergeIntoParent ? currentWorkspaceInfo.name : parentWorkspaceInfo.name}
>
<span class="text-secondary">merge:</span>
{#if mergeIntoParent}
<GitFork size={14} />
<span class="text-emphasis">{currentWorkspaceInfo.id}</span>
{:else}
<Building size={14} />
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
{/if}
</Badge>
<ArrowRight size={16} />
<Badge
color="transparent"
class="font-semibold"
title={!mergeIntoParent
? currentWorkspaceInfo.name
: parentWorkspaceInfo.name}
>
<span class="text-secondary">into:</span>
{#if !mergeIntoParent}
<GitFork size={14} />
<span class="text-emphasis">{currentWorkspaceInfo.id}</span>
{:else}
<Building size={14} />
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
{/if}
</Badge>
</div>
{/if}
<div class="flex items-center gap-2 text-sm">
<Badge color="transparent">
{comparison.summary.total_diffs} total items
</Badge>
<Badge color="transparent">
{selectableDiffs.length}
{mergeIntoParent ? 'deployable' : 'updateable'}
</Badge>
{#if conflictingDiffs.length > 0}
<Badge color="orange">
<AlertTriangle class="w-3 h-3 inline mr-1" />
{conflictingDiffs.length} conflicts
</Badge>
{/if}
</div>
</div>
</div>
</div>
{/snippet}
{#if allCiTests.length > 0}
<div class="flex flex-col gap-1.5 mt-3 p-3 border bg-surface-secondary rounded text-xs">
<div class="flex items-center gap-1.5 font-semibold text-secondary">
<FlaskConical size={14} />
CI tests
</div>
<div class="flex flex-col gap-1">
{#each allCiTests as test (test.test_script_path)}
<div class="flex items-center gap-2">
{#if test.status === 'success'}
<CircleCheck size={14} class="text-green-600" />
{:else if test.status === 'failure' || test.status === 'canceled'}
<CircleX size={14} class="text-red-600" />
{:else if test.status === 'running'}
<Loader2 size={14} class="text-yellow-600 animate-spin" />
{:else}
<div class="w-3.5 h-3.5 rounded-full border border-tertiary"></div>
{/if}
<span class="text-secondary">{test.test_script_path}</span>
{#if test.job_id}
<a
href="{base}/run/{test.job_id}?workspace={currentWorkspaceId}"
class="text-tertiary hover:text-secondary text-2xs"
>
view run
</a>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{#snippet alerts()}
{#if draftCount > 0}
<Alert title="Undeployed drafts" type="warning" size="xs" class="my-2">
<div class="flex items-center gap-2 flex-wrap">
<span>
{#if mergeIntoParent}
This workspace has {draftCount} undeployed draft{draftCount !== 1 ? 's' : ''}.
Only deployed versions in this fork can be sent to {parentWorkspaceId} — deploy
{draftCount !== 1 ? 'them' : 'it'} first, otherwise those changes won't be included.
{:else}
This workspace has {draftCount} undeployed draft{draftCount !== 1 ? 's' : ''}.
{/if}
</span>
<Button variant="subtle" unifiedSize="xs" onclick={() => onModeSelected?.('draft')}>
See drafts
</Button>
</div>
</Alert>
{/if}
{#if mergeIntoParent}
<ParentWorkspaceProtectionAlert
{parentWorkspaceId}
onUpdateCanDeploy={(canDeploy) => {
canDeployToParent = canDeploy
}}
/>
{/if}
{#if conflictingDiffs.length > 0}
<Alert title="Conflicting changes detected" type="warning" class="mt-2">
<span>
{conflictingDiffs.length} item{conflictingDiffs.length !== 1 ? 's have' : ' has'} conflicting
changes, it was modified on the original workspace while changes were made on this fork.
Make sure to resolve these before merging.
</span>
</Alert>
{/if}
{#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)}
<Alert
title="This fork is behind {parentWorkspaceId} and needs to be up to date before deploying"
type="warning"
class="my-2"
>
You have items behind '{parentWorkspaceId}'. You need to update and test your changes
before being able to deploy.
<span class="font-medium flex flex-row gap-1 text-red-500">
<input
type="checkbox"
bind:checked={allowBehindChangesOverride}
class="rounded max-w-4"
/>
Override: Deploy despite {itemsWithBehindChanges.length} outdated item{itemsWithBehindChanges.length !==
1
? 's'
: ''}
</span>
</Alert>
{/if}
{#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible}
<Alert
title="This fork has changes not visible to your user"
type="warning"
class="my-2"
>
{#if !comparison.all_ahead_items_visible && !comparison.all_behind_items_visible}
This fork is ahead and behind its parent
{:else if !comparison.all_behind_items_visible}
This fork is behind of its parent
{:else if !comparison.all_ahead_items_visible}
This fork is ahead of its parent
{/if}
and some of the changes are not visible by you. Only a user with access to the whole context
may deploy or update this fork. You can share the link to this page to someone with proper
permissions to get it deployed.
</Alert>
{/if}
{/snippet}
{#snippet itemSummary(item)}
{@const diff = item.diff as WorkspaceItemDiff}
{@const key = item.key}
<!-- Point the edit link at the workspace the item actually lives in:
a parent-only row (deleted/absent in the fork) would 404 if linked
into the fork, so link it into the parent instead. -->
{@const editUrl = editUrlFor(
diff,
diff.exists_in_fork ? currentWorkspaceId : parentWorkspaceId
)}
{#if isTriggerOrScheduleKind(diff.kind)}
<span class="text-emphasis">
{KIND_DISPLAY_NAMES[diff.kind as string] ?? diff.kind}
</span>
<span class="text-tertiary mx-1">&rarr;</span>
<span class="text-secondary">{diff.path}</span>
{:else}
{@const isSelectable = selectableDiffs.includes(diff)}
{@const oldSummary = mergeIntoParent
? summaryCache[key]?.parent
: summaryCache[key]?.current}
{@const newSummary = mergeIntoParent
? summaryCache[key]?.current
: summaryCache[key]?.parent}
{@const existsInBothWorkspaces = !(
(diff.exists_in_fork && !diff.exists_in_source) ||
(!diff.exists_in_fork && diff.exists_in_source)
)}
<WorkspaceDeployItemSummary
path={diff.path}
{editUrl}
{oldSummary}
{newSummary}
renamed={oldSummary != newSummary && isSelectable && existsInBothWorkspaces}
/>
{/if}
{/snippet}
{#snippet itemActions(item)}
{@const diff = item.diff as WorkspaceItemDiff}
{@const key = item.key}
{@const targetOnBehalfOf = getTargetOnBehalfOf(key)}
{@const isConflict = diff.ahead > 0 && diff.behind > 0}
{@const existsInBothWorkspaces = !(
(diff.exists_in_fork && !diff.exists_in_source) ||
(!diff.exists_in_fork && diff.exists_in_source)
)}
<!-- On-behalf-of selector -->
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
<OnBehalfOfSelector
targetWorkspace={deployTargetWorkspace}
targetValue={targetOnBehalfOf}
selected={onBehalfOfChoice[key]}
onSelect={(choice, details) => {
onBehalfOfChoice[key] = choice
if (details) customOnBehalfOf[key] = details
}}
kind={diff.kind}
canPreserve={canPreserveOnBehalfOf}
customValue={customOnBehalfOf[key]?.permissionedAs}
/>
{/if}
{#if diff.kind === 'raw_app'}
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
{#if hasDraft(diff)}
<!-- This deployed fork item also has a pending draft. Deploying/updating
moves the deployed version, not the draft — so we warn (yellow, ahead
of the New/status badges) and leave it out of the default selection
(see selectDefault). -->
<Badge
title={mergeIntoParent
? 'This item has a draft — deploying sends the deployed version, not the draft.'
: 'This item has a draft — updating replaces the deployed version your draft is based on.'}
color="yellow"
size="xs"
>
<AlertTriangle class="w-3 h-3 inline mr-0.5" />+Draft
</Badge>
{/if}
<!-- Status badges -->
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
<Badge
title="This item was newly created in the parent workspace '{parentWorkspaceId}'"
color="indigo"
size="xs">New</Badge
>
{/if}
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0}
<Badge title="This item was deleted in '{currentWorkspaceId}'" color="red" size="xs"
>Deleted</Badge
>
{/if}
{#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0}
<Badge
title="This item was deleted in the parent workspace '{parentWorkspaceId}'"
color="red"
size="xs">Deleted</Badge
>
{/if}
{#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0}
<Badge
title="This item was newly created in '{currentWorkspaceId}'"
color="indigo"
size="xs">New</Badge
>
{/if}
{@const ciStatus = getCiTestStatus(diff)}
{#if ciStatus === 'pass'}
<Badge color="green" size="xs"><CircleCheck size={10} class="mr-0.5" />CI pass</Badge>
{:else if ciStatus === 'fail'}
<Badge color="red" size="xs"><CircleX size={10} class="mr-0.5" />CI fail</Badge>
{:else if ciStatus === 'running'}
<Badge color="yellow" size="xs"
><Loader2 size={10} class="mr-0.5 animate-spin" />CI</Badge
>
{/if}
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
<div class="flex items-center gap-2">
{#if isConflict || existsInBothWorkspaces}
{#if diff.ahead > 0}
<Badge color="green" size="xs">
<ArrowUpRight class="w-3 h-3 inline" />
{diff.ahead} ahead
</Badge>
{/if}
{#if diff.behind > 0}
<Badge color="blue" size="xs">
<ArrowDownRight class="w-3 h-3 inline" />
{diff.behind} behind
</Badge>
{/if}
{#if isConflict}
<Badge color="orange" size="xs">
<AlertTriangle class="w-3 h-3 inline" />
Conflict
</Badge>
{/if}
{/if}
</div>
<div class:invisible={!existsInBothWorkspaces}>
<Button
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: DiffIcon }}
onClick={() => showDiff(diff.kind as Kind, diff.path)}
>
Show diff
</Button>
</div>
{#if diff.kind === 'resource' || diff.kind === 'variable'}
{#if isPinned(diff.kind, diff.path)}
<Badge
color="gray"
size="xs"
title="Kept per environment, so it's excluded from the diff. Seed a missing copy or revert it in the Workspace-specific items list below."
>
workspace-specific
</Badge>
{:else}
<Button
unifiedSize="xs"
variant="subtle"
disabled={pinBusy[key]}
title="Keep this item's value per environment (excluded from the diff). Seed a missing copy from the Workspace-specific items list."
onClick={() => setPinned(diff.kind as 'resource' | 'variable', diff.path, true)}
>
Make workspace specific
</Button>
{/if}
{/if}
{/if}
{/snippet}
{#snippet footer()}
{#if !nothingToAct}
<div class="flex items-center justify-between">
<div></div>
<div class="flex flex-col items-end gap-2">
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
<div class="flex items-center gap-2">
{#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
<Button
variant="default"
startIcon={{ icon: UserPlus }}
on:click={() => deploymentRequestPanel?.openRequestDialog()}
>
Request deployment
</Button>
{/if}
<Button
variant="accent"
disabled={selectedItems.length === 0 ||
deploying ||
(hasBehindChanges && !allowBehindChangesOverride) ||
(mergeIntoParent && !canDeployToParent) ||
hasUnselectedOnBehalfOf}
loading={deploying}
on:click={requestDeploy}
>
{mergeIntoParent ? 'Deploy' : 'Update'}
{selectedItems.length} Item{selectedItems.length !== 1 ? 's' : ''}
{#if selectedConflicts != 0}
({selectedConflicts} conflicts)
{/if}
</Button>
</div>
{#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf}
<span class="text-xs text-yellow-600">
You must set the "on behalf of" user for all items before deploying
<Tooltip class="text-yellow-600">
The "run on behalf of" field defines which user's permissions will be
applied during execution. Make sure this is set to an appropriate user
before deploying.
</Tooltip>
</span>
{/if}
{/if}
{#if deploymentErrorMessage != ''}
<Alert
title="Cannot {mergeIntoParent ? 'deploy these changes' : 'update these items'}"
type="error"
class="my-2 max-w-80"
>
<span>
{deploymentErrorMessage}
</span>
</Alert>
{/if}
</div>
</div>
{/if}
{/snippet}
</WorkspaceDeployLayout>
<DeploymentRequestPanel
bind:this={deploymentRequestPanel}
forkWorkspaceId={currentWorkspaceId}
{parentWorkspaceId}
currentUsername={$userStore?.username ?? ''}
isAdmin={$userStore?.is_admin ?? false}
onStateChange={(open) => {
hasOpenDeploymentRequest = open
}}
/>
</div>
<div class="bg-surface-tertiary p-4 rounded-md border">
<DatatableSchemaDiff {currentWorkspaceId} {parentWorkspaceId} />
</div>
{#if pinnedItems.length > 0}
<div class="bg-surface-tertiary p-4 rounded-md border flex flex-col gap-2">
<div class="flex items-center gap-1.5 font-semibold text-secondary text-sm">
Workspace-specific items
</div>
<p class="text-xs text-tertiary">
These resources and variables keep their own value in each environment and are excluded
from the diff. An item that exists on only one side can be seeded onto the other with
"Create in …" (copies the current value, including secrets); it will never overwrite an
existing value.
</p>
<div class="flex flex-col gap-1">
{#each pinnedItems as it (`${it.item_kind}:${it.path}`)}
{@const missingSide =
it.onCurrent && !it.onParent
? parentWorkspaceId
: !it.onCurrent && it.onParent
? currentWorkspaceId
: undefined}
<div class="flex items-center gap-2 text-sm">
<Badge color="transparent" small>{it.item_kind}</Badge>
<span class="text-secondary font-mono text-xs flex-1 truncate">{it.path}</span>
{#if missingSide}
<Button
unifiedSize="xs"
variant="subtle"
disabled={pinBusy[`${it.item_kind}:${it.path}`]}
title="Copy this item's current value (including secrets) into {missingSide}"
onClick={() => (createConfirm = it)}
>
Create in {missingSide}
</Button>
{/if}
<Button
unifiedSize="xs"
variant="subtle"
disabled={pinBusy[`${it.item_kind}:${it.path}`]}
onClick={() => setPinned(it.item_kind as 'resource' | 'variable', it.path, false)}
>
Make shared
</Button>
</div>
{/each}
</div>
</div>
{/if}
</div>
<DiffDrawer bind:this={diffDrawer} {isFlow} />
<ConfirmationModal
open={draftConfirmOpen}
title={mergeIntoParent ? 'Deploy items with a draft?' : 'Update items with a draft?'}
confirmationText={mergeIntoParent ? 'Deploy anyway' : 'Update anyway'}
onConfirmed={() => {
draftConfirmOpen = false
deployChanges()
}}
onCanceled={() => (draftConfirmOpen = false)}
>
<div class="flex flex-col gap-2">
<p>
{selectedDraftKeys.length} selected item{selectedDraftKeys.length !== 1 ? 's' : ''}
{selectedDraftKeys.length !== 1 ? 'have' : 'has'} an undeployed draft.
{#if mergeIntoParent}
Deploying sends the deployed version, not the draft — those draft changes won't be
included.
{:else}
Updating replaces the deployed version your draft is based on.
{/if}
</p>
<ul class="list-disc pl-5 text-sm font-mono text-secondary">
{#each selectedDraftKeys as k (k)}
<li>{k.split(':').slice(1).join(':')}</li>
{/each}
</ul>
</div>
</ConfirmationModal>
<ConfirmationModal
open={!!createConfirm}
title="Create in {createConfirm?.onCurrent ? parentWorkspaceId : currentWorkspaceId}?"
confirmationText="Create"
onConfirmed={() => {
const it = createConfirm
createConfirm = undefined
if (it) createOnRemote(it)
}}
onCanceled={() => (createConfirm = undefined)}
>
<p class="text-sm">
This copies the current value of <span class="font-mono">{createConfirm?.path}</span>
(including any secret value) from
<b>{createConfirm?.onCurrent ? currentWorkspaceId : parentWorkspaceId}</b>
into <b>{createConfirm?.onCurrent ? parentWorkspaceId : currentWorkspaceId}</b>. It stays
workspace-specific afterward, so later promotes won't overwrite it. If it already exists
there, it's left untouched and just marked workspace-specific.
</p>
</ConfirmationModal>
{:else}
<div class="flex items-center justify-center h-full">
<div class="text-gray-500">No comparison data available</div>
</div>
{/if}