Files
windmill/frontend/src/lib/components/CompareWorkspaces.svelte
T
wendrul fa4f608da2 feat: workspace forks merge UI (#7333)
* feat: Add workspace diff viewer and deployment UI for forked workspaces

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

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

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

* Fix banner and use wworkspace_diff table

* satisfactory UI WIP

* UI complete

* Deploy button

* Comaprison and reset tally

* compare all types of items

* Show summaries

* Disable buttons during deployment

* Auto select all on entering page

* Change migration to have 'exists_in' cols

* Show new and deleted items

* frontend fixes

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

* Message to block whe changes are behind

* Skip workspaces pre-migration

* Remove unused code

* Fix apps comparison

* Only return changes where user has visibility

* No deploy button if no access to all changes

* Prepare sqlx

* Remove redundant message

* CI: update ee repo ref

* eereporef bis

* Small tweaks

* Remove unused struct

* Remove unused refactor component

* Fix npm run check

* Remove unused component

* chore: update ee-repo-ref to bbf406edc222199ca2e6076da12c376fb4ff28c5

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

Previous ee-repo-ref: 6aae845c5629ae32da43dbfbdc4566e5bf90fb1e

New ee-repo-ref: bbf406edc222199ca2e6076da12c376fb4ff28c5

Automated by sync-ee-ref workflow.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-12-12 22:52:26 +00:00

1008 lines
29 KiB
Svelte

<script lang="ts">
import {
AlertTriangle,
ArrowDown,
ArrowDownRight,
ArrowRight,
ArrowUp,
ArrowUpRight,
Building,
DiffIcon,
GitFork,
Loader2
} from 'lucide-svelte'
import { Alert, Badge } from './common'
import {
AppService,
FlowService,
FolderService,
RawAppService,
ResourceService,
ScheduleService,
ScriptService,
VariableService,
WorkspaceService,
type WorkspaceComparison,
type WorkspaceItemDiff
} from '$lib/gen'
import Button from './common/button/Button.svelte'
import DiffDrawer from './DiffDrawer.svelte'
import { getAllModules } from './flows/flowExplorer'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import type { Kind } from '$lib/utils_deployable'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import Row from './common/table/Row.svelte'
import { sendUserToast } from '$lib/toast'
import Tooltip from './Tooltip.svelte'
import { deepEqual } from 'fast-equals'
interface Props {
currentWorkspaceId: string
parentWorkspaceId: string
comparison: WorkspaceComparison | undefined
}
let { currentWorkspaceId, parentWorkspaceId, comparison }: Props = $props()
let currentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == currentWorkspaceId))
let parentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == parentWorkspaceId))
let mergeIntoParent = $state(true)
let deploying = $state(false)
let hasAutoSelected = $state(false)
let selectableDiffs = $derived(
comparison?.diffs.filter((diff) => {
if (mergeIntoParent) {
return diff.ahead > 0
} else {
return diff.behind > 0
}
}) ?? []
)
let selectedItems = $state<string[]>([])
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>({})
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') {
const app = await AppService.getAppByPath({ workspace, path })
return app.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'].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 getValue(kind: Kind, path: string, workspace: string) {
try {
if (kind == 'flow') {
const flow = await FlowService.getFlowByPath({
workspace: workspace,
path: path
})
getAllModules(flow.value.modules).forEach((x) => {
if (x.value.type == 'script' && x.value.hash != undefined) {
x.value.hash = undefined
}
})
return { summary: flow.summary, description: flow.description, value: flow.value }
} else if (kind == 'script') {
const script = await ScriptService.getScriptByPath({
workspace: workspace,
path: path
})
return {
content: script.content,
lock: script.lock,
schema: script.schema,
summary: script.summary,
language: script.language
}
} else if (kind == 'app') {
const app = await AppService.getAppByPath({
workspace: workspace,
path: path
})
return app
} else if (kind == 'variable') {
const variable = await VariableService.getVariable({
workspace: workspace,
path: path,
decryptSecret: true
})
return variable.value
} else if (kind == 'resource') {
const resource = await ResourceService.getResource({
workspace: workspace,
path: path
})
return resource.value
} else if (kind == 'resource_type') {
const resource = await ResourceService.getResourceType({
workspace: workspace,
path: path
})
return resource.schema
} else if (kind == 'raw_app') {
throw new Error('Raw app deploy not implemented yet')
// const app = await RawAppService.getRawAppData({
// workspace: workspace,
// path: path
// })
// if (alreadyExists) {
// }
// await RawAppService.updateRawApp({
// workspace: workspace,
// path: path,
// requestBody: {
// path: path
// }
// })
} else if (kind == 'folder') {
const folder = await FolderService.getFolder({
workspace: workspace,
name: path
})
return {
name: folder.name
}
// } else if (kind == 'trigger') {
// if (additionalInformation?.triggers) {
// return await getTriggerValue(additionalInformation.triggers.kind, path, workspace)
// } else {
// throw new Error(`Missing trigger information`)
// }
} else {
throw new Error(`Unknown kind ${kind}`)
}
} catch {
return {}
}
}
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([
getValue(kind, path, workspaceTo),
getValue(kind, path, workspaceFrom)
])
diffDrawer?.setDiff({
mode: 'simple',
original: values?.[0] as any,
current: values?.[1] as any,
title: `${workspaceFrom} <> ${workspaceTo}`
})
}
}
let allSelected = $derived(selectedItems.length == selectableDiffs.length)
async function selectAll() {
selectedItems = selectableDiffs
.map((d) => getItemKey(d))
.filter((k) => !(deploymentStatus[k]?.status == 'deployed'))
}
function deselectAll() {
selectedItems = []
}
async function selectAllNonConflicts() {
selectedItems = selectableDiffs
.filter((d) => !(d.ahead > 0 && d.behind > 0))
.map((d) => getItemKey(d))
.filter((k) => !(deploymentStatus[k]?.status == 'deployed'))
}
async function checkAlreadyExists(kind: Kind, path: string, workspace: string): Promise<boolean> {
let exists: boolean
if (kind == 'flow') {
exists = await FlowService.existsFlowByPath({
workspace: workspace,
path: path
})
} else if (kind == 'script') {
exists = await ScriptService.existsScriptByPath({
workspace: workspace,
path: path
})
} else if (kind == 'app') {
exists = await AppService.existsApp({
workspace: workspace,
path: path
})
} else if (kind == 'raw_app') {
exists = await RawAppService.existsRawApp({
workspace: workspace,
path: path
})
} else if (kind == 'variable') {
exists = await VariableService.existsVariable({
workspace: workspace,
path: path
})
} else if (kind == 'resource') {
exists = await ResourceService.existsResource({
workspace: workspace,
path: path
})
} else if (kind == 'schedule') {
exists = await ScheduleService.existsSchedule({
workspace: workspace,
path: path
})
} else if (kind == 'resource_type') {
exists = await ResourceService.existsResourceType({
workspace: workspace,
path: path
})
} else if (kind == 'folder') {
exists = await FolderService.existsFolder({
workspace: workspace,
name: path
})
// } else if (kind === 'trigger') {
// const triggersKind: TriggerKind[] = [
// 'kafka',
// 'mqtt',
// 'nats',
// 'postgres',
// 'routes',
// 'schedules',
// 'sqs',
// 'websockets',
// 'gcp'
// ]
// if (
// additionalInformation?.triggers &&
// triggersKind.includes(additionalInformation.triggers.kind)
// ) {
// exists = await existsTrigger(
// { workspace: workspace, path },
// additionalInformation.triggers.kind
// )
// } else {
// throw new Error(
// `Unexpected triggers kind, expected one of: '${triggersKind.join(', ')}' got: ${
// additionalInformation?.triggers?.kind
// }`
// )
// }
} else {
throw new Error(`Unknown kind ${kind}`)
}
return exists
}
const deploymentStatus: Record<
string,
{ status: 'loading' | 'deployed' | 'failed'; error?: string }
> = $state({})
async function deploy(
kind: Kind,
path: string,
workspaceToDeployTo: string,
workspaceFrom: string
) {
const statusPath = `${kind}:${path}`
deploymentStatus[statusPath] = { status: 'loading' }
// await sleep(1000)
// if (Math.random() > 0.5) {
// deploymentStatus[statusPath] = {status: 'failed'}
// } else {
// deploymentStatus[statusPath] = {status: 'deployed'}
// }
// return
try {
let alreadyExists = await checkAlreadyExists(kind, path, workspaceToDeployTo)
if (kind == 'flow') {
const flow = await FlowService.getFlowByPath({
workspace: workspaceFrom,
path: path
})
getAllModules(flow.value.modules).forEach((x) => {
if (x.value.type == 'script' && x.value.hash != undefined) {
x.value.hash = undefined
}
})
if (alreadyExists) {
await FlowService.updateFlow({
workspace: workspaceToDeployTo,
path: path,
requestBody: {
...flow
}
})
} else {
await FlowService.createFlow({
workspace: workspaceToDeployTo,
requestBody: {
...flow
}
})
}
} else if (kind == 'script') {
const script = await ScriptService.getScriptByPath({
workspace: workspaceFrom,
path: path
})
await ScriptService.createScript({
workspace: workspaceToDeployTo,
requestBody: {
...script,
lock: script.lock,
parent_hash: alreadyExists
? (
await ScriptService.getScriptByPath({
workspace: workspaceToDeployTo,
path: path
})
).hash
: undefined
}
})
} else if (kind == 'app') {
const app = await AppService.getAppByPath({
workspace: workspaceFrom,
path: path
})
if (alreadyExists) {
await AppService.updateApp({
workspace: workspaceToDeployTo,
path: path,
requestBody: {
...app
}
})
} else {
await AppService.createApp({
workspace: workspaceToDeployTo,
requestBody: {
...app
}
})
}
} else if (kind == 'variable') {
const variable = await VariableService.getVariable({
workspace: workspaceFrom,
path: path,
decryptSecret: true
})
if (alreadyExists) {
await VariableService.updateVariable({
workspace: workspaceToDeployTo,
path: path,
requestBody: {
path: path,
value: variable.value ?? '',
is_secret: variable.is_secret,
description: variable.description ?? ''
},
alreadyEncrypted: false
})
} else {
await VariableService.createVariable({
workspace: workspaceToDeployTo,
requestBody: {
path: path,
value: variable.value ?? '',
is_secret: variable.is_secret,
description: variable.description ?? ''
}
})
}
} else if (kind == 'resource') {
const resource = await ResourceService.getResource({
workspace: workspaceFrom,
path: path
})
if (alreadyExists) {
await ResourceService.updateResource({
workspace: workspaceToDeployTo,
path: path,
requestBody: {
path: path,
value: resource.value ?? '',
description: resource.description ?? ''
}
})
} else {
await ResourceService.createResource({
workspace: workspaceToDeployTo,
requestBody: {
path: path,
value: resource.value ?? '',
resource_type: resource.resource_type,
description: resource.description ?? ''
}
})
}
} else if (kind == 'resource_type') {
const resource = await ResourceService.getResourceType({
workspace: workspaceFrom,
path: path
})
if (alreadyExists) {
await ResourceService.updateResourceType({
workspace: workspaceToDeployTo,
path: path,
requestBody: {
schema: resource.schema,
description: resource.description ?? ''
}
})
} else {
await ResourceService.createResourceType({
workspace: workspaceToDeployTo,
requestBody: {
description: resource.description ?? '',
schema: resource.schema,
name: resource.name
}
})
}
} else if (kind == 'raw_app') {
throw new Error('Raw app deploy not implemented yet')
// const app = await RawAppService.getRawAppData({
// workspace: workspaceFrom,
// path: path
// })
// if (alreadyExists) {
// }
// await RawAppService.updateRawApp({
// workspace: workspaceFrom,
// path: path,
// requestBody: {
// path: path
// }
// })
} else if (kind == 'folder') {
await FolderService.createFolder({
workspace: workspaceToDeployTo,
requestBody: {
name: path
}
})
// } else if (kind === 'trigger') {
// if (additionalInformation?.triggers) {
// const { data, createFn, updateFn } = await getTriggersDeployData(
// additionalInformation.triggers.kind,
// path,
// workspaceFrom
// )
// if (alreadyExists) {
// await updateFn({
// path,
// workspace: workspaceToDeployTo,
// requestBody: data
// } as any)
// } else {
// await createFn({
// workspace: workspaceToDeployTo,
// requestBody: data
// } as any)
// }
// } else {
// throw new Error('Missing triggers kind')
// }
} else {
throw new Error(`Unknown kind ${kind}`)
}
// allAlreadyExists[statusPath] = true
deploymentStatus[statusPath] = { status: 'deployed' }
} catch (e) {
deploymentStatus[statusPath] = { status: 'failed', error: e.body || e.message }
sendUserToast(`Failed to deploy ${statusPath}: ${e.body || e.message}`)
}
}
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
for (const itemKey of selectedItems) {
const diff = selectableDiffs.find((d) => itemKey == getItemKey(d))
if (!diff) {
sendUserToast(`Undeployable item: ${itemKey}`, true)
continue
}
if (mergeIntoParent) {
await deploy(diff.kind, diff.path, parent, current)
} else {
await deploy(diff.kind, diff.path, current, parent)
}
}
deploying = false
deselectAll()
}
function toggleItem(diff: WorkspaceItemDiff) {
const key = getItemKey(diff)
if (selectedItems.includes(key)) {
selectedItems = selectedItems.filter((i) => i !== key)
} else {
selectedItems.push(key)
}
}
function selectDefault() {
if (mergeIntoParent) {
selectAll()
} else {
selectAllNonConflicts()
}
}
function toggleDeploymentDirection(v: string) {
deselectAll()
mergeIntoParent = v == 'deploy_to'
selectDefault()
}
// Fetch summaries when comparison data loads
$effect(() => {
if (comparison?.diffs) {
fetchSummaries(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
})
</script>
<div class="flex flex-col h-full">
{#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="bg-surface">
<div class="flex items-center justify-between">
<div
class="flex flex-col gap-2 border bg-surface-tertiary w-full p-4 border-radius-5 rounded"
>
<div class="flex flex-row gap-1 items-center">
<ToggleButtonGroup
disabled={deploying}
selected="deploy_to"
onSelected={toggleDeploymentDirection}
noWFull
>
{#snippet children({ item })}
<ToggleButton
value="deploy_to"
label="Deploy to {parentWorkspaceId}"
icon={ArrowUp}
{item}
/>
<ToggleButton value="update" label="Update current" icon={ArrowDown} {item} />
{/snippet}
</ToggleButtonGroup>
{#if currentWorkspaceInfo && parentWorkspaceInfo}
<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>
{/if}
</div>
<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>
{#if conflictingDiffs.length > 0}
<Alert title="Conflicting changes detected" type="warning" class="mt-2">
<!-- <AlertTriangle class="w-4 h-4" /> -->
<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}
<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}
<div class="px-4 py-2 flex items-center justify-between">
<div
class="flex items-center gap-2 text-secondary text-sm"
class:opacity-50={selectableDiffs.length == 0}
>
<input
type="checkbox"
disabled={selectableDiffs.length == 0}
checked={allSelected}
onchange={allSelected ? deselectAll : selectAll}
class="rounded max-w-4 w-full"
/> Select all
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div class="border rounded-md bg-surface-tertiary">
{#each comparison.diffs as diff}
{@const key = getItemKey(diff)}
{@const isSelectable = selectableDiffs.includes(diff)}
{@const isSelected = selectedItems.includes(key)}
{@const isConflict = diff.ahead > 0 && diff.behind > 0}
{@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)
)}
{@const isDeployedAndIrrelevant =
deploymentStatus[key]?.status == 'deployed' && !isSelectable}
{#if !isDeployedAndIrrelevant}
<Row
isSelectable={isSelectable && !(deploymentStatus[key]?.status == 'deployed')}
alignWithSelectable={true}
disabled={!isSelectable}
selected={isSelected && !(deploymentStatus[key]?.status == 'deployed')}
onSelect={() => toggleItem(diff)}
path={diff.kind != 'resource' && diff.kind != 'variable' ? diff.path : ''}
marked={undefined}
kind={diff.kind}
canFavorite={false}
workspaceId=""
starred={false}
>
{#snippet customSummary()}
{#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces}
<span class="line-through text-secondary">{oldSummary || diff.path}</span>
{newSummary || diff.path}
{:else if !existsInBothWorkspaces}
{newSummary || oldSummary || diff.path}
{:else}
{newSummary || diff.path}
{/if}
{/snippet}
{#snippet actions()}
<!-- 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}
{#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
size="xs"
variant="subtle"
onclick={() => showDiff(diff.kind as Kind, diff.path)}
>
<DiffIcon class="w-3 h-3" />
Show diff
</Button>
</div>
{/if}
{#if deploymentStatus[key]}
{#if deploymentStatus[key].status == 'loading'}
<Loader2 class="animate-spin" />
{:else if deploymentStatus[key].status == 'deployed'}
<Badge color="green">Deployed</Badge>
{:else if deploymentStatus[key].status == 'failed'}
<div class="inline-flex gap-1">
<Badge color="red">Failed</Badge>
<Tooltip>{deploymentStatus[key].error}</Tooltip></div
>
{/if}
{/if}
{/snippet}
</Row>
{/if}
{/each}
</div>
</div>
<div class="p-4 bg-surface">
<div class="flex items-center justify-between">
<div>
<!-- {#if comparison.summary.total_diffs === 0} -->
<!-- <Button color="red" variant="accent-secondary" on:click={deleteWorkspace}> -->
<!-- Delete Fork Workspace -->
<!-- </Button> -->
<!-- {/if} -->
</div>
<div class="flex flex-col items-end gap-2">
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
<Button
color="blue"
disabled={selectedItems.length === 0 ||
deploying ||
(hasBehindChanges && !allowBehindChangesOverride)}
loading={deploying}
on:click={deployChanges}
>
{mergeIntoParent ? 'Deploy' : 'Update'}
{selectedItems.length} Item{selectedItems.length !== 1 ? 's' : ''}
{#if selectedConflicts != 0}
({selectedConflicts} conflicts)
{/if}
</Button>
{/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>
</div>
<DiffDrawer bind:this={diffDrawer} {isFlow} />
{:else}
<div class="flex items-center justify-center h-full">
<div class="text-gray-500">No comparison data available</div>
</div>
{/if}
<!-- <DeployWorkspaceItems kind="script" initialPath="u/admin/economical_script" workspaceToDeployTo={parentWorkspaceId} /> -->
</div>