feat: bulk discard selected drafts on the compare & deploy page (#10372)

* feat(frontend): bulk discard selected drafts on the Compare & Deploy page

Add a destructive "Discard N drafts" button next to "Deploy N drafts" in
draft mode. A single confirmation modal lists draft-only items (permanent
deletions) by path before discarding sequentially with per-row status.

Split the selection gate into isDiscardable (own draft, not deployed this
session, not a data-pipeline bundle) and isDeployable (+ can_write): the
server lets you discard your own draft on a path you can no longer write
to, so each footer button counts its own eligible selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): address review findings on bulk draft discard

- invalidate the workspace-drafts resource once per batch instead of once
  per discarded row (discardDraft gains an invalidate opt-out)
- write-gate legacy (ownerless) drafts in isDiscardable + tooltips,
  mirroring the server's discard check
- explain diverging footer counts with a hint when selected drafts are
  discardable but not deployable
- update selection-contract comments left over from the deploy-only gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): cover shared-draft outcome in bulk discard modal + comment reflow

A draft-only item someone else also drafted is neither reverted nor
deleted — only the current user's draft is removed. Say so in the modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-07-28 14:35:44 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c12e7c3431
commit ac970efa16
2 changed files with 180 additions and 46 deletions
+173 -43
View File
@@ -22,7 +22,11 @@
draftBaseIsStale
} from '$lib/utils_draft_deploy'
import { checkDeployPermission, type DeployPermission } from '$lib/utils_workspace_deploy'
import { type DraftItem, useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import {
type DraftItem,
invalidateWorkspaceDrafts,
useWorkspaceDrafts
} from '$lib/workspaceDrafts.svelte'
import type { Kind as LayoutKind } from '$lib/utils_deployable'
import { userStore } from '$lib/stores'
@@ -44,8 +48,8 @@
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 deployable rows (the default). All rows
* are still shown either way. */
* 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
@@ -180,35 +184,45 @@
isFork && hideUnchanged ? items.filter((i) => i.unchanged_from_parent !== true) : items
)
// A row is actionable when it isn't already deployed this session, the user has
// write permission, AND it's their own draft (you can't deploy someone else's
// draft — those show view-only in the "all drafts" view). The server enforces
// the same; this keeps the UI honest. A data-pipeline bundle is never deployable
// from this page — its scripts deploy individually inside the pipeline view — so
// it's excluded from every selection path.
function isSelectable(item: Row): boolean {
// 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.can_write &&
item.mine &&
item.draftKind !== 'data_pipeline'
item.draftKind !== 'data_pipeline' &&
(!item.legacy_draft || item.can_write)
)
}
// Why a row can't be deployed (drives the disabled-checkbox tooltip).
// `undefined` ⇒ actionable.
// 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.can_write) return "You don't have write permission on this path"
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, which they always own,
// so — unlike deploy — it never requires write permission on the path. The
// only block is someone else's draft (view-only in the "all drafts" view).
// 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
}
@@ -290,8 +304,9 @@
if (ws === currentWorkspaceId) deployPerm = p
})
})
// Select all on the first non-empty load (deploy-all is the common intent);
// only once, so a refetch after a deploy doesn't re-select the leftovers.
// 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<
@@ -314,9 +329,9 @@
$effect(() => {
if (!hasAutoSelected && chatMaskReady && visibleItems.length > 0) {
// Default intent is deploy-all; when reached from a session's Review
// 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(isSelectable)
const selectable = visibleItems.filter(isDiscardable)
selectedItems = (
chatMask
? selectable.filter((i) =>
@@ -332,17 +347,24 @@
}
})
// Selected items still in the visible list and deployable. Derived (not a
// pruning effect) so the "Deploy N drafts" button stays reactive to the
// Workspace Drafts resource: deploy/discard drop items, and stale keys left in
// selectedItems are simply ignored here (and by deploySelected).
let selectedCount = $derived(
visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i)).length
// 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(isSelectable).length > 0 &&
visibleItems.filter(isSelectable).every((i) => selectedItems.includes(i.key))
visibleItems.filter(isDiscardable).length > 0 &&
visibleItems.filter(isDiscardable).every((i) => selectedItems.includes(i.key))
)
function toggleItem(item: { key: string }) {
@@ -354,7 +376,7 @@
}
function selectAll() {
selectedItems = visibleItems.filter(isSelectable).map((i) => i.key)
selectedItems = visibleItems.filter(isDiscardable).map((i) => i.key)
}
function deselectAll() {
@@ -394,8 +416,8 @@
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
// isSelectable so a non-writable row can never be deployed via a stale key.
const toDeploy = visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i))
// 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' }
@@ -470,6 +492,64 @@
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,
@@ -559,7 +639,7 @@
{selectedItems}
{deploymentStatus}
{allSelected}
selectablePredicate={(item) => isSelectable(item as unknown as Row)}
selectablePredicate={(item) => isDiscardable(item as unknown as Row)}
selectBlockedReason={(item) => blockedReason(item as unknown as Row)}
onToggleItem={toggleItem}
onSelectAll={selectAll}
@@ -752,17 +832,34 @@
{#snippet footer()}
<div class="flex flex-col items-end gap-2">
<Button
variant="accent"
disabled={selectedCount === 0 || deploying || !deployPerm.ok}
title={!deployPerm.ok ? deployPerm.reason : undefined}
loading={deploying}
onClick={deploySelected}
>
Deploy {selectedCount} draft{selectedCount !== 1 ? 's' : ''}
</Button>
<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}
@@ -772,6 +869,39 @@
<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
+7 -3
View File
@@ -712,7 +712,11 @@ export async function discardDraft(
path: string,
workspace: string,
_draftOnly = false,
legacy = false
legacy = false,
// Batch callers pass false and invalidate once after the last discard —
// per-item invalidation would refetch the whole draft list N times, with
// overlapping responses able to land out of order.
invalidate = true
): Promise<DeployResult> {
try {
if (legacy) {
@@ -723,7 +727,7 @@ export async function discardDraft(
requestBody: { value: null, legacy: true }
})
setLocalDraftHint(workspace, kind, path, false)
invalidateWorkspaceDrafts(workspace)
if (invalidate) invalidateWorkspaceDrafts(workspace)
return { success: true }
}
// postSave clears the syncer-owned `*` hint on the delete. `immediate`
@@ -731,7 +735,7 @@ export async function discardDraft(
// enqueue time and the invalidate below refetches before the delete,
// re-listing the just-discarded draft.
await UserDraftDbSyncer.save({ workspace, itemKind: kind, path, value: null, immediate: true })
invalidateWorkspaceDrafts(workspace)
if (invalidate) invalidateWorkspaceDrafts(workspace)
return { success: true }
} catch (e: any) {
return { success: false, error: e?.body ?? e?.message ?? String(e) }