fix: hide subscribed-only unwritten assets, review fixes for the edit pane and edge actions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 17:41:48 +02:00
co-authored by Claude Opus 5.5
parent 0024e623c2
commit 5edcb14303
2 changed files with 61 additions and 37 deletions
@@ -328,7 +328,14 @@
model.edges.some((x) => x.kind === kind && x.source === e.source && x.target === id)
const target = { script: script.path as string, asset, reads: has('lineage-read') }
if (has('trigger-asset')) stop.push(target)
else if (target.reads) listen.push(target)
// A script rerunning on its own writes would loop: offered only for inputs.
else if (
target.reads &&
!model.edges.some(
(x) => x.kind === 'lineage-write' && x.source === id && x.target === e.source
)
)
listen.push(target)
}
const where = (ts: EdgeTarget[]) =>
ts.length === 1 ? ts[0].script : `${ts.length} scripts: ${ts.map((t) => t.script).join(', ')}`
@@ -343,7 +350,10 @@
if (stop.length) {
out.push({
label: 'Stop rerunning on writes',
detail: `${stopListeningChangeText(asset, stop.some((t) => t.reads))} in ${where(stop)}; only the annotation header changes. Saved as a draft.`,
detail: `${stopListeningChangeText(
asset,
stop.some((t) => t.reads)
)} in ${where(stop)}; only the annotation header changes. Saved as a draft.`,
run: () => onEdgeAction?.({ action: 'stop', targets: stop })
})
}
@@ -356,7 +366,8 @@
const nameOf = (id: string) => {
const n = byId.get(id)
if (!n) return id.replace(/^[^:]+:/, '')
if (n.type === 'asset') return formatShortAssetPath({ kind: n.data.asset_kind, path: n.data.path })
if (n.type === 'asset')
return formatShortAssetPath({ kind: n.data.asset_kind, path: n.data.path })
if (n.type === 'no-asset') return 'the scripts that build no asset'
if (n.type === 'trigger') return n.data.summary || n.data.ref
return n.data.summary || n.data.path
@@ -1227,13 +1238,20 @@
}
: onDeleteAssetUpstream && u
? {
deleteBlocked: !u.multiple
? r?.runnable_kind === 'flow'
? 'a flow builds it'
: 'its script builds other assets too'
: u.runnableIds.length > 1
? 'several scripts build it'
: 'its script has several triggers'
// The No-asset card stands for its scripts; an asset is built by them.
deleteBlocked: data.noAsset
? !u.multiple
? 'it stands for a flow'
: u.runnableIds.length > 1
? 'it stands for several scripts'
: 'its script has several triggers'
: !u.multiple
? r?.runnable_kind === 'flow'
? 'a flow builds it'
: 'its script builds other assets too'
: u.runnableIds.length > 1
? 'several scripts build it'
: 'its script has several triggers'
}
: {})
}
@@ -1903,10 +1921,7 @@
{/if}
{#if scheduleWizard}
<div
class="fixed w-0 h-0"
style="left: {scheduleWizard.at.x}px; top: {scheduleWizard.at.y}px;"
>
<div class="fixed w-0 h-0" style="left: {scheduleWizard.at.x}px; top: {scheduleWizard.at.y}px;">
<PipelineInsertMenu
kinds={[{ id: 'schedule', label: 'On schedule', description: '', icon: Clock }]}
onPick={() => {}}
@@ -185,6 +185,8 @@
if (pe.folder !== f) {
if (pe.folder !== undefined) {
pe.reset()
// The set-aside edit pane belongs to the folder being left.
editPane = undefined
// Re-scope the Global chat's pipeline prompt to the new folder. The
// helper methods already read the reactive folder, but the system
// message string was built for the old one and is only rebuilt when
@@ -241,7 +243,7 @@
if (m === 'view') url.searchParams.delete('mode')
else url.searchParams.set('mode', m)
// Same-pathname navigation — the drafts leave-guard skips it.
goto(url, { replaceState: opts?.replace ?? false, keepFocus: true, noScroll: true })
return goto(url, { replaceState: opts?.replace ?? false, keepFocus: true, noScroll: true })
}
// View-mode drafts overlay: "what View will show once the drafts are
// deployed". A view variant rather than a third mode — ephemeral (not
@@ -249,17 +251,6 @@
// shared link couldn't reproduce it anyway).
let includeDrafts = $state(false)
// Asset-kind → syntax-prefix for `// on <ref>` reconstruction. Mirrors
// ASSET_KINDS in backend/parsers/windmill-parser/src/asset_parser.rs.
const ASSET_PREFIX: Record<AssetKind, string> = {
s3object: 's3://',
resource: '$res:',
ducklake: 'ducklake://',
datatable: 'datatable://',
volume: 'volume://',
dbt: 'dbt://'
}
// Path-input split for the insert menu: a read-only `f/<folder>/` chip
// on the left the user can't delete, plus an editable suffix seeded
// with a placeholder name.
@@ -1210,7 +1201,7 @@
aiPrompt?: string,
options?: PipelineInsertOptions
) {
const ref = `${ASSET_PREFIX[asset.kind]}${asset.path}`
const ref = assetRef(asset)
openMaterializerDraft(
language,
scriptPath,
@@ -1306,7 +1297,9 @@
): Promise<boolean> {
const ws = $workspaceStore
if (!ws) return false
if (mode !== 'edit') setMode('edit')
// Wait for Edit: the switch reopens Edit's set-aside pane when it lands, which
// would take the place of the draft opened below.
if (mode !== 'edit') await setMode('edit')
pe.selection = undefined
pe.activeDraftPath = undefined
await tick()
@@ -1430,6 +1423,15 @@
scheduleFor: {
script: path,
onSchedule: async (schedule) => {
// Checked first: a header saying `on schedule` with no schedule
// behind it would be a new error, not a fix.
if (pe.triggerDrafts.has(triggerDraftKey('schedule', schedule.path))) {
sendUserToast(
`Another draft schedule already uses the path ${schedule.path}`,
true
)
return
}
const ok = await editScriptHeader(
path,
(c) => addTriggerDirective(c, 'schedule'),
@@ -1741,22 +1743,29 @@
const showDrafts = mode === 'edit' || includeDrafts
const errors = nodeErrors
const schedules = scheduleInfo.current
// An asset drawn only because a script subscribes to it, which nothing ever
// writes, stands for nothing: it is left out, and the subscriber carries the
// error. One a script reads or writes stays.
const used = new Set(g.edges.map((e) => `${e.asset_kind}:${e.asset_path}`))
const phantom = new Set([...errors.unwritten.keys()].filter((k) => !used.has(k)))
const triggers = g.triggers.filter(
(t) => t.trigger_kind !== 'asset' || !phantom.has(`${t.asset_kind}:${t.asset_path}`)
)
return {
...g,
triggers: schedules?.size
? g.triggers.map((t) =>
? triggers.map((t) =>
t.trigger_kind === 'schedule' && t.path && !t.draft && schedules.has(t.path)
? { ...t, ...schedules.get(t.path) }
: t
)
: g.triggers,
assets:
errors.assets.size === 0
? g.assets
: g.assets.map((a) => {
const error = errors.assets.get(`${a.kind}:${a.path}`)
return error ? { ...a, error } : a
}),
: triggers,
assets: g.assets
.filter((a) => !phantom.has(`${a.kind}:${a.path}`))
.map((a) => {
const error = errors.assets.get(`${a.kind}:${a.path}`)
return error ? { ...a, error } : a
}),
runnables: g.runnables.map((r) => {
const draft = showDrafts ? pe.drafts.get(r.path)?.script : undefined
const meta = scriptMetaByPath.get(r.path)