feat: drag an asset's + onto a script or the canvas; tell reactive assets-only edges apart

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 18:27:23 +02:00
co-authored by Claude Opus 5.5
parent 7c840fb54e
commit f80d2594b1
7 changed files with 326 additions and 70 deletions
@@ -0,0 +1,73 @@
<!--
@component
The menu adding a pipeline script downstream of an asset: opened from the asset's
"+", or where that "+" was dropped on the blank canvas.
-->
<script lang="ts" module>
import type { ScriptLang } from '$lib/gen'
import type { AssetKind } from '$lib/components/assets/lib'
import type { PipelineInsertOptions } from './PipelineInsertMenu.svelte'
import type { PipelineOutputKind } from './pipelineTemplates'
export type AddScriptForAsset = (
asset: { kind: AssetKind; path: string },
language: ScriptLang,
scriptPath: string,
outputKind: PipelineOutputKind,
aiPrompt?: string,
options?: PipelineInsertOptions
) => void
</script>
<script lang="ts">
import { Code2 } from 'lucide-svelte'
import type { Snippet } from 'svelte'
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
let {
asset,
onAddScript,
pathPrefix = '',
defaultPathSuffix = '',
openSignal,
trigger
}: {
asset: { kind: AssetKind; path: string }
onAddScript: AddScriptForAsset
pathPrefix?: string
defaultPathSuffix?: string
openSignal?: number
trigger: Snippet<[{ open: boolean }]>
} = $props()
function handlePick(pick: PipelineInsertPick) {
if (pick.kindId === 'pipeline_script' && pick.language && pick.path) {
onAddScript(
asset,
pick.language as ScriptLang,
pick.path,
(pick.outputKind ?? 'none') as PipelineOutputKind,
pick.aiPrompt,
{ outputAsset: pick.outputAsset }
)
}
}
</script>
<PipelineInsertMenu
kinds={[
{
id: 'pipeline_script',
label: 'Add downstream pipeline script',
description: 'Triggered when this asset changes',
icon: Code2
}
]}
languages={PIPELINE_LANGUAGES as any}
{pathPrefix}
{defaultPathSuffix}
{openSignal}
onPick={handlePick}
{trigger}
/>
@@ -0,0 +1,23 @@
<!--
@component
The "+" on an asset's bottom edge: a circle at rest, widened into a labelled pill
while its menu is open or while it is being dragged. The label animates its
max-width (width can't transition to auto) and never wraps.
-->
<script lang="ts">
import { Plus } from 'lucide-svelte'
let { expanded }: { expanded: boolean } = $props()
</script>
<span
class="group/add h-6 min-w-6 rounded-full flex items-center justify-center bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm leading-none overflow-hidden transition-[padding] duration-200 ease-out data-[open=true]:pl-1"
data-open={expanded}
>
<Plus size={16} strokeWidth={2.5} class="shrink-0" />
<span
class="max-w-0 opacity-0 whitespace-nowrap text-3xs font-normal transition-[max-width,opacity,margin] duration-200 ease-out 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>
</span>
@@ -16,6 +16,8 @@
import AddNode from './AddNode.svelte'
import AddDataSourceMenu from './AddDataSourceMenu.svelte'
import PipelineInsertMenu, { type PipelineInsertSchedule } from './PipelineInsertMenu.svelte'
import AddDownstreamMenu from './AddDownstreamMenu.svelte'
import AddDownstreamPill from './AddDownstreamPill.svelte'
import DataTestNode from './DataTestNode.svelte'
import AssetGraphEdge from './AssetGraphEdge.svelte'
import PanToNode from './PanToNode.svelte'
@@ -291,55 +293,170 @@
onAddAssetTrigger
}: Props = $props()
// Dragging a script's top dot onto an asset subscribes the script to it. Only
// the assets it can subscribe to without a loop stay lit while dragging.
// Two drags write `// on`: a script's top dot onto an asset, and an asset's "+"
// onto a script. Only the nodes the drop can subscribe without a loop stay lit.
// The "+" can also be dropped on the blank canvas, opening its add menu there.
let onDrag = $state<
| {
sourceId: string
scripts: string[]
from: { x: number; y: number }
to: { x: number; y: number }
eligible: Set<string>
over: string | undefined
/** Dragging an asset's "+", whose blank-canvas drop adds a step. */
feed: boolean
blank: boolean
}
| undefined
>(undefined)
// Hit-tested on the boxes rather than with elementFromPoint: svelte-flow can
// make nodes click-through while the pointer is held down.
const within = (el: Element, x: number, y: number) => {
const r = el.getBoundingClientRect()
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom
}
let flowEl: HTMLElement | undefined = $state()
const nodeUnder = (x: number, y: number) =>
document.elementFromPoint(x, y)?.closest('.svelte-flow__node')?.getAttribute('data-id') ??
undefined
function startOnDrag(sourceId: string, scripts: string[], e: PointerEvent) {
if (!onAddAssetTrigger || scripts.length === 0) return
const sets = scripts.map((id) => onDirectiveTargets(model.nodes, model.edges, id))
const eligible = new Set([...sets[0]].filter((a) => sets.every((t) => t.has(a))))
[...(flowEl?.querySelectorAll('.svelte-flow__node') ?? [])]
.find((el) => within(el, x, y))
?.getAttribute('data-id') ?? undefined
const overBlank = (x: number, y: number) =>
!!flowEl &&
within(flowEl, x, y) &&
![
...flowEl.querySelectorAll(
'.svelte-flow__panel, .svelte-flow__controls, .svelte-flow__minimap'
)
].some((el) => within(el, x, y))
const readsAsset = (assetId: string, scriptId: string) =>
model.edges.some((x) => x.kind === 'lineage-read' && x.source === assetId && x.target === scriptId)
const assetOf = (assetId: string) => {
const a = model.nodes.find((n) => n.id === assetId)?.data
return a ? { kind: a.asset_kind as AssetKind, path: a.path as string } : undefined
}
function subscribe(assetId: string, scripts: string[]) {
const asset = assetOf(assetId)
if (!asset) return
onAddAssetTrigger?.({
asset,
scripts: scripts.map((id) => ({
script: id.replace(/^script:/, ''),
reads: readsAsset(assetId, id)
}))
})
}
/** `threshold`: pixels the pointer must travel before it counts as a drag, so a
* plain click on the handle still clicks. */
function trackDrag(
e: PointerEvent,
start: { sourceId: string; feed: boolean; eligible: () => Set<string>; threshold: number },
drop: (target: string | undefined, at: { x: number; y: number }, blank: boolean) => void
) {
const from = { x: e.clientX, y: e.clientY }
onDrag = { sourceId, scripts, from, to: from, eligible, over: undefined }
let started = false
const begin = () => {
started = true
onDrag = {
sourceId: start.sourceId,
from,
to: from,
eligible: start.eligible(),
over: undefined,
feed: start.feed,
blank: false
}
}
if (start.threshold === 0) begin()
const move = (ev: PointerEvent) => {
if (!started) {
if (Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < start.threshold) return
begin()
}
if (!onDrag) return
onDrag.to = { x: ev.clientX, y: ev.clientY }
onDrag.over = nodeUnder(ev.clientX, ev.clientY)
onDrag.blank = !onDrag.over && overBlank(ev.clientX, ev.clientY)
}
const up = (ev: PointerEvent) => {
window.removeEventListener('pointermove', move)
const drag = onDrag
onDrag = undefined
if (!started || !drag) return
// The release is not a click on whatever it lands on.
const swallow = (c: MouseEvent) => {
c.stopPropagation()
c.preventDefault()
}
window.addEventListener('click', swallow, { capture: true, once: true })
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0)
const target = nodeUnder(ev.clientX, ev.clientY)
if (!drag || !target || !drag.eligible.has(target)) return
const a = model.nodes.find((n) => n.id === target)?.data
if (!a) return
const asset = { kind: a.asset_kind as AssetKind, path: a.path as string }
onAddAssetTrigger?.({
asset,
scripts: drag.scripts.map((id) => ({
script: id.replace(/^script:/, ''),
reads: model.edges.some(
(x) => x.kind === 'lineage-read' && x.source === target && x.target === id
)
}))
})
const at = { x: ev.clientX, y: ev.clientY }
drop(target && drag.eligible.has(target) ? target : undefined, at, !target && drag.blank)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up, { once: true })
}
function startOnDrag(sourceId: string, scripts: string[], e: PointerEvent) {
if (!onAddAssetTrigger || scripts.length === 0) return
trackDrag(
e,
{
sourceId,
feed: false,
threshold: 0,
eligible: () => {
const sets = scripts.map((id) => onDirectiveTargets(model.nodes, model.edges, id))
return new Set([...sets[0]].filter((a) => sets.every((t) => t.has(a))))
}
},
(target) => {
if (target) subscribe(target, scripts)
}
)
}
// Where a "+" dropped on the blank canvas opened its add menu.
let feedMenu = $state<
| { at: { x: number; y: number }; asset: { kind: AssetKind; path: string }; data: any }
| undefined
>(undefined)
let feedMenuSignal = $state(0)
function startFeedDrag(assetId: string, data: any, e: PointerEvent) {
trackDrag(
e,
{
sourceId: assetId,
feed: true,
threshold: 5,
// A drop target is a node whose scripts can all subscribe to the asset.
eligible: () => {
if (!onAddAssetTrigger) return new Set()
const targets = new Map<string, Set<string>>()
const can = (id: string) => {
if (!targets.has(id)) targets.set(id, onDirectiveTargets(model.nodes, model.edges, id))
return targets.get(id)!.has(assetId)
}
return new Set(
view.nodes
.filter((n) => {
const scripts = dragScripts(n)
return n.id !== assetId && scripts.length > 0 && scripts.every(can)
})
.map((n) => n.id)
)
}
},
(target, at, blank) => {
const node = target ? view.nodes.find((n) => n.id === target) : undefined
if (node) subscribe(assetId, dragScripts(node))
else if (blank) {
const asset = assetOf(assetId)
if (!asset) return
feedMenu = { at, asset, data }
feedMenuSignal++
}
}
)
}
// Pipeline scripts a node's top dot drags for: the script itself, or, in the
// assets-only view, the scripts building the asset.
function dragScripts(n: { id: string; type: string; data: any }): string[] {
@@ -522,6 +639,8 @@
// script that is folded away.
| 'asset-flow'
unsaved?: boolean
// Assets-only `asset-flow`: a write to the source reruns a folded script.
reactive?: boolean
// Muted read edge: a ducklake/s3 input read every run whose (default)
// auto cascade trigger is suppressed by `// mute` / `// mute all`.
// Rendered with a bell-off badge — auto-wiring is the norm, so we mark
@@ -1541,6 +1660,9 @@
...(foldedFixes.length ? { foldedFixes } : {}),
...(scriptsToDrag.length
? { onStartOnDrag: (e: PointerEvent) => startOnDrag(n.id, scriptsToDrag, e) }
: {}),
...(n.type === 'asset' && onAddScriptForAsset
? { onStartFeedDrag: (e: PointerEvent) => startFeedDrag(n.id, n.data, e) }
: {})
}
return {
@@ -1673,8 +1795,16 @@
markerColor = 'rgb(59 130 246)'
break
case 'asset-flow':
style = 'stroke: rgb(59 130 246); stroke-width: 1.25px;'
markerColor = 'rgb(59 130 246)'
// Assets-only: a write upstream reruns what builds the target
// (solid, like a trigger), or the target only reads it (dashed gray).
if (e.reactive) {
style = 'stroke: rgb(59 130 246); stroke-width: 1.5px;'
markerColor = 'rgb(59 130 246)'
} else {
style = 'stroke: rgb(156 163 175); stroke-width: 1px;'
strokeDasharray = '4 3'
markerColor = 'rgb(156 163 175)'
}
break
case 'lineage-read':
style = 'stroke: rgb(156 163 175); stroke-width: 1px;'
@@ -1886,6 +2016,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="w-full h-full relative"
bind:this={flowEl}
bind:clientWidth={paneWidth}
onclickcapture={handleBackgroundClick}
oncontextmenu={handleContextMenu}
@@ -2017,8 +2148,39 @@
stroke-width="1.5"
stroke-dasharray="4 3"
/>
<circle cx={onDrag.to.x} cy={onDrag.to.y} r="3" class="fill-blue-500" />
{#if !(onDrag.feed && onDrag.blank)}
<circle cx={onDrag.to.x} cy={onDrag.to.y} r="3" class="fill-blue-500" />
{/if}
</svg>
{#if onDrag.feed && onDrag.blank}
<div
class="fixed z-50 pointer-events-none -translate-x-1/2 -translate-y-1/2"
style="left: {onDrag.to.x}px; top: {onDrag.to.y}px;"
>
<AddDownstreamPill expanded />
</div>
{/if}
{/if}
{#if feedMenu && onAddScriptForAsset}
<div
class="fixed z-10 -translate-x-1/2 -translate-y-1/2"
style="left: {feedMenu.at.x}px; top: {feedMenu.at.y}px;"
>
<AddDownstreamMenu
asset={feedMenu.asset}
onAddScript={onAddScriptForAsset}
pathPrefix={feedMenu.data.pathPrefix ?? pathPrefix}
defaultPathSuffix={feedMenu.data.defaultPathSuffix ?? defaultPathSuffix}
openSignal={feedMenuSignal}
>
{#snippet trigger({ open })}
{#if open}
<AddDownstreamPill expanded />
{/if}
{/snippet}
</AddDownstreamMenu>
</div>
{/if}
{#if hoveredEdge && hoveredEdgeText}
@@ -7,7 +7,8 @@
import NodeActionsMenu from './NodeActionsMenu.svelte'
import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte'
import NodeOnDot from './NodeOnDot.svelte'
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
import AddDownstreamMenu from './AddDownstreamMenu.svelte'
import AddDownstreamPill from './AddDownstreamPill.svelte'
import {
ArrowUpRight,
Code2,
@@ -15,7 +16,6 @@
History,
Play,
Loader2,
Plus,
ShieldCheck,
ShieldAlert,
CheckCircle2,
@@ -26,7 +26,6 @@
import type { ScriptLang } from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import { sendUserToast, type Item } from '$lib/utils'
import { PIPELINE_LANGUAGES } from './pipelineLanguages'
import type { PipelineOutputKind } from './pipelineTemplates'
import type { DbtAssetProvenance } from './types'
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
@@ -116,6 +115,9 @@
) => void
pathPrefix?: string
defaultPathSuffix?: string
/** Starts dragging the "+" onto a script (subscribing it to this asset)
* or onto the blank canvas (opening the add menu there). */
onStartFeedDrag?: (e: PointerEvent) => void
// Producer scripts/flows that write to this asset, supplied by
// the canvas from the graph's write/rw edges. Drives the on-hover
// "Run" button. Includes unsaved/draft producers so the button
@@ -212,18 +214,7 @@
}
}
function handlePick(pick: PipelineInsertPick) {
if (pick.kindId === 'pipeline_script' && pick.language && pick.path) {
data.onAddScript?.(
{ kind: data.asset_kind, path: data.path },
pick.language as ScriptLang,
pick.path,
(pick.outputKind ?? 'none') as PipelineOutputKind,
pick.aiPrompt,
{ outputAsset: pick.outputAsset }
)
}
}
// A red asset needs fixing where its error comes from, not a downstream step;
// and a step downstream of what nothing writes would never run on its writes.
@@ -669,41 +660,33 @@
script creation, half-overlapping it like the flow editor's
between-step inserter. -->
<div class="absolute left-1/2 -bottom-3 -translate-x-1/2 z-10">
<PipelineInsertMenu
kinds={[
{
id: 'pipeline_script',
label: 'Add downstream pipeline script',
description: 'Triggered when this asset changes',
icon: Code2
}
]}
languages={PIPELINE_LANGUAGES as any}
pathPrefix={data.pathPrefix ?? ''}
defaultPathSuffix={data.defaultPathSuffix ?? ''}
onPick={handlePick}
<AddDownstreamMenu
asset={{ kind: data.asset_kind, path: data.path }}
onAddScript={data.onAddScript!}
pathPrefix={data.pathPrefix}
defaultPathSuffix={data.defaultPathSuffix}
>
{#snippet trigger({ open })}
<!-- A circle at rest; hovering widens it into a labelled pill, which
stays open while its menu is (the menu can open under the pointer
and take the hover away). The label animates its max-width (width
can't transition to auto) and never wraps. -->
<!-- A click opens the menu; a drag goes to the canvas, which
tells a real drag from a click by how far the pointer moves. -->
<button
type="button"
class="nopan nodrag block"
onclick={(e) => e.stopPropagation()}
class="group/add h-6 min-w-6 rounded-full flex items-center justify-center bg-surface border border-gray-400 dark:border-gray-600 text-secondary hover:text-primary hover:border-gray-500 shadow-sm leading-none overflow-hidden transition-[padding] duration-200 ease-out hover:pl-1 data-[open=true]:pl-1"
data-open={open}
onpointerdown={(e) => {
if (e.button !== 0) return
e.preventDefault()
data.onStartFeedDrag?.(e)
}}
aria-label="Add downstream step"
title={data.onStartFeedDrag
? 'Click to add a downstream step, or drag onto a script to run it after each write'
: undefined}
>
<Plus size={16} strokeWidth={2.5} class="shrink-0" />
<span
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>
<AddDownstreamPill expanded={open} />
</button>
{/snippet}
</PipelineInsertMenu>
</AddDownstreamMenu>
</div>
{/if}
</div>
@@ -10,6 +10,8 @@ export type ViewEdge = {
unsaved?: boolean
/** Assets-only edges: the folded runnables the data goes through. */
via?: string[]
/** Assets-only edges: a write to the source reruns one of `via`. */
reactive?: boolean
}
/** The assets-only node standing for the scripts that build no asset. */
@@ -63,6 +65,9 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
} else if (e.kind === 'trigger-native') push(triggersOf, e.target, e)
}
const triggered = new Set(
edges.filter((e) => e.kind === 'trigger-asset').map((e) => `${e.source}\n${e.target}`)
)
const out = new Map<string, ViewEdge>()
for (const [assetId, writes] of producersOf) {
for (const w of writes) {
@@ -72,8 +77,10 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
const id = `flow:${r.source}->${assetId}`
const prev = out.get(id)
const unsaved = !!(w.unsaved || r.unsaved)
const reactive = triggered.has(`${r.source}\n${w.source}`)
if (prev) {
prev.unsaved = prev.unsaved && unsaved
prev.reactive ||= reactive
if (!prev.via?.includes(w.source)) prev.via = [...(prev.via ?? []), w.source]
} else {
out.set(id, {
@@ -82,6 +89,7 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
target: assetId,
kind: 'asset-flow',
unsaved,
reactive,
via: [w.source]
})
}
@@ -136,8 +144,10 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
for (const r of inputsOf.get(id) ?? []) {
const eid = `flow:${r.source}->${NO_ASSET_NODE_ID}`
const prev = out.get(eid)
const reactive = triggered.has(`${r.source}\n${id}`)
if (prev) {
prev.unsaved = prev.unsaved && !!r.unsaved
prev.reactive ||= reactive
if (!prev.via?.includes(id)) prev.via = [...(prev.via ?? []), id]
} else {
out.set(eid, {
@@ -146,6 +156,7 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
target: NO_ASSET_NODE_ID,
kind: 'asset-flow',
unsaved: !!r.unsaved,
reactive,
via: [id]
})
}
@@ -46,6 +46,6 @@ describe('describeEdge', () => {
nameOf,
dataOf
)
).toEqual({ subject: 'clean', phrase: 'is built from', object: 'raw', note: 'By Clean.' })
).toEqual({ subject: 'clean', phrase: 'is built from', object: 'raw', note: 'By Clean. A write to raw does not rerun it.' })
})
})
@@ -13,6 +13,7 @@ export type DescribedEdge = {
muted?: boolean
missing?: boolean
via?: string[]
reactive?: boolean
macro_names?: string[]
via_use?: boolean
}
@@ -71,15 +72,18 @@ export function describeEdge(
}
case 'asset-flow': {
const via = (e.via ?? []).map(nameOf).join(', ')
const rerun = e.reactive
? `Each write to ${src} reruns it.`
: `A write to ${src} does not rerun it.`
// The scripts that build nothing only read: name them, not the node.
if (e.target === NO_ASSET_NODE_ID) {
return { subject: src, phrase: 'is read by', object: via || tgt }
return { subject: src, phrase: 'is read by', object: via || tgt, note: rerun }
}
return {
subject: tgt,
phrase: 'is built from',
object: src,
note: via ? `By ${via}.` : undefined
note: via ? `By ${via}. ${rerun}` : rerun
}
}
case 'dbt-ref':