Files
windmill/frontend/src/lib/components/WorkspaceDeployLayout.svelte
T
Ruben Fiszel 689f5d7c75 fix: stop reading a parent-only fork item as deleted in the fork (#10467)
* fix: stop reading a parent-only fork item as deleted in the fork

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: condense the deploy-direction helper comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the ambiguous half of a one-sided diff out of bulk defaults

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: disable select-all on a removal-only list and cover the hidden source-only row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep parent-only items out of the fork merge list entirely

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: count the fork banner's ahead/behind with the compare page's predicate

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: open the direction the fork banner's button offers

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: cache the sqlx query for the source-only visibility test

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: don't read an unloaded comparison as nothing to deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: treat an in-flight comparison as unknown in the fork banner

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:14:41 +00:00

324 lines
11 KiB
Svelte

<script lang="ts">
import { Folder, Loader2, User } from 'lucide-svelte'
import { Badge } from './common'
import Checkbox from './common/checkbox/Checkbox.svelte'
import Row from './common/table/Row.svelte'
import Tooltip from './Tooltip.svelte'
import type { Snippet } from 'svelte'
import type { Kind } from '$lib/utils_deployable'
interface DeployableItem {
key: string
path: string
kind: Kind
triggerKind?: string
[key: string]: unknown
}
interface DeployGroup {
/** `f/<folder>` or `u/<user>`; '' for items outside both conventions. */
key: string
label: string
groupKind: 'folder' | 'user' | 'other'
items: DeployableItem[]
}
interface Props {
items: DeployableItem[]
selectedItems: string[]
selectablePredicate?: (item: DeployableItem) => boolean
/** Selectable on its own row, but never swept in by a bulk action (the group
* checkbox here, "Select all" in the caller). For a row whose deploy does
* something the user must choose deliberately. */
bulkExcluded?: (item: DeployableItem) => boolean
/** For a non-deployed item that isn't selectable, return a reason string to
* render a disabled checkbox + hover tooltip (instead of greying the row);
* return undefined to keep the default greyed-out, no-checkbox treatment. */
selectBlockedReason?: (item: DeployableItem) => string | undefined
deploymentStatus: Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
allSelected?: boolean
emptyMessage?: string
hideSelection?: boolean
children?: Snippet
// Snippets for customization
header?: Snippet
alerts?: Snippet
/** Rendered on the right of the "Select all" row (e.g. a filter toggle). */
selectAllActions?: Snippet
/** Rendered on the right of each folder/user group header — receives the
* group's items so pages can roll up their own badges (e.g. conflicts). */
groupActions?: Snippet<[DeployableItem[]]>
itemSummary?: Snippet<[DeployableItem]>
/** Overrides the secondary path line per item (e.g. to strike a renamed path). */
itemPath?: Snippet<[DeployableItem]>
itemActions?: Snippet<[DeployableItem]>
footer?: Snippet
// Callbacks
onToggleItem?: (item: DeployableItem) => void
onSelectAll?: () => void
onDeselectAll?: () => void
}
let {
items,
selectedItems,
selectablePredicate = () => true,
bulkExcluded = () => false,
selectBlockedReason,
deploymentStatus,
allSelected = false,
emptyMessage = 'No items to deploy',
hideSelection = false,
header,
alerts,
selectAllActions,
groupActions,
itemSummary,
itemPath,
itemActions,
footer,
onToggleItem,
onSelectAll,
onDeselectAll
}: Props = $props()
let selectableItems = $derived(items.filter(selectablePredicate))
// "Select all" is a bulk action, so it lives or dies by the bulk-selectable set:
// a list of nothing but bulk-excluded rows would otherwise offer an enabled
// control that selects nothing. Each such row is still selectable on its own.
let hasSelectableItems = $derived(selectableItems.some((i) => !bulkExcluded(i)))
// Plain row click and the checkbox both toggle this row in/out — multi-select
// is the default, no modifier needed.
function handleSelect(item: DeployableItem) {
onToggleItem?.(item)
}
function groupOf(path: string): Pick<DeployGroup, 'key' | 'label' | 'groupKind'> {
const parts = path.split('/')
// >= 2 so a folder item itself (path `f/<name>`, kind 'folder') lands in
// its folder's group next to the items that need it, not in "other".
if (parts.length >= 2 && (parts[0] === 'f' || parts[0] === 'u')) {
const key = `${parts[0]}/${parts[1]}`
return { key, label: key, groupKind: parts[0] === 'f' ? 'folder' : 'user' }
}
return { key: '', label: 'other', groupKind: 'other' }
}
let groups = $derived.by((): DeployGroup[] => {
const byKey = new Map<string, DeployGroup>()
for (const item of items) {
const g = groupOf(item.path)
let group = byKey.get(g.key)
if (!group) {
group = { ...g, items: [] }
byKey.set(g.key, group)
}
group.items.push(item)
}
const rank = { folder: 0, user: 1, other: 2 }
return [...byKey.values()].sort(
(a, b) => rank[a.groupKind] - rank[b.groupKind] || a.key.localeCompare(b.key)
)
})
// A lone catch-all group would just duplicate "Select all" — skip headers then.
let showGroupHeaders = $derived(groups.some((g) => g.groupKind !== 'other'))
// Mirrors the per-row checkbox condition: already-deployed rows keep their
// selection frozen, so they don't count toward the group's tri-state. Bulk-excluded
// rows are left out too — the group checkbox is a bulk action, so it must neither
// select them nor let them hold its tri-state short of full.
function groupSelectable(group: DeployGroup): DeployableItem[] {
return group.items.filter(
(i) =>
selectablePredicate(i) && !bulkExcluded(i) && deploymentStatus[i.key]?.status !== 'deployed'
)
}
function toggleGroup(group: DeployGroup) {
const selectable = groupSelectable(group)
const target = !selectable.every((i) => selectedItems.includes(i.key))
// Snapshot which items need flipping before mutating: onToggleItem updates
// the parent's selection, which feeds back into `selectedItems` mid-loop.
const toToggle = selectable.filter((i) => selectedItems.includes(i.key) !== target)
for (const item of toToggle) {
onToggleItem?.(item)
}
}
</script>
<div class="flex flex-col h-full">
<!-- Header section -->
{#if header}
<div class="bg-surface">
{@render header()}
</div>
{/if}
<!-- Alerts section -->
{#if alerts}
{@render alerts()}
{/if}
<!-- Controls row: "Select all" (when there are items and selection is enabled) +
optional right-side actions (e.g. a filter toggle). Renders when there are
items OR actions are provided, so a filter that empties the list doesn't take
its own toggle with it. -->
{#if items.length > 0 || selectAllActions}
<div class="px-4 py-2 flex items-center justify-between">
{#if items.length > 0 && !hideSelection}
<label
class="flex items-center gap-2 text-secondary text-xs"
class:opacity-50={!hasSelectableItems}
class:cursor-pointer={hasSelectableItems}
>
<Checkbox
disabled={!hasSelectableItems}
checked={allSelected}
onChange={allSelected ? onDeselectAll : onSelectAll}
/> Select all
</label>
{:else}
<span></span>
{/if}
{#if selectAllActions}
{@render selectAllActions()}
{/if}
</div>
{/if}
{#if items.length > 0}
<!-- Items list -->
<div class="overflow-y-auto">
<div class="border rounded-md bg-surface-tertiary">
{#each groups as group (group.key)}
{#if showGroupHeaders}
{@const selectable = groupSelectable(group)}
{@const selectedCount = selectable.filter((i) => selectedItems.includes(i.key)).length}
<!-- The label counts every ticked row, bulk-excluded ones included: those
are deployed like any other, and reporting only the bulk-selectable
ones would contradict the deploy button. The checkbox above keeps its
own count, which must ignore them to reach a full-checked state. -->
{@const selectedInGroup = group.items.filter((i) =>
selectedItems.includes(i.key)
).length}
<!-- The disabled-state hint lives on the row: a disabled Checkbox is
pointer-events-none, so a title on the input would never show. -->
<div
class="sticky top-0 z-10 flex items-center gap-2 px-4 py-1.5 bg-surface-secondary border-b first:rounded-t-md"
title={selectable.length === 0 ? 'No selectable items in this group' : undefined}
>
{#if !hideSelection}
<Checkbox
checked={selectable.length > 0 && selectedCount === selectable.length}
indeterminate={selectedCount > 0 && selectedCount < selectable.length}
disabled={selectable.length === 0}
title={selectedCount === selectable.length
? `Deselect all in ${group.label}`
: `Select all in ${group.label}`}
onChange={() => toggleGroup(group)}
/>
{/if}
{#if group.groupKind === 'folder'}
<Folder size={14} class="text-tertiary shrink-0" />
{:else if group.groupKind === 'user'}
<User size={14} class="text-tertiary shrink-0" />
{/if}
<span class="text-xs font-semibold text-secondary truncate">{group.label}</span>
<span class="text-2xs text-tertiary whitespace-nowrap">
{group.items.length} item{group.items.length !== 1 ? 's' : ''}{selectedInGroup > 0
? ` · ${selectedInGroup} selected`
: ''}
</span>
{#if groupActions}
<div class="ml-auto flex items-center gap-1">
{@render groupActions(group.items)}
</div>
{/if}
</div>
{/if}
{#each group.items as item (item.key)}
{@const isSelectable = selectablePredicate(item)}
{@const isSelected = selectedItems.includes(item.key)}
{@const status = deploymentStatus[item.key]}
{@const isDeployed = status?.status === 'deployed'}
{@const blockedReason =
!isSelectable && !isDeployed ? selectBlockedReason?.(item) : undefined}
{@const showPath =
item.kind !== 'resource' && item.kind !== 'variable' && item.kind !== 'resource_type'}
<!-- The group header already names the folder — the path line only
carries what's below it. -->
{@const subPath =
group.key && item.path.startsWith(group.key + '/')
? item.path.slice(group.key.length + 1)
: item.path}
<Row
isSelectable={!hideSelection && isSelectable && !isDeployed}
selectDisabledReason={blockedReason}
selectOnRowClick={!hideSelection}
alignWithSelectable={!hideSelection}
disabled={!hideSelection && (blockedReason ? false : !isSelectable)}
selected={isSelected && !isDeployed}
onSelect={() => handleSelect(item)}
path={showPath ? item.path : ''}
marked={undefined}
kind={item.kind}
triggerKind={item.triggerKind}
canFavorite={false}
workspaceId=""
>
{#snippet customSummary()}
{#if itemSummary}
{@render itemSummary(item)}
{:else}
{item.path}
{/if}
{/snippet}
{#snippet pathDisplay()}
{#if itemPath}
{@render itemPath(item)}
{:else}
{showPath ? (showGroupHeaders ? subPath : item.path) : ''}
{/if}
{/snippet}
{#snippet actions()}
{#if itemActions}
{@render itemActions(item)}
{/if}
<!-- Deployment status always shown -->
{#if status}
{#if status.status === 'loading'}
<Loader2 class="animate-spin" />
{:else if status.status === 'deployed'}
<Badge color="green">Deployed</Badge>
{:else if status.status === 'failed'}
<div class="inline-flex gap-1">
<Badge color="red">Failed</Badge>
<Tooltip>{status.error}</Tooltip>
</div>
{/if}
{/if}
{/snippet}
</Row>
{/each}
{/each}
</div>
</div>
{:else}
<div class="flex items-center justify-center h-full">
<div class="text-hint text-xs">{emptyMessage}</div>
</div>
{/if}
<!-- Footer -->
{#if footer}
<div class="pt-4">
{@render footer()}
</div>
{/if}
</div>