Files
windmill/frontend/src/lib/components/CompareDrafts.svelte
T
8dbdfa1949 feat: workspace roles settings UI and capability-based gating (#11824)
* feat: configurable workspace roles with per-workspace overrides

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

* fix: close role review gaps in pickers, declarative sync and removal

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

* docs: keep resolve_role_name doc on its function

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

* fix: validate overrides on instance role changes and bill admins fully

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

* fix: allow pipeline drafts and keep role checks on one connection

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

* docs: list data pipelines in operator_can_draft

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

* test: expect a 403 role refusal when an operator resolves jobs

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

* feat: require a valid license for custom workspace roles

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

* fix: freeze workspace roles when the license lapses

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

* chore: pin the ee companion for workspace roles

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

* refactor: broadcast role override changes from the write path, not a trigger

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

* chore: update ee-repo-ref to b5294d38db082bdd104d5358a47695390694c05d

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

Previous ee-repo-ref: 688e00c5982865b14098f530d54280e2311994c0

New ee-repo-ref: b5294d38db082bdd104d5358a47695390694c05d

Automated by sync-ee-ref workflow.

* feat: add workspace roles settings UI and role pickers

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

* fix: show the server's reason when a settings save fails

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

* fix: default role pickers to a defined role and confirm resets of unknown capabilities

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

* fix: treat null role capabilities as unset and key role drafts by map

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

* feat: gate the frontend on workspace role capabilities

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

* fix: gate deploys, bulk actions and chat composition per role kind

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

* fix: treat a superadmin as full in deploy permission checks

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

* fix: leave page visibility out of isRestricted, as the server does

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

* fix: let a member discard their own draft without the authoring right

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

* fix: show custom roles as inactive without a valid license

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

* fix: keep the user store import on the agent page

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

* fix: show custom roles as frozen without a valid license

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

* fix: reorder instance roles with a drag handle

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

* fix: let an admin row that kept the operator flag hold full rights everywhere

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

* fix: treat superadmin members as admins and return role names from get user

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

* fix: move the instance role label into the expanded panel

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-21 16:31:04 +02:00

948 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 { capabilitiesIn } from '$lib/roleCapabilities'
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),
capabilitiesIn(
// Superadmin is global; a workspace's admin flag is not.
{ is_super_admin: $userStore?.is_super_admin },
$userWorkspaces.find((w) => w.id === currentWorkspaceId)
)
)
)
// 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>