feat: delete a node's script, trigger, or both from its menu; red header for a script with errors

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 19:39:24 +02:00
co-authored by Claude Opus 5.5
parent 2d72eeb765
commit ea9bc2119e
5 changed files with 102 additions and 45 deletions
@@ -1462,9 +1462,9 @@
foldedIds,
...(toDelete
? {
deleteCount: toDelete.trigger ? 2 : 1,
upstreamDelete: toDelete,
deleteVerb: assetDeleteVerb,
onDeleteUpstream: () => onDeleteAssetUpstream?.(toDelete)
onDeleteUpstream: (t: AssetUpstreamDelete) => onDeleteAssetUpstream?.(t)
}
: onDeleteAssetUpstream && u
? {
@@ -1705,8 +1705,13 @@
.map((id) => toFix(nodeFixes?.get(id)))
.filter((f) => f != undefined)
const scriptsToDrag = onAddAssetTrigger ? dragScripts(n) : []
// Assets-only: a folded script with an error reddens the header naming it.
const scriptError = ((n.data.foldedIds ?? []) as string[]).some(
(id) => id.startsWith('script:') && nodeFixes?.has(id)
)
const data = {
...withUpstream,
...(scriptError ? { scriptError } : {}),
...(fix ? { fix } : {}),
...(foldedFixes.length ? { foldedFixes } : {}),
...(scriptsToDrag.length
@@ -171,14 +171,16 @@
noAssetName?: string
/** Assets-only: the node is only its script's banner. */
bannerOnly?: boolean
/** Assets-only: a script folded into the node has an error. */
scriptError?: boolean
/** No script in the workspace writes it; a flow's write fires no asset trigger. */
neverWritten?: boolean
/** Card width in the assets-only view, sized to its text. */
width?: number
/** Assets-only view: deletes the producing script and trigger no other
* asset shares; `deleteCount` is how many that is. */
onDeleteUpstream?: () => void
deleteCount?: number
/** Assets-only view: the producing script and trigger no other asset
* shares, which the menu offers to delete one by one or together. */
upstreamDelete?: import('./assetsOnlyView').AssetUpstreamDelete
onDeleteUpstream?: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void
deleteVerb?: 'Delete' | 'Archive'
/** Why nothing can be deleted, when the producer is shared. */
deleteBlocked?: string
@@ -229,15 +231,8 @@
// and a step downstream of what nothing writes would never run on its writes.
let showAdd = $derived(data.onAddScript != undefined && !data.error && !data.neverWritten)
let menuItems: Item[] = $derived(
data.onDeleteUpstream
? [
{
displayName: `${data.deleteVerb ?? 'Delete'} ${data.deleteCount} item${data.deleteCount === 1 ? '' : 's'}`,
icon: Trash2,
type: 'delete' as const,
action: () => data.onDeleteUpstream?.()
}
]
data.onDeleteUpstream && data.upstreamDelete
? deleteItems(data.upstreamDelete, data.onDeleteUpstream)
: data.deleteBlocked
? [
{
@@ -248,6 +243,42 @@
]
: []
)
const lastSegment = (p: string) => p.split('/').pop() ?? p
// A script is archived rather than deleted when the user may not delete it;
// a trigger is always deleted.
function deleteItems(
t: import('./assetsOnlyView').AssetUpstreamDelete,
run: (target: import('./assetsOnlyView').AssetUpstreamDelete) => void
): Item[] {
const items: Item[] = []
if (t.script) {
const script = t.script
items.push({
displayName: `${data.deleteVerb ?? 'Delete'} script ${lastSegment(script.path)}`,
icon: Trash2,
type: 'delete' as const,
action: () => run({ script })
})
}
if (t.trigger) {
const trigger = t.trigger
items.push({
displayName: `Delete ${trigger.kind} trigger ${lastSegment(trigger.path)}`,
icon: Trash2,
type: 'delete' as const,
action: () => run({ trigger })
})
}
if (t.script && t.trigger) {
items.push({
displayName: `${data.deleteVerb === 'Archive' ? 'Remove' : 'Delete'} all 2 items`,
icon: Trash2,
type: 'delete' as const,
action: () => run(t)
})
}
return items
}
let upstream = $derived(data.upstream)
const HEADER_SELECTED = 'bg-surface-accent-selected hover:bg-surface-accent-selected text-accent'
const CHIP_SELECTED =
@@ -335,7 +366,9 @@
naming it, which opens it. Several scripts open a menu to pick one. -->
{@const one = producer ?? (coProducers.length === 1 ? coProducers[0] : undefined)}
{@const headerClass = twMerge(
'flex items-center gap-1.5 w-full min-w-0 px-2 text-3xs font-normal leading-none text-secondary hover:bg-surface-hover hover:text-primary'
'flex items-center gap-1.5 w-full min-w-0 px-2 text-3xs font-normal leading-none text-secondary hover:bg-surface-hover hover:text-primary',
data.scriptError &&
'bg-red-100 dark:bg-red-700 text-red-700 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-600 hover:text-red-800'
)}
{#if one}
{@const isSelected = producer ? !!producer.selected : coSelected}
@@ -501,6 +534,7 @@
? upstreamRow
: undefined}
header={hasHeader ? scriptHeader : undefined}
headerTone={data.scriptError ? 'error' : undefined}
headerSelected={producer ? !!producer.selected : coSelected}
width={data.width}
surface={upstream ? 'primary' : 'secondary'}
@@ -44,6 +44,8 @@ and the controls hung around the card.
header?: Snippet
/** The header's script is selected: its edge takes the selected colour. */
headerSelected?: boolean
/** The header's script has an error: its edge goes red. */
headerTone?: 'error'
}
let {
@@ -61,6 +63,7 @@ and the controls hung around the card.
subtitle,
header,
headerSelected = false,
headerTone,
width = NODE.width
}: Props = $props()
@@ -137,6 +140,7 @@ and the controls hung around the card.
<div
class={twMerge(
'mx-2 flex items-stretch min-w-0 rounded-t-md overflow-hidden bg-surface border border-b-0 border-gray-200 dark:border-gray-700',
headerTone === 'error' && 'border-red-300 dark:border-red-600',
headerSelected && 'border-border-selected dark:border-border-selected'
)}
style="height: {PIPELINE_NODE_HEADER}px;"
@@ -20,8 +20,9 @@ export const noAssetNodeId = (runnableId: string) => `no-asset:${runnableId}`
export const isNoAssetNode = (id: string) => id.startsWith('no-asset:')
/** The script and trigger an assets-only asset's delete removes. */
/** What an assets-only delete removes: the script, its trigger, or both. */
export type AssetUpstreamDelete = {
script: { path: string; unsaved: boolean }
script?: { path: string; unsaved: boolean }
trigger?: { kind: NativeTriggerKind; path: string; draft: boolean }
}
@@ -1282,26 +1282,12 @@
if (!target || !ws) return
assetDeleteLoading = true
const { script, trigger } = target
const n = (script ? 1 : 0) + (trigger ? 1 : 0)
try {
// The script first: when it is refused, nothing else has changed.
const deployed = graphRes.current?.runnables.some(
(r) => r.path === script.path && !r.unsaved
)
if (deployed) {
if (canHardDeleteScripts) {
await ScriptService.deleteScriptByPath({ workspace: ws, path: script.path })
} else {
await ScriptService.archiveScriptByPath({ workspace: ws, path: script.path })
}
}
if (pe.drafts.has(script.path)) discardDraft(script.path)
if (deployed) {
pe.discardTriggerDraftsFor(script.path)
forgetPath(script.path)
forgetScriptCache(script.path)
}
if (script) await removeScript(script, ws)
} catch (e: any) {
sendUserToast(`Could not remove ${script.path}: ${e?.body ?? e?.message ?? String(e)}`, true)
sendUserToast(`Could not remove ${script?.path}: ${e?.body ?? e?.message ?? String(e)}`, true)
assetDeleteLoading = false
return
}
@@ -1311,11 +1297,10 @@
if (pe.triggerDrafts.has(key)) pe.discardTriggerDraft(key)
if (!trigger.draft) await deleteTriggerRow(trigger.kind, trigger.path, ws)
}
const n = trigger ? 2 : 1
sendUserToast(`Removed ${n} item${n === 1 ? '' : 's'}`)
} catch (e: any) {
sendUserToast(
`Removed ${script.path}, but not its ${trigger?.kind} trigger ${trigger?.path}: ${e?.body ?? e?.message ?? String(e)}`,
`${script ? `Removed ${script.path}, but not its` : 'Could not remove the'} ${trigger?.kind} trigger ${trigger?.path}: ${e?.body ?? e?.message ?? String(e)}`,
true
)
} finally {
@@ -1324,6 +1309,24 @@
await graphRes.refetch()
}
}
async function removeScript(script: { path: string }, ws: string) {
const deployed = graphRes.current?.runnables.some(
(r) => r.path === script.path && !r.unsaved
)
if (deployed) {
if (canHardDeleteScripts) {
await ScriptService.deleteScriptByPath({ workspace: ws, path: script.path })
} else {
await ScriptService.archiveScriptByPath({ workspace: ws, path: script.path })
}
}
if (pe.drafts.has(script.path)) discardDraft(script.path)
if (deployed) {
pe.discardTriggerDraftsFor(script.path)
forgetPath(script.path)
forgetScriptCache(script.path)
}
}
// ===================== Fixes for misconfigured nodes =====================
// Rewrites a script's annotation header and keeps the result as a draft, opened
@@ -3523,7 +3526,11 @@
<ConfirmationModal
open={assetDeleteTarget != undefined}
loading={assetDeleteLoading}
title={assetDeleteTarget?.trigger ? 'Delete 2 items?' : 'Delete 1 item?'}
title={assetDeleteTarget?.script && assetDeleteTarget.trigger
? 'Delete 2 items?'
: assetDeleteTarget?.script
? `${canHardDeleteScripts ? 'Delete' : 'Archive'} script?`
: 'Delete trigger?'}
confirmationText="Delete"
onConfirmed={confirmAssetUpstreamDelete}
onCanceled={() => {
@@ -3534,16 +3541,22 @@
{@const t = assetDeleteTarget}
<div class="flex flex-col gap-2 text-xs text-secondary">
<p>
{canHardDeleteScripts
? 'The script is deleted, with every version'
: 'The script is archived'}{t.trigger ? ', and its trigger deleted' : ''}. The tables it
wrote stay.
{#if t.script}
{canHardDeleteScripts
? 'The script is deleted, with every version'
: 'The script is archived'}{t.trigger ? ', and its trigger deleted' : ''}. The tables
it wrote stay.
{:else}
The trigger is deleted: its script stays, and no longer runs this way.
{/if}
</p>
<ul class="flex flex-col gap-1">
<li class="flex flex-col">
<span class="text-emphasis truncate">{t.script.path}</span>
<span class="text-2xs text-hint">Script{t.script.unsaved ? ' · draft' : ''}</span>
</li>
{#if t.script}
<li class="flex flex-col">
<span class="text-emphasis truncate">{t.script.path}</span>
<span class="text-2xs text-hint">Script{t.script.unsaved ? ' · draft' : ''}</span>
</li>
{/if}
{#if t.trigger}
<li class="flex flex-col">
<span class="text-emphasis truncate">{t.trigger.path}</span>