From 1b159d91f546cbffabcd516a488e34b7f729f659 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Wed, 30 Sep 2026 18:53:32 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20review=20=E2=80=94=20compare=20every=20d?= =?UTF-8?q?eployed=20field,=20passive-read=20filter=20only=20with=20its=20?= =?UTF-8?q?toggle,=20drag=20cancel,=20asset=20escape=20hatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../assets/AssetGraph/AssetGraphCanvas.svelte | 24 +++++++++++++-- .../assets/AssetGraph/AssetNode.svelte | 2 +- .../lib/components/assets/AssetGraph/types.ts | 5 ++-- .../(logged)/pipeline/[folder]/+page.svelte | 30 ++++++++++++++++--- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 007813d41e..dd47a1f5c9 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -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 } ) diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index 2163398372..e7d80da8c1 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -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 diff --git a/frontend/src/lib/components/assets/AssetGraph/types.ts b/frontend/src/lib/components/assets/AssetGraph/types.ts index cf4dd414cb..4ee7aa0216 100644 --- a/frontend/src/lib/components/assets/AssetGraph/types.ts +++ b/frontend/src/lib/components/assets/AssetGraph/types.ts @@ -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 diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte index 09bd462cb8..cd83e6071a 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte @@ -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 { 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