feat: edit an asset's producing script from its pane and multi-script chip

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 14:33:52 +02:00
co-authored by Claude Opus 5.5
parent a3d46f7364
commit be5e651f20
8 changed files with 231 additions and 34 deletions
@@ -1021,7 +1021,21 @@
? undefined
: { none: true as const }
: u.multiple || !r
? { multiple: true as const }
? {
multiple: true as const,
scripts: (u.multiple ? u.runnableIds : [])
.map((id) => runnables.get(id)?.data)
.filter((d) => d != undefined)
.map((d) => ({
runnableId: `${d.runnable_kind}:${d.path}`,
path: d.path as string,
summary: d.summary as string | undefined,
language: d.language,
unsaved: d.unsaved as boolean | undefined,
onOpen: () =>
onselect?.({ kind: 'runnable', runnable_kind: d.runnable_kind, path: d.path })
}))
}
: {
runnableId: u.runnableId,
path: r.path,
@@ -1059,7 +1073,8 @@
? 'Multiple upstream nodes'
: `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`,
runState: 'runState' in upstream && !!upstream.runState,
scriptChip: 'runnableId' in upstream
scriptChips:
'runnableId' in upstream ? 1 : 'scripts' in upstream ? (upstream.scripts?.length ?? 0) : 0
})
: undefined
return {
@@ -1218,7 +1233,15 @@
// never re-runs the layout.
const up = n.type === 'asset' ? n.data.upstream : undefined
const data =
up?.runnableId !== undefined
up?.scripts
? {
...n.data,
upstream: {
...up,
selected: up.scripts.some((sc: { runnableId: string }) => sc.runnableId === selectedId)
}
}
: up?.runnableId !== undefined
? {
...n.data,
upstream: {
@@ -20,8 +20,11 @@
SquareFunction,
Trash2,
X,
Pencil
Pencil,
ChevronDown
} from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { upstreamScriptItems, type UpstreamScript } from './upstreamScriptItems'
import { inferArgs } from '$lib/infer'
import { emptySchema, sendUserToast } from '$lib/utils'
import type { Schema } from '$lib/common'
@@ -157,6 +160,10 @@
path: string
unsaved?: boolean
}>
/** The selected asset's producing scripts, for its "Edit script" action,
* which calls `onEditScript` with the one picked. */
producerScripts?: UpstreamScript[]
onEditScript?: (path: string) => void
// Pipeline-wide column-lineage graph (built by the parent page from the
// resolved graph). Drives the transitive column-lineage trace shown for a
// selected materialized asset.
@@ -305,6 +312,8 @@
onScriptRenamed,
onScriptRemoved,
selectionProducers = [],
producerScripts = [],
onEditScript,
selectionColumnGraph,
selectionColumnLoading = false,
selectionColumnTruncated = false,
@@ -1018,6 +1027,36 @@
{/if}
</div>
<div class="flex items-center gap-1 shrink-0">
{#if selection?.kind === 'asset' && onEditScript && producerScripts.length === 1}
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Pencil }}
onclick={() => onEditScript(producerScripts[0].path)}
title={`Open ${producerScripts[0].path}, which builds this asset`}
>
Edit script
</Button>
{:else if selection?.kind === 'asset' && onEditScript && producerScripts.length > 1}
<DropdownV2
items={upstreamScriptItems(producerScripts, onEditScript)}
placement="bottom-end"
enableFlyTransition
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
variant="default"
unifiedSize="sm"
startIcon={{ icon: Pencil }}
endIcon={{ icon: ChevronDown }}
title={`${producerScripts.length} scripts build this asset`}
>
Edit script
</Button>
{/snippet}
</DropdownV2>
{/if}
{#if readOnly && onRequestEdit}
<Button
variant="accent"
@@ -29,6 +29,8 @@
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import RunStateChip from './RunStateChip.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { upstreamScriptItems } from './upstreamScriptItems'
import type { RunnableRunState } from './activeRunnables.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -40,7 +42,19 @@
// non-deployed scripts).
export type AssetUpstreamChips =
| { none: true }
| { multiple: true }
| {
multiple: true
/** The producing scripts; one when only its triggers are ambiguous. */
scripts: Array<{
runnableId: string
path: string
summary?: string
language?: ScriptLang
unsaved?: boolean
onOpen: () => void
}>
selected?: boolean
}
| {
runnableId: string
path: string
@@ -208,9 +222,19 @@
: []
)
let upstream = $derived(data.upstream)
const CHIP_CLASS =
'shrink-0 h-5 min-w-5 rounded-md border bg-surface text-secondary hover:bg-surface-hover border-gray-300 dark:border-gray-600'
const CHIP_SELECTED =
'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent'
let producer = $derived(upstream && 'path' in upstream ? upstream : undefined)
let coProducers = $derived(upstream && 'scripts' in upstream ? upstream.scripts : [])
let coSelected = $derived(!!(upstream && 'scripts' in upstream && upstream.selected))
let coProducerItems = $derived(
upstreamScriptItems(coProducers, (path) =>
coProducers.find((s) => s.path === path)?.onOpen()
)
)
let scriptMenuOpen = $state(false)
// dbt badge. `materialized` is dbt's own word rather than the Windmill
// strategy because `view` and `ephemeral` have no strategy, and showing the
@@ -278,11 +302,44 @@
)
</script>
{#snippet scriptChip(sc: {
path: string
summary?: string
language?: ScriptLang
unsaved?: boolean
selected?: boolean
onOpen: () => void
})}
<button
type="button"
class={twMerge(
CHIP_CLASS,
'grid place-items-center px-1',
sc.unsaved && 'border-dashed border-gray-400 dark:border-gray-500',
sc.selected && CHIP_SELECTED
)}
title={`Open ${sc.summary ? `${sc.summary} (${sc.path})` : sc.path}${sc.unsaved ? ' · draft' : ''}`}
aria-label="Open the script that produces this asset"
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation()
sc.onOpen()
}}
>
{#if sc.language}
<LanguageIcon lang={sc.language} width={12} height={12} />
{:else}
<Code2 size={12} />
{/if}
</button>
{/snippet}
{#snippet upstreamRow()}
{#if upstream}
{@const trigger = producer?.trigger}
{@const chipClass = twMerge(
'truncate rounded-md border px-1.5 py-0.5 text-3xs leading-none font-normal',
'truncate rounded-md border px-1.5 py-0.5 text-3xs leading-none font-normal bg-surface',
trigger?.missing
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300'
: 'border-gray-300 dark:border-gray-600 text-secondary',
@@ -462,28 +519,47 @@
{#if producer.runState}
<RunStateChip runState={producer.runState} class="mr-1 h-5 px-1.5 rounded-md" />
{/if}
<button
type="button"
class={twMerge(
'shrink-0 h-5 min-w-5 px-1 grid place-items-center rounded-md border bg-surface text-secondary hover:bg-surface-hover border-gray-300 dark:border-gray-600',
producer.unsaved && 'border-dashed border-gray-400 dark:border-gray-500',
producer.selected && CHIP_SELECTED
)}
title={`Open ${producer.summary ? `${producer.summary} (${producer.path})` : producer.path}${producer.unsaved ? ' · draft' : ''}`}
aria-label="Open the script that produces this asset"
{@render scriptChip(producer)}
{:else if coProducers.length === 1}
{@render scriptChip({ ...coProducers[0], selected: coSelected })}
{:else if coProducers.length > 1}
<!-- Several scripts build this table: the first two languages and a
count, opening a menu to pick which one to open. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onpointerdown={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation()
producer?.onOpen()
}}
>
{#if producer.language}
<LanguageIcon lang={producer.language} width={12} height={12} />
{:else}
<Code2 size={12} />
{/if}
</button>
<DropdownV2
items={coProducerItems}
placement="bottom-end"
bind:open={scriptMenuOpen}
fixedHeight={false}
usePointerDownOutside
>
{#snippet buttonReplacement()}
<span
class={twMerge(
CHIP_CLASS,
'flex items-center gap-0.5 px-1',
(coSelected || scriptMenuOpen) && CHIP_SELECTED
)}
title={`Built by ${coProducers.length} scripts: pick one to open`}
>
{#each coProducers.slice(0, 2) as s (s.path)}
{#if s.language}
<LanguageIcon lang={s.language} width={12} height={12} />
{:else}
<Code2 size={12} />
{/if}
{/each}
{#if coProducers.length > 2}
<span class="text-3xs leading-none">+{coProducers.length - 2}</span>
{/if}
</span>
{/snippet}
</DropdownV2>
</div>
{/if}
{/snippet}
</PipelineNodeCard>
@@ -566,7 +642,7 @@
>
<Plus size={16} strokeWidth={2.5} class="shrink-0" />
<span
class="max-w-0 opacity-0 whitespace-nowrap text-2xs font-normal transition-[max-width,opacity,margin] duration-200 ease-out group-hover/add:max-w-32 group-hover/add:opacity-100 group-hover/add:ml-1 group-hover/add:mr-2 group-data-[open=true]/add:max-w-32 group-data-[open=true]/add:opacity-100 group-data-[open=true]/add:ml-1 group-data-[open=true]/add:mr-2"
class="max-w-0 opacity-0 whitespace-nowrap text-3xs font-normal transition-[max-width,opacity,margin] duration-200 ease-out group-hover/add:max-w-32 group-hover/add:opacity-100 group-hover/add:ml-1 group-hover/add:mr-2 group-data-[open=true]/add:max-w-32 group-data-[open=true]/add:opacity-100 group-data-[open=true]/add:ml-1 group-data-[open=true]/add:mr-2"
>
Add downstream step
</span>
@@ -279,6 +279,21 @@
}
})
// The selected asset's producing scripts, named and iconed from the graph, for
// the pane's "Edit script".
let producerScripts = $derived(
selectionProducers
.filter((p) => p.kind === 'script')
.map((p) => {
const r = displayGraph.runnables.find((x) => x.usage_kind === 'script' && x.path === p.path)
return { path: p.path, summary: r?.summary, language: r?.language }
})
)
function editProducerScript(path: string) {
if (mode !== 'edit') onRequestEdit?.()
onSelect({ kind: 'runnable', runnable_kind: 'script', path })
}
// ===================== Draft autosave (persistDrafts only) =====================
// All of this folder's in-flight drafts live in ONE per-user DB draft (typ
// `data_pipeline`) keyed at the folder, syncing across devices + surfacing in
@@ -576,6 +591,8 @@
{localScriptsVersion}
selection={activeDraft ? undefined : editor.selection}
selectionProducers={activeDraft ? [] : selectionProducers}
{producerScripts}
onEditScript={editProducerScript}
{selectionColumnGraph}
{selectionColumnLoading}
{selectionColumnTruncated}
@@ -28,7 +28,8 @@ export function assetsOnlyNodeWidth(n: {
title: string
chip: string
runState: boolean
scriptChip: boolean
/** Producing scripts: one chip for one, two icons and a "+N" for more. */
scriptChips: number
}): number {
const icon = 12 + 14 + 10
const text = Math.max(
@@ -37,8 +38,13 @@ export function assetsOnlyNodeWidth(n: {
textWidth(n.chip, 0.65) + 12 + 2
)
// The run chip is sized for a two-digit count, so a new run never re-lays the graph.
const trailing =
(n.runState ? 12 + 10 + 2 + textWidth('×99', 0.65) + 4 : 0) + (n.scriptChip ? 20 : 0) + 8
const scripts =
n.scriptChips === 0
? 0
: n.scriptChips === 1
? 20
: 8 + 2 + 12 * 2 + 2 + (n.scriptChips > 2 ? textWidth(`+${n.scriptChips - 2}`, 0.65) + 2 : 0)
const trailing = (n.runState ? 12 + 10 + 2 + textWidth('×99', 0.65) + 4 : 0) + scripts + 8
const width = Math.ceil(icon + text + 4 + trailing + 2 + 8)
return Math.min(NODE.width, Math.max(MIN_WIDTH, width))
}
@@ -51,7 +51,10 @@ describe('assetsOnlyView', () => {
runnableId: 'script:f/p/clean',
trigger: { kind: 'schedule', data: { schedule: '0 0 4 * * *' } }
})
expect(v.upstream.get('asset:ducklake:report')).toEqual({ multiple: true })
expect(v.upstream.get('asset:ducklake:report')).toEqual({
multiple: true,
runnableIds: ['script:f/p/report', 'script:f/p/other']
})
})
})
@@ -81,6 +84,15 @@ describe('upstreamDeletion', () => {
expect(upstreamDeletion(u, script, new Map([['asset:x', u]]))?.trigger).toBeUndefined()
})
it('deletes nothing when the script also co-produces another asset', () => {
const u = own(schedule(['f/p/a']))
const all = new Map<string, AssetUpstream>([
['asset:x', u],
['asset:y', { multiple: true, runnableIds: ['script:f/p/a', 'script:f/p/b'] }]
])
expect(upstreamDeletion(u, script, all)).toBeUndefined()
})
it('deletes nothing when the script builds another asset too', () => {
const u = own(schedule(['f/p/a']))
const all = new Map([
@@ -18,7 +18,8 @@ export type AssetUpstreamDelete = {
/** What an asset shows of the script and trigger that produce it, in the assets-only view. */
export type AssetUpstream =
| { multiple: true }
/** Several producing scripts, or one with several triggers. */
| { multiple: true; runnableIds: string[] }
| {
multiple: false
/** Canvas id of the producing runnable (`script:<path>`). */
@@ -75,7 +76,7 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
for (const [assetId, writes] of producersOf) {
const runnableIds = [...new Set(writes.map((w) => w.source))]
if (runnableIds.length !== 1) {
upstream.set(assetId, { multiple: true })
upstream.set(assetId, { multiple: true, runnableIds })
continue
}
const runnableId = runnableIds[0]
@@ -89,7 +90,7 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
).values()
]
if (triggers.length > 1) {
upstream.set(assetId, { multiple: true })
upstream.set(assetId, { multiple: true, runnableIds })
continue
}
const t = triggers[0]
@@ -119,7 +120,9 @@ export function upstreamDeletion(
): AssetUpstreamDelete | undefined {
if (r.runnable_kind !== 'script') return undefined
const producesOthers =
[...all.values()].filter((o) => !o.multiple && o.runnableId === u.runnableId).length > 1
[...all.values()].filter((o) =>
o.multiple ? o.runnableIds.includes(u.runnableId) : o.runnableId === u.runnableId
).length > 1
if (producesOthers) return undefined
const t = u.trigger
const kind = t?.kind as NativeTriggerKind | undefined
@@ -0,0 +1,21 @@
import type { Item } from '$lib/utils'
import type { ScriptLang } from '$lib/gen'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import { Code2 } from 'lucide-svelte'
export type UpstreamScript = { path: string; summary?: string; language?: ScriptLang }
/** A menu of the scripts that produce an asset, to pick the one to open. */
export function upstreamScriptItems(
scripts: UpstreamScript[],
onPick: (path: string) => void
): Item[] {
return scripts.map((s) => ({
displayName: s.summary || s.path,
description: s.summary ? s.path : undefined,
...(s.language
? { icon: LanguageIcon, iconProps: { lang: s.language, width: 14, height: 14 } }
: { icon: Code2 }),
action: () => onPick(s.path)
}))
}