fix: review — compare every deployed field, passive-read filter only with its toggle, drag cancel, asset escape hatch

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 18:53:32 +02:00
co-authored by Claude Opus 5.5
parent 112f33df80
commit 1b159d91f5
4 changed files with 51 additions and 10 deletions
@@ -377,8 +377,21 @@
onDrag.over = nodeUnder(ev.clientX, ev.clientY)
onDrag.blank = !onDrag.over && overBlank(ev.clientX, ev.clientY)
}
const up = (ev: PointerEvent) => {
// A drag that ends without a release (pointer cancelled, window left) drops
// nothing, and must not leave its line following the cursor.
const teardown = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', cancel)
window.removeEventListener('blur', cancel)
document.removeEventListener('visibilitychange', cancel)
}
const cancel = () => {
teardown()
onDrag = undefined
}
const up = (ev: PointerEvent) => {
teardown()
const drag = onDrag
onDrag = undefined
if (!started || !drag) return
@@ -394,7 +407,10 @@
drop(target && drag.eligible.has(target) ? target : undefined, at, !target && drag.blank)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up, { once: true })
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', cancel)
window.addEventListener('blur', cancel)
document.addEventListener('visibilitychange', cancel)
}
function startOnDrag(sourceId: string, scripts: string[], e: PointerEvent) {
if (!onAddAssetTrigger || scripts.length === 0) return
@@ -1488,8 +1504,10 @@
showPassiveReads = localStorage.getItem(SHOW_PASSIVE_READS_KEY) !== 'false'
} catch {}
let withoutPassive = $derived(withoutPassiveReads(model.nodes, model.edges))
// Only where the toggle is shown: the preference is global, and a graph
// without the toggle would have no way to show the reads again.
let shownModel = $derived(
showPassiveReads || withoutPassive.dropped === 0
!assetsOnlyToggle || showPassiveReads || withoutPassive.dropped === 0
? model
: { ...model, nodes: withoutPassive.nodes, edges: withoutPassive.edges }
)
@@ -162,7 +162,7 @@
onStartOnDrag?: (e: PointerEvent) => void
/** The assets-only node for the scripts that build no asset. */
noAsset?: boolean
/** Nothing in the workspace writes it. */
/** 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
@@ -8,8 +8,9 @@ export interface AssetGraphAssetNode {
path: string
// Filled in client-side: the node is misconfigured (see `pipelineNodeErrors`).
error?: string
// Filled in client-side: nothing in the workspace writes it, so a step
// downstream of it would never run on its writes.
// Filled in client-side: no script in the workspace writes it, so a step
// downstream of it would never run on its writes (a flow's write fires no
// asset trigger).
never_written?: boolean
// Fork workspaces only: 'fork' when this ducklake asset was materialized in
// the fork itself, 'deferred' when reads fall back to the parent workspace's
@@ -1180,10 +1180,27 @@
// (same rationale as the live-callback handlers above) and so the
// template can gate them per-mode with simple ternaries — the canvas
// hides each affordance when its callback is undefined.
// Every field Save all deploys: a draft differing from the live script in any
// of them still has something to save.
const DEPLOYED_FIELDS = [
'content',
'language',
'summary',
'description',
'tag',
'kind',
'labels'
] as const
async function matchesDeployed(path: string, d: Draft, ws: string): Promise<boolean> {
try {
const live = await ScriptService.getScriptByPath({ workspace: ws, path })
return live.content === d.script.content && live.language === d.script.language
const norm = (v: unknown) =>
JSON.stringify(Array.isArray(v) && v.length === 0 ? null : (v ?? null))
return DEPLOYED_FIELDS.every(
(f) =>
norm(f === 'description' ? (live[f] ?? '') : live[f]) ===
norm(f === 'description' ? (d.script[f] ?? '') : d.script[f])
)
} catch {
return false
}
@@ -1216,9 +1233,14 @@
opts?: { soleScript?: string }
) {
if (s?.kind === 'asset' && opts?.soleScript) {
const script = opts.soleScript
if (!(await isMaterialized({ kind: s.asset_kind, path: s.path }))) {
s = { kind: 'runnable', runnable_kind: 'script', path: script }
const asset = s
if (!(await isMaterialized({ kind: asset.asset_kind, path: asset.path }))) {
s = { kind: 'runnable', runnable_kind: 'script', path: opts.soleScript }
// Job history expires, so "no write on record" may be an asset built long
// ago: its own pane stays one click away.
sendUserToast(`No build of ${asset.path} on record: opened its script.`, false, [
{ label: 'Show the asset', callback: () => void handleCanvasSelect(asset) }
])
}
}
// Clicking a node while the pane is explicitly hidden is a request