Files
windmill/frontend/src/lib/components/CompareDrafts.svelte
T
AlexRV12andClaude Opus 5 ef8a8e821c fix: check direct-deployment lock and superadmin in the deploy preflight (#10748)
* fix: check direct-deployment lock and superadmin in the deploy preflight

`checkDeployPermission` mirrors the server's `check_deploy_rules` so the deploy
UI can disable an action with a reason instead of letting the click come back
403. It modelled only `RestrictDeployToDeployers`, leaving two terms out:

- `DisableDirectDeployment` was never evaluated. In a workspace carrying only
  that rule the preflight allowed the deploy and the request 403'd.
- The server bypasses on `ApiAuthed.is_admin`, which is `usr.is_admin ||
  super_admin`, while `whoami` reports the two separately. A superadmin who is
  a plain member of the workspace was refused a deploy the server allows.

Evaluate `DisableDirectDeployment` first, as the server does, so the same
message wins when both rules block, and add the superadmin term to the shared
ruleset bypass helper. `wm_deployers` membership is an implicit pass on
`RestrictDeployToDeployers` alone, so it no longer short-circuits the rules
fetch the way admin does — a deployer is still bound by a direct-deployment
lock, and a test pins that.

The operator refusal stays above the admin/superadmin short-circuit: the server
refuses operators in the item handlers whatever their global role, so a
superadmin who is an operator in the workspace is still refused. Its doc no
longer presents that term as part of the `check_deploy_rules` mirror, since the
rule carries no operator term and refusing every kind here is deliberately
stricter than the server.

Callers no longer name which rules the preflight covers. That list rots at every
site that repeats it, so it lives only at the preflight itself.

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

* fix: apply the direct-deployment refusal only to the kinds the server gates

`check_deploy_rules` runs from the item handlers, and only scripts, flows, apps,
resources, resource types, variables and folders reach it. Schedules and
triggers hit no gate at all: in a `DisableDirectDeployment` workspace the server
returns 200 for a schedule and 403 for a script.

The preflight answers per workspace, and that one answer disabled the deploy
action for every kind, so adding the direct-deployment term would have blocked
schedule and trigger deploys the server accepts. Tag each refusal with the term
that produced it and let callers narrow a direct-deployment refusal to the kinds
the server actually gates; a selection still blocks as soon as one gated kind is
in it.

The deployers-only term keeps applying to every kind. It over-reaches the same
way, but narrowing it would loosen the UI beyond mirroring the new rule, so it
stays as it is and no existing behaviour changes.

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

* fix: mirror the superadmin bypass in the per-item deploy checks too

`checkPathWritePermission` and `canPreserveOnBehalfOf` still tested `is_admin`
alone. The server reads the merged `ApiAuthed.is_admin` in both places —
`is_owner` for path ownership and `can_preserve_on_behalf_of` for the deploy
identity — so a superadmin who is a plain member was refused a write the server
accepts: creating a script in a folder owned by someone else returns 201 for
them.

Also drop the rule enumeration from the session deploy guard's comment, which
named the operator and deployer rules for a preflight that now covers the
direct-deployment lock and answers per kind.

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

* fix: keep the deploy refusal on an empty selection and match the advice to the fork lock

* fix: mirror the superadmin bypass in the compare page's on-behalf-of gate

* docs: name the variable that tracks the deploy direction

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:01:46 +02:00

942 lines
34 KiB
Svelte

<script lang="ts">
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
import { maskHasDraftRow } from './sessions/modifiedItemsMask'
import { pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
import DiffDrawer from './DiffDrawer.svelte'
import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte'
import DraftBadge from './DraftBadge.svelte'
import Toggle from './Toggle.svelte'
import Popover from './meltComponents/Popover.svelte'
import { Badge } from './common'
import Button from './common/button/Button.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import { AlertTriangle, ArrowRight, DiffIcon, GitFork, Pencil, Undo2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte'
import { editUrlFor } from './sessions/forkEditUrl'
import { AppService, FlowService, ScriptService, type WorkspaceItemDiff } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import {
getDraftDiffValues,
deployDraft,
discardDraft,
draftBaseIsStale
} from '$lib/utils_draft_deploy'
import {
checkDeployPermission,
deployPermissionForKinds,
type DeployPermission
} from '$lib/utils_workspace_deploy'
import {
type DraftItem,
invalidateWorkspaceDrafts,
useWorkspaceDrafts
} from '$lib/workspaceDrafts.svelte'
import type { Kind as LayoutKind } from '$lib/utils_deployable'
import { userStore, userWorkspaces } from '$lib/stores'
import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel'
interface Props {
currentWorkspaceId: string
/** The Draft Items to review, owned by the page's Workspace Drafts resource
* and passed down so we don't mount a second resource (which would double
* the list fetches). Deploy/discard here invalidate that resource, so the
* list refetches upstream and the new `draftItems` flow back in. */
draftItems: DraftItem[]
/** True while the page's Workspace Drafts resource is loading. */
draftsLoading?: boolean
/** Fork context drives the parent-relative draft handling (the
* unchanged-from-parent filter), not the toggle. */
isFork?: boolean
parentWorkspaceId?: string
/** The workspace the comparison view merges into — the parent, or an
* arbitrary target. Its presence is what the toggle needs: without one there
* is no comparison to switch back to, so the toggle is hidden. */
compareTargetId?: string
/** The comparison is one-way, so the toggle drops the update direction. */
oneWayCompare?: boolean
deployCount?: number
updateCount?: number
draftCount?: number
/** When set (reached via a session's Review button), preselect only the
* rows this chat modified — `${UserDraftItemKind}:${path}` keys, matching
* Row.key. Undefined → preselect all actionable rows (the default). All
* rows are still shown either way. */
chatMask?: Set<string>
/** False while the (async) chatMask is still loading. The select-all default
* waits for this so it doesn't race the mask and select everything. Defaults
* to true for callers that don't pass a mask. */
chatMaskReady?: boolean
/** Selecting deploy_to/update asks the page to swap to CompareWorkspaces. */
onModeSelected?: (v: CompareMode) => void
/** Fired after a deploy/discard so the page can refresh the *fork*
* comparison (ahead/behind). The Draft Count refreshes itself — deploy/
* discard invalidate the Workspace Drafts resource. */
onChanged?: () => void
}
let {
currentWorkspaceId,
draftItems,
draftsLoading = false,
isFork = false,
parentWorkspaceId,
compareTargetId,
oneWayCompare = false,
deployCount = 0,
updateCount = 0,
draftCount = 0,
chatMask,
chatMaskReady = true,
onModeSelected,
onChanged
}: Props = $props()
type Row = {
/** The deploy layout's `Kind` naming (`http_trigger`, `schedule`,
* ...) — the layout reads `item.kind` for the row icon. Our
* `UserDraftItemKind` naming lives in `draftKind`. */
kind: LayoutKind
draftKind: DraftItem['kind']
path: string
/** Friendly path for display (storage `path` stays the key for all
* fetch/deploy/discard calls — the draft is keyed by it server-side). */
draft_path?: string
summary?: string
draft_only: boolean
legacy_draft: boolean
raw_app: boolean
key: string
can_write: boolean
draft_users?: { username?: string | null }[]
/** The row is my own draft (or the legacy no-owner row) — only then is it
* actionable. Other users' rows (shown when "Show all drafts" is on) are
* view-only: you can't deploy/discard someone else's draft. */
mine: boolean
/** Fork only: true when this draft is identical to the parent's (inherited
* on fork, never edited here). Drives "Hide unchanged drafts". */
unchanged_from_parent?: boolean
}
function getItemKey(kind: string, path: string): string {
return `${kind}:${path}`
}
// UserDraftItemKind → the deploy layout's Kind union (drives row icons).
// Trigger kinds swap the prefix to a suffix; kinds the layout doesn't
// know (webhook, native triggers) borrow the generic 'trigger' icon.
function toLayoutKind(kind: DraftItem['kind']): LayoutKind {
if (kind === 'trigger_schedule') return 'schedule'
if (kind === 'trigger_default_email') return 'email_trigger'
if (kind.startsWith('trigger_')) {
const candidate = `${kind.slice('trigger_'.length)}_trigger`
const known = [
'http_trigger',
'websocket_trigger',
'kafka_trigger',
'nats_trigger',
'postgres_trigger',
'mqtt_trigger',
'amqp_trigger',
'sqs_trigger',
'gcp_trigger',
'azure_trigger',
'email_trigger'
]
return (known.includes(candidate) ? candidate : 'trigger') as LayoutKind
}
return kind as LayoutKind
}
// "Show all drafts" widens the list from my own (+ legacy) to every user's
// drafts in the workspace. Off by default. The default view reuses the page's
// shared Workspace Drafts resource (passed in via `draftItems`); the "all
// users" superset is fetched lazily here via its own resource — only while the
// toggle is on (workspace() is undefined otherwise, so no fetch) — and shares
// the same invalidation, so a deploy/discard refetches both.
let showAll = $state(false)
const allDrafts = useWorkspaceDrafts(
() => (showAll ? currentWorkspaceId : undefined),
() => true,
() => (isFork ? parentWorkspaceId : undefined)
)
const sourceItems = $derived(showAll ? allDrafts.items : draftItems)
const loading = $derived(showAll ? allDrafts.loading : draftsLoading)
// On by default: a fork inherits the parent's drafts on creation, and those
// (unrelated to the fork's own work) are the common case worth hiding.
let hideUnchanged = $state(true)
const currentNoun = $derived(
childWorkspaceNoun($userWorkspaces.find((w) => w.id === currentWorkspaceId))
)
// The list (and, in the default view, the Draft Count) come from the Workspace
// Drafts module; deploy/discard invalidate the resource, so the list refetches
// and deployed items drop off without a manual reload here.
const items: Row[] = $derived(
sourceItems.map((d) => ({
...d,
key: getItemKey(d.kind, d.path),
kind: toLayoutKind(d.kind),
draftKind: d.kind,
draft_path: d.draft_path,
legacy_draft: d.legacy_draft
}))
)
const currentUsername = $derived($userStore?.username)
// Other real users (not me, not the legacy NULL-email row) who also drafted
// this path. Only the shared full-page-editor kinds carry draft_users, so this
// is naturally empty for drawer kinds. Deploying only deploys my own draft, so
// a non-empty list warrants the triangle warning.
function otherDraftUsers(row: Row): string[] {
return (row.draft_users ?? [])
.map((u) => u.username)
.filter((u): u is string => !!u && u !== currentUsername)
}
// The backend returns exactly the rows for the current view; the only
// client-side filter is "Hide unchanged drafts".
const visibleItems = $derived(
isFork && hideUnchanged ? items.filter((i) => i.unchanged_from_parent !== true) : items
)
// Selection gate: a row is actionable when it isn't already deployed this
// session AND it's the user's own draft (someone else's shows view-only in the
// "all drafts" view). A data-pipeline bundle is excluded — its scripts deploy
// individually inside the pipeline view. Every selectable row can at least be
// discarded: discarding your own email-scoped draft never needs write
// permission on the path — only legacy (ownerless) drafts stay write-gated,
// mirroring the server's discard check.
function isDiscardable(item: Row): boolean {
return (
deploymentStatus[item.key]?.status !== 'deployed' &&
item.mine &&
item.draftKind !== 'data_pipeline' &&
(!item.legacy_draft || item.can_write)
)
}
// Deploying additionally requires write permission on the path, so the Deploy
// count can be lower than the selection when a drafted path lost writability.
function isDeployable(item: Row): boolean {
return isDiscardable(item) && item.can_write
}
// Why a row can't be selected (drives the disabled-checkbox tooltip).
// `undefined` ⇒ selectable.
function blockedReason(item: Row): string | undefined {
if (!item.mine) return 'This draft belongs to another user'
if (item.legacy_draft && !item.can_write)
return 'Discarding a legacy draft requires write permission on the path'
return undefined
}
// Why a row can't be discarded (drives the Discard button's title).
// Discarding only removes the caller's own draft row, so it doesn't require
// write permission on the path — except for legacy (ownerless) drafts, which
// the server write-gates like a deploy.
function discardBlockedReason(item: Row): string | undefined {
if (!item.mine) return 'This draft belongs to another user'
if (item.legacy_draft && !item.can_write)
return 'Discarding a legacy draft requires write permission on the path'
return undefined
}
// The Draft Items list only carries the *deployed* summary, so the draft's
// (new) display name isn't known yet. Fetch each item's draft blob once and
// cache both names — mirrors CompareWorkspaces' fetchSummaries (eager on load,
// keyed by row key) so the rename rendering is shared and consistent. Only
// non-`draft_only` items can show a rename: a `draft_only` item has no deployed
// side to diff the name against. Raw apps are fetched via the apps endpoint too
// (it auto-detects raw from the deployed row and overlays the raw_app draft).
const summaryCache = $state<
Record<string, { deployed?: string; draft?: string; stale?: boolean; loading?: boolean }>
>({})
async function fetchDraftSummary(item: Row) {
if (summaryCache[item.key]) return
summaryCache[item.key] = { loading: true }
try {
const r = (await (item.draftKind === 'script'
? ScriptService.getScriptByPath({
workspace: currentWorkspaceId,
path: item.path,
getDraft: true
})
: item.draftKind === 'flow'
? FlowService.getFlowByPath({
workspace: currentWorkspaceId,
path: item.path,
getDraft: true
})
: AppService.getAppByPath({
workspace: currentWorkspaceId,
path: item.path,
getDraft: true
}))) as any
const draftBlob = r.draft as any
summaryCache[item.key] = {
deployed: r.summary,
draft: draftBlob?.summary,
stale: draftBaseIsStale(item.draftKind, r),
loading: false
}
} catch (error) {
console.error(`Failed to fetch draft summary for ${item.kind}:${item.path}`, error)
summaryCache[item.key] = { loading: false }
}
}
$effect(() => {
const current = items
untrack(() => {
for (const item of current) {
if (
item.mine &&
!item.draft_only &&
(['script', 'flow', 'app'].includes(item.draftKind) || item.raw_app) &&
!summaryCache[item.key]
) {
void fetchDraftSummary(item)
}
}
})
})
let selectedItems = $state<string[]>([])
let deploying = $state(false)
// Whether the user may deploy drafts into this workspace, via the shared util —
// same as the fork compare page and the session review drawer. Fail-open while
// resolving.
let workspaceDeployPerm = $state<DeployPermission>({ ok: true })
$effect(() => {
const ws = currentWorkspaceId
// Reset to fail-open on workspace change, and drop a stale resolution —
// otherwise the previous workspace's verdict lingers (or lands last) and
// gates the wrong workspace.
workspaceDeployPerm = { ok: true }
void checkDeployPermission(ws).then((p) => {
if (ws === currentWorkspaceId) workspaceDeployPerm = p
})
})
// A direct-deployment lock never reaches trigger or schedule drafts server-side, so it must
// not disable a selection made only of those. One refused kind still blocks the whole action.
let deployPerm = $derived(
deployPermissionForKinds(
workspaceDeployPerm,
visibleItems
.filter((i) => selectedItems.includes(i.key) && isDeployable(i))
.map((i) => i.draftKind)
)
)
// Select all on the first non-empty load (acting on everything is the common
// intent); only once, so a refetch after a deploy doesn't re-select the
// leftovers.
let hasAutoSelected = $state(false)
const deploymentStatus: Record<
string,
{ status: 'loading' | 'deployed' | 'failed'; error?: string }
> = $state({})
// Prune transient deploy status for items no longer in the list (a deployed
// item drops off after the resource refetches). Keeps the map from growing
// unbounded and avoids a stale 'deployed' entry suppressing a row if the same
// kind:path is re-drafted within this mount.
$effect(() => {
const live = new Set(items.map((i) => i.key))
untrack(() => {
for (const key of Object.keys(deploymentStatus)) {
if (!live.has(key)) delete deploymentStatus[key]
}
})
})
$effect(() => {
if (!hasAutoSelected && chatMaskReady && visibleItems.length > 0) {
// Default intent is act-on-all; when reached from a session's Review
// (chatMask set), preselect only that chat's items instead.
const selectable = visibleItems.filter(isDiscardable)
selectedItems = (
chatMask
? selectable.filter((i) =>
maskHasDraftRow(chatMask, {
kind: i.draftKind,
path: i.path,
draft_path: i.draft_path
})
)
: selectable
).map((i) => i.key)
hasAutoSelected = true
}
})
// Selected items still in the visible list, per action. Derived (not a
// pruning effect) so the footer buttons stay reactive to the Workspace
// Drafts resource: deploy/discard drop items, and stale keys left in
// selectedItems are simply ignored here (and by the action handlers).
let deployableCount = $derived(
visibleItems.filter((i) => selectedItems.includes(i.key) && isDeployable(i)).length
)
let discardableCount = $derived(
visibleItems.filter((i) => selectedItems.includes(i.key) && isDiscardable(i)).length
)
// Selected rows the user can discard but not deploy (own draft on a path
// without write permission) — surfaced under the footer so the diverging
// button counts are explained.
let undeployableSelectedCount = $derived(discardableCount - deployableCount)
let allSelected = $derived(
visibleItems.filter(isDiscardable).length > 0 &&
visibleItems.filter(isDiscardable).every((i) => selectedItems.includes(i.key))
)
function toggleItem(item: { key: string }) {
if (selectedItems.includes(item.key)) {
selectedItems = selectedItems.filter((k) => k !== item.key)
} else {
selectedItems = [...selectedItems, item.key]
}
}
function selectAll() {
selectedItems = visibleItems.filter(isDiscardable).map((i) => i.key)
}
function deselectAll() {
selectedItems = []
}
// --- Diff ---
let diffDrawer: DiffDrawer | undefined = $state(undefined)
let isFlow = $state(false)
// Monotonic token so that two quick "Show diff" clicks don't race: a slower
// earlier fetch must not overwrite a faster later one in the (single) drawer.
let diffRequestId = 0
async function showDiff(item: Row) {
if (!diffDrawer) return
const reqId = ++diffRequestId
isFlow = item.draftKind === 'flow'
diffDrawer.openDrawer()
const { deployed, draft } = await getDraftDiffValues(
item.draftKind,
item.path,
currentWorkspaceId,
item.draft_only
)
// A newer Show-diff click superseded this one — drop the stale result.
if (reqId !== diffRequestId) return
diffDrawer.setDiff({
mode: 'simple',
original: deployed as any,
current: draft as any,
title: 'Deployed → Draft'
})
}
// --- Deploy ---
async function deploySelected() {
deploying = true
// Snapshot the items to deploy: deployDraft invalidates the Workspace Drafts
// resource, so `items` can change mid-loop — iterate a stable copy. Guard on
// isDeployable so a non-writable row can never be deployed via a stale key.
const toDeploy = visibleItems.filter((i) => selectedItems.includes(i.key) && isDeployable(i))
let deployedAny = false
for (const item of toDeploy) {
deploymentStatus[item.key] = { status: 'loading' }
const res = await deployDraft(item.draftKind, item.path, currentWorkspaceId, {
draftOnly: item.draft_only,
rawApp: item.raw_app
})
if (res.success) {
deploymentStatus[item.key] = { status: 'deployed' }
deployedAny = true
} else {
deploymentStatus[item.key] = { status: 'failed', error: res.error }
sendUserToast(`Failed to deploy ${item.path}: ${res.error}`, true)
}
}
deploying = false
selectedItems = []
// The Draft list refetches itself (deployDraft invalidated it). Deploying
// also changes the fork comparison (ahead/behind) — ask the page to refresh
// that.
if (deployedAny) onChanged?.()
}
// --- Discard ---
// Only one discard is destructive: removing the last draft of a never-deployed
// item (draft_only, and no other user still holds a draft) permanently deletes
// the item, so it gets a confirmation. Every other discard just reverts to the
// deployed version or removes your own copy while another draft remains — those
// run immediately (the row already carries the ⚠️ for the multi-user case).
let discardTarget = $state<Row | undefined>(undefined)
function isDestructiveDiscard(item: Row): boolean {
// A deployed counterpart exists → discard just reverts, never deletes.
if (!item.draft_only) return false
// draft_only → discarding deletes the item, UNLESS another real user still
// holds a draft of it. Guard on `currentUsername`: if we don't yet know who
// "me" is, `otherDraftUsers` would count my own row as someone else's, so
// fall back to treating it as a delete (confirm) rather than risk a silent
// deletion.
if (!currentUsername) return true
return otherDraftUsers(item).length === 0
}
function onDiscardClick(item: Row) {
if (isDestructiveDiscard(item)) {
discardTarget = item
} else {
void doDiscard(item)
}
}
async function doDiscard(item: Row) {
const res = await discardDraft(
item.draftKind,
item.path,
currentWorkspaceId,
item.draft_only,
item.legacy_draft
)
if (res.success) {
sendUserToast(item.draft_only ? `Deleted ${item.path}` : `Discarded draft of ${item.path}`)
// discardDraft invalidated the Draft list; refresh the fork comparison.
onChanged?.()
} else {
sendUserToast(`Failed to discard ${item.path}: ${res.error}`, true)
}
}
function confirmDiscard() {
const item = discardTarget
discardTarget = undefined
if (item) void doDiscard(item)
}
// --- Bulk discard ---
// One click can drop many drafts at once, and some of them (draft_only with
// no other drafter) are permanent deletions — always confirm, listing the
// permanent ones explicitly.
let bulkDiscardItems = $state<Row[] | undefined>(undefined)
let discarding = $state(false)
const bulkPermanent = $derived((bulkDiscardItems ?? []).filter(isDestructiveDiscard))
// Third outcome the modal must cover: a draft-only item someone else also
// drafted isn't deleted — only this user's draft goes; the item survives via
// the other drafts.
const bulkSharedCount = $derived(
(bulkDiscardItems ?? []).filter((i) => i.draft_only && !isDestructiveDiscard(i)).length
)
function onDiscardSelectedClick() {
const toDiscard = visibleItems.filter((i) => selectedItems.includes(i.key) && isDiscardable(i))
if (toDiscard.length > 0) bulkDiscardItems = toDiscard
}
async function discardSelected(toDiscard: Row[]) {
discarding = true
let changed = false
for (const item of toDiscard) {
deploymentStatus[item.key] = { status: 'loading' }
// invalidate: false — one refetch after the whole batch (below), not
// one per row.
const res = await discardDraft(
item.draftKind,
item.path,
currentWorkspaceId,
item.draft_only,
item.legacy_draft,
false
)
if (res.success) {
changed = true
delete deploymentStatus[item.key]
} else {
deploymentStatus[item.key] = { status: 'failed', error: res.error }
sendUserToast(`Failed to discard ${item.path}: ${res.error}`, true)
}
}
discarding = false
selectedItems = []
if (changed) {
// Refetch the Draft list once for the batch, then refresh the fork
// comparison.
invalidateWorkspaceDrafts(currentWorkspaceId)
onChanged?.()
}
}
function confirmBulkDiscard() {
const toDiscard = bulkDiscardItems
bulkDiscardItems = undefined
if (toDiscard) void discardSelected(toDiscard)
}
// Editor URL for a draft item, scoped to the current workspace. Raw apps live
// under a different editor route, so map their kind accordingly. Kinds whose
// editor is a drawer on a list page (variables, resources, schedules,
// triggers) link to that page with the item path as the hash anchor.
const LIST_PAGE_FOR_KIND: Partial<Record<Row['draftKind'], string>> = {
variable: '/variables',
resource: '/resources',
trigger_schedule: '/schedules',
trigger_http: '/routes',
trigger_websocket: '/websocket_triggers',
trigger_postgres: '/postgres_triggers',
trigger_kafka: '/kafka_triggers',
trigger_nats: '/nats_triggers',
trigger_mqtt: '/mqtt_triggers',
trigger_amqp: '/amqp_triggers',
trigger_sqs: '/sqs_triggers',
trigger_gcp: '/gcp_triggers',
trigger_azure: '/azure_triggers',
trigger_email: '/email_triggers'
}
function draftEditUrl(d: Row): string | undefined {
if (d.draftKind === 'data_pipeline') {
const folder = pipelineFolderFromBundlePath(d.path)
return folder
? `/pipeline/${encodeURIComponent(folder)}?workspace=${encodeURIComponent(currentWorkspaceId)}`
: undefined
}
const listPage = LIST_PAGE_FOR_KIND[d.draftKind]
if (listPage) {
return `${listPage}?workspace=${encodeURIComponent(currentWorkspaceId)}#${d.path}`
}
return editUrlFor(
{ kind: d.raw_app ? 'raw_app' : d.draftKind, path: d.path } as unknown as WorkspaceItemDiff,
currentWorkspaceId
)
}
// Auto-generated draft slot: `u/{user}/draft_{uuid}` (uuid dashes → underscores),
// minted for a never-named draft. We don't surface this synthetic id as a row's
// bold title.
const AUTO_GEN_DRAFT_RE = /(^|\/)draft_[0-9a-f]{8}(_[0-9a-f]{4}){3}_[0-9a-f]{12}$/
// Bold title for a row: the friendly typed path if the draft carries one, else
// the storage path — with `{user}` truncated at `@` (the admins workspace and
// email-as-username setups put the full email in the namespace). The real
// path/key (used for fetch/deploy/discard) is left untouched. Returns '' for an
// auto-generated `draft_{uuid}` path so it isn't shown in bold (the row still
// shows the storage path in its secondary line).
function displayPath(d: Row): string {
// The pipeline bundle's storage path (`f/<folder>/data_pipeline`) is an
// implementation detail — show the folder it belongs to.
if (d.draftKind === 'data_pipeline') {
const folder = pipelineFolderFromBundlePath(d.path)
return folder ? `f/${folder}` : d.path
}
const path = d.draft_path ?? d.path
if (AUTO_GEN_DRAFT_RE.test(path)) return ''
const segs = path.split('/')
if (segs[0] === 'u' && segs.length >= 2) {
const at = segs[1].indexOf('@')
if (at > 0) segs[1] = segs[1].slice(0, at)
}
return segs.join('/')
}
// Human label for the kind badge on each row — without it a variable
// draft and a script draft at the same path are indistinguishable.
function kindLabel(kind: Row['draftKind']): string {
if (kind === 'raw_app') return 'app'
if (kind === 'data_pipeline') return 'pipeline'
if (kind === 'trigger_schedule') return 'schedule'
if (kind.startsWith('trigger_')) return `${kind.slice('trigger_'.length)} trigger`
return kind
}
</script>
<div class="flex flex-col gap-4">
<div class="bg-surface-tertiary p-4 rounded-md border">
<WorkspaceDeployLayout
items={visibleItems}
{selectedItems}
{deploymentStatus}
{allSelected}
selectablePredicate={(item) => isDiscardable(item as unknown as Row)}
selectBlockedReason={(item) => blockedReason(item as unknown as Row)}
onToggleItem={toggleItem}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
emptyMessage={loading
? 'Loading drafts…'
: isFork && hideUnchanged && items.length > 0
? 'All drafts are unchanged from the parent workspace. Turn off "Hide unchanged drafts" to show them.'
: showAll
? 'No drafts in this workspace'
: 'No drafts you authored in this workspace'}
>
{#snippet selectAllActions()}
<div class="flex items-center gap-4">
{#if isFork}
<Toggle
bind:checked={hideUnchanged}
size="xs"
options={{
right: 'Hide unchanged drafts',
rightTooltip: `Hide drafts identical to the parent workspace. A ${currentNoun} inherits the parent's drafts when it's created; those are unrelated to the changes made in this ${currentNoun}.`
}}
/>
{/if}
<Toggle
bind:checked={showAll}
size="xs"
options={{
right: 'Show all drafts',
rightTooltip:
"Show every user's drafts in this workspace, not just yours. Others' drafts are view-only — you can only deploy or discard your own."
}}
/>
</div>
{/snippet}
{#snippet header()}
{#if compareTargetId}
<div class="flex flex-wrap gap-1 items-center bg-surface-tertiary pb-4">
<CompareModeToggle
selected="draft"
isFork={true}
oneWay={oneWayCompare}
parentWorkspaceId={compareTargetId}
{deployCount}
{updateCount}
{draftCount}
disabled={deploying}
onSelected={(v) => onModeSelected?.(v)}
/>
<!-- Direction badge, mirroring the fork compare header: make it explicit
that deploying a draft promotes it *within this fork* (deployed↔draft),
not up to the parent. -->
<div class="flex-1 flex gap-1 items-center">
<Badge color="transparent" class="ml-5 font-semibold">
<span class="text-secondary">deploy:</span>
<Pencil size={14} />
<span class="text-emphasis">draft</span>
</Badge>
<ArrowRight size={16} />
<Badge color="transparent" class="font-semibold" title={currentWorkspaceId}>
<span class="text-secondary">into:</span>
<GitFork size={14} />
<span class="text-emphasis">{currentWorkspaceId}</span>
</Badge>
</div>
</div>
{/if}
{/snippet}
{#snippet itemSummary(item)}
{@const draftItem = item as unknown as Row}
{@const editUrl = draftEditUrl(draftItem)}
{@const cache = summaryCache[draftItem.key]}
{@const oldSummary = cache?.deployed ?? draftItem.summary}
{@const newSummary = cache?.draft ?? draftItem.summary}
<WorkspaceDeployItemSummary
path={displayPath(draftItem)}
{editUrl}
{oldSummary}
{newSummary}
renamed={!draftItem.draft_only &&
!!oldSummary &&
!!newSummary &&
oldSummary !== newSummary}
/>
{/snippet}
{#snippet itemPath(item)}
{@const draftItem = item as unknown as Row}
{#if draftItem.kind === 'resource' || draftItem.kind === 'variable' || draftItem.kind === 'resource_type'}
<!-- drawer-only items show their path as the title; keep this line empty -->
{:else if !draftItem.draft_only && draftItem.draft_path && draftItem.draft_path !== draftItem.path}
<!-- Path rename: a *deployed* item's draft moves it to a new path. Strike
the deployed path and show the draft's target path. Draft-only items are
excluded — their storage path is an auto-generated `draft_{uuid}` and
`draft_path` is just the pretty name, not a rename. -->
<span class="line-through">{draftItem.path}</span>
{draftItem.draft_path}
{:else}
{draftItem.draft_path ?? draftItem.path}
{/if}
{/snippet}
{#snippet itemActions(item)}
{@const draftItem = item as unknown as Row}
{@const others = otherDraftUsers(draftItem)}
<Badge color="gray" size="xs">{kindLabel(draftItem.draftKind)}</Badge>
<DraftBadge
is_draft={true}
draft_only={draftItem.draft_only}
draft_users={draftItem.draft_users ?? []}
{currentUsername}
workspace={currentWorkspaceId}
itemKind={draftItem.draftKind}
path={draftItem.path}
allowFork={false}
/>
{#if draftItem.mine && others.length > 0}
<Popover openOnHover debounceDelay={50}>
{#snippet trigger()}
<AlertTriangle size={16} class="text-yellow-500" />
{/snippet}
{#snippet content()}
<div class="text-xs p-3 max-w-xs text-primary">
{others.length} other {others.length === 1 ? 'user' : 'users'} ({others.join(', ')})
{others.length === 1 ? 'has' : 'have'} a draft of this item. Deploying only deploys your
draft; theirs are left untouched.
</div>
{/snippet}
</Popover>
{/if}
{#if draftItem.mine && summaryCache[draftItem.key]?.stale}
<Popover openOnHover debounceDelay={50}>
{#snippet trigger()}
<AlertTriangle size={16} class="text-orange-500" />
{/snippet}
{#snippet content()}
<div class="text-xs p-3 max-w-xs text-primary">
Started from an older deployed version. A newer version was deployed after this
draft began. Review the latest deploy before deploying.
</div>
{/snippet}
</Popover>
{/if}
{#if deploymentStatus[draftItem.key]?.status !== 'deployed'}
{#if draftItem.draftKind === 'data_pipeline'}
<!-- A bundle isn't diffable/deployable here — its scripts deploy
individually inside the pipeline view. -->
{@const openUrl = draftEditUrl(draftItem)}
{#if openUrl}
<Button
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: ArrowRight }}
href={openUrl}
>
Open pipeline
</Button>
{/if}
{:else}
{@const discardBlock = discardBlockedReason(draftItem)}
<!-- Show diff fetches the *current user's* draft overlay, so it's only
meaningful for your own/legacy rows. Another user's draft (view-only,
`mine=false`) would diff against the wrong draft or 404 — hide it. -->
{#if draftItem.mine}
<Button
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: DiffIcon }}
onClick={() => showDiff(draftItem)}
>
Show diff
</Button>
{/if}
<Button
unifiedSize="xs"
variant="subtle"
destructive
disabled={!!discardBlock}
title={discardBlock}
startIcon={{ icon: Undo2 }}
onClick={() => onDiscardClick(draftItem)}
>
Discard draft
</Button>
{/if}
{/if}
{/snippet}
{#snippet footer()}
<div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-2">
<Button
variant="default"
destructive
disabled={discardableCount === 0 || deploying || discarding}
loading={discarding}
startIcon={{ icon: Undo2 }}
onClick={onDiscardSelectedClick}
>
Discard {discardableCount} draft{discardableCount !== 1 ? 's' : ''}
</Button>
<Button
variant="accent"
disabled={deployableCount === 0 || deploying || discarding || !deployPerm.ok}
title={!deployPerm.ok ? deployPerm.reason : undefined}
loading={deploying}
onClick={deploySelected}
>
Deploy {deployableCount} draft{deployableCount !== 1 ? 's' : ''}
</Button>
</div>
{#if !deployPerm.ok}
<span class="text-xs text-yellow-600">{deployPerm.reason}</span>
{:else if undeployableSelectedCount > 0}
<span class="text-xs text-secondary">
{undeployableSelectedCount} selected draft{undeployableSelectedCount !== 1 ? 's' : ''}
can't be deployed (no write permission on the path) but can still be discarded
</span>
{/if}
</div>
{/snippet}
</WorkspaceDeployLayout>
</div>
<DiffDrawer bind:this={diffDrawer} {isFlow} />
</div>
<ConfirmationModal
open={bulkDiscardItems !== undefined}
title="Discard selected drafts"
confirmationText="Discard"
onConfirmed={confirmBulkDiscard}
onCanceled={() => (bulkDiscardItems = undefined)}
>
<p>
This will discard {bulkDiscardItems?.length} draft{(bulkDiscardItems?.length ?? 0) !== 1
? 's'
: ''}. Items with a deployed version revert to it.
</p>
{#if bulkSharedCount > 0}
<p class="mt-2">
{bulkSharedCount} draft-only {bulkSharedCount === 1 ? 'item is' : 'items are'} also drafted by
other users: only your draft is removed and the {bulkSharedCount === 1 ? 'item' : 'items'} will
remain through theirs.
</p>
{/if}
{#if bulkPermanent.length > 0}
<p class="mt-2">
{bulkPermanent.length}
{bulkPermanent.length === 1 ? 'item exists' : 'items exist'} only as a draft and will be
<span class="font-semibold">permanently deleted</span>:
</p>
<ul class="list-disc list-inside font-mono text-xs mt-1">
{#each bulkPermanent as item (item.key)}
<li>{item.draft_path ?? item.path}</li>
{/each}
</ul>
{/if}
</ConfirmationModal>
<!-- Only the destructive discard (deleting the last draft of a never-deployed
item) opens this modal; non-destructive discards run without confirmation. -->
<ConfirmationModal
open={discardTarget !== undefined}
title="Delete item"
confirmationText="Delete"
onConfirmed={confirmDiscard}
onCanceled={() => (discardTarget = undefined)}
>
<p>
<span class="font-mono font-medium text-primary"
>{discardTarget?.draft_path ?? discardTarget?.path}</span
> exists only as a draft. Discarding it will permanently delete the item. This cannot be undone.
</p>
</ConfirmationModal>