mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
* wip: one sidebar for operators and developers, with a detach mode Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: snap-detach the operator sidebar, and join the handle to the picker Dragging the sidebar into the left snap zone detaches it for an operator, who has no icon-only rail to collapse into; the width the drag started from is what gets persisted, so re-attaching restores the sidebar's size. The handle's colour pastille slides onto the card's workspace pastille as the card opens, alongside the workspace name, and both buttons that attach or detach the sidebar carry a tooltip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: attach and detach from the sidebar header, open the card under its handle The button that swaps the sidebar between docked and detached moves to the header, next to the workspace name, so it stays in one place across the swap; the icon-only rail has no room for it and keeps the footer copy. The handle itself becomes a component of its own, carries the workspace icon its picker uses, and opens the card rather than docking — docking is the header button's job. The card is placed under the handle measured at open time instead of at a fixed offset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: one page header across routes, with the workspace picker in it Every route now wears the same bar above its content: the sidebar toggle, the workspace picker, and whatever the route registers — an item's breadcrumb and summary, or a section name — with the route's own buttons at the far end. The bar's row is level with the sidebar's own top row, which the workspace ⇄ sessions switch now owns, so the two read as one band. A route fills the bar by mounting PageHeaderContent, which registers a getter rather than a snapshot: the layout calls it while rendering, so a path or summary the page edits reaches the bar without an effect keeping two copies in step. Nothing registers yet; the routes follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: detail pages fill the page header, which now spans above the sidebar A script or flow detail page no longer draws a bar of its own: its path becomes the header's breadcrumb, its summary keeps the rename-and-labels popover next to it, and its badges, menu and buttons ride along as the header's actions. The band runs the full width with the sidebar hanging below it. A snippet rendered in the header initialises its components under the header, not under the page that wrote it, so anything the page's tree provides is lost — the detail page's buttons read TriggerContext and threw. ContextBridge carries the entries a registration names back around the render. The floating handle is gone with it: the header's own toggle is the one control, hovering it peeks the sidebar in and clicking pins it. The workspace picker in the header is a denser variant of the sidebar's, and the card's outside-click catcher starts at the card rather than the viewport top, so it no longer swallows clicks meant for the toggle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: one breadcrumb in the page header, and the banners that used to sit under it The header band now carries a single breadcrumb — workspace, fork, then the item's path — built from one component, so every segment has the same size, weight and icon size, and slashes separate them. The item's kind icon rides with its summary, after a dot, rather than with the path. The fork and drafts banners move into the band as badges plus a subtle Review button: ahead/behind counts instead of a bare change total, and the per-kind breakdown stays on the compare page. The home hero's CLI, MCP and Hub links move into an ellipsis menu, alongside the toggle that pins the session chat to the page. The flow editor and the app viewer hand their bar to the band too, and a route can drop the band's bottom border when its own content draws one — the border stays in the box with a transparent colour, so contents never shift a pixel across a navigation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the runs page's own header row moves into the page header The title, the queue counters, the kind and status filters, the schedule and future-job switches, the search field and the timeframe picker all ride the band now, and the page starts at the chart — one row of vertical space back. The two toggle groups appear from 1500px: below that the bar cannot hold them next to the search field, and the same filtering is reachable from the search field's own kind and status chips. The search field is what gives width back, and the breadcrumb yields last, so a page's own name stays whole while the workspace and fork names truncate. The queue counters keep their icon form at every width, since the bar has room for two counters and never for the two labelled blocks. Their counts hang outside their icons, so the group carries the margins that keep a badge off its neighbour, and the suspended counter answers a hover at zero rather than sitting there as a dead icon. The band is 44px rather than 40, and a page can hand the breadcrumb a hint — the documentation tooltip that belongs with the page's name rather than with its buttons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: no border under the page header, and the peek card closes on the way out The band draws no bottom edge and no page asks it to, so `barBorder` goes with it — a page that needs the separation draws its own line, as the detail pages now do above the run form and the tab strip. With nothing toggling a border, the band is a flat 44px on every route. The detached sidebar's card is a hover affordance, so it leaves when the pointer does: off the handle and off the card both, on the same debounce a hover menu uses, because the two do not touch and the leave of one fires before the enter of the other. The mobile drawer is opened deliberately and still stays until it is dismissed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: one band, one workspace part, and the pickers that hang off it The band is a single element whose placement is styled rather than branched: a page registering a side-panel inset used to swap it between two `{#if}` arms, destroying the header and rebuilding it a frame later — a blink of no header with the content sliding up into the gap. The workspace and its fork become one breadcrumb part: the name leads home (inert in a session, where the workspace is the session's scope rather than a place to go) and one chevron opens the picker, which already expands a family to reach its forks. The picker's panel hangs from the part's left edge, opens with the active family expanded, and closes before the switch runs — the switch renames the workspace, which moves the trigger the panel hangs from, and floating-ui was chasing it across the viewport. The item's path is a plain label rather than a row of pickers: same separators and weight, and a click copies it with its own confirmation. The kind icon goes, the page below says what kind it is. Around them: a "prod" badge for a root workspace, the session's acting fork beside the session's name rather than in the breadcrumb, the edit-in-dev affordance as a button in the header, the drafts count as a sentence, and a fade when the workspace changes under a page that stays mounted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a page's own controls when the workspace navigation is hidden `menuHidden` means "this is an embed, don't show the workspace's navigation" — but Runs, the detail pages and the flow editor now keep their controls in the band and nowhere else, so hiding it took the filters, the timeframe and the page's buttons with it. A session's preview iframe opens exactly that way. The band now renders for an embed too, without its breadcrumb, so the page keeps its controls and the embed still gains no way to navigate the host's workspace. The flow editor's autosave indicator moves into the header's actions with the rest of its bar: saving state, the autosave toggle and other people's drafts were left behind in the branch the full-page editor no longer takes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the header carries what it promised, and nothing it does not The registry's item described pickers the breadcrumb no longer has: nine fields — the editability flags, the change callbacks, the navigation hook — that nothing read, while the flow editor passed eight of them and quietly lost its inline rename. The type now says what the bar does: a path, and either a summary or the page's own control for one. The flow editor renames through that control, as the detail page already did. `barPlacement` went the same way: no page registered 'inline', so the layout's guard never fired. The sessions page registered an inset of 0 whenever its preview pane never mounted — the "no session selected" and opted-out arms — and 0 reads as "a page owns the right edge", floating the band over the page's first line. It registers an inset only once the pane has a width. The band's sidebar handle docks through `setDetached` like the card's own toggle, so docking still suppresses the rail's entry animation and closes the card; a summary without its breadcrumb no longer leads with a stray separator in an embed; a page's hint sits with its name whichever way the page named itself; and `Menu.closeInstantly` and `EditorHeader`'s `inline`/`hideSummary` go, their callers having been replaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: a deployed app gets the viewport, and the header waits in the corner An app is the page, not a page inside Windmill's chrome, so the band leaves the flow and floats above the viewport until the handle in the app's corner calls it down. The handle is faded until the pointer nears it, hovering peeks the band over the app, and a click pins it — one flag for every deployed app, in this browser, because someone who wants the breadcrumb over a dashboard wants it over the next one too. Peeking overlays rather than pushes: the app is an iframe, and taking 44px off its box on every pass of the pointer would re-lay-out whatever it draws. Pinned, the band is in flow like any other route's and the app starts under it. The sidebar is untouched — docked stays docked, detached stays a peek — since the pin is about the header alone. A page says it wants the viewport with one registry field, so the layout keeps asking the registry rather than knowing about apps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: one corner control for the chrome an app page hides Hovering the button overlaid on a deployed app brings back whatever that page is hiding: the band always, and the sidebar card too when the sidebar is detached. The card hangs from this button rather than from the header's own handle, which is off-screen with the band. The handle in the band no longer attaches the sidebar — it reveals it and nothing more. A control that hides the thing it sits on cannot also be the way to bring it back, so attaching stays with the toggle in the sidebar's own footer, where detaching happens. The band's two buttons go in the order the things nest: the sidebar's handle, then the header's pin. The overlaid button stands where the pin will be once pinned — same 28px box, and a slot further along when the sidebar's handle takes the first — so pinning moves nothing. It wears the panel pair the sidebar's toggle uses: solid while the band is away, dashed while it is shown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the access point sits in the corner, and the header covers it The button is in the page's top-left corner — 8px in, or just past the rail when the sidebar is docked — rather than shifted along to where the header's pin would land. Hovering brings the header down over it: an access point that the regular view covers once it arrives, so there is nothing to line up and nothing to move. Because the header lands on the button, the pointer leaves it the instant it arrives. The band holds both itself and the sidebar card open while the pointer is anywhere on it, or the card would retract under a header that stayed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: on an app page the floating button owns both, and they move together The band drops its sidebar handle there: the floating control in the corner reveals the sidebar along with the band, and two handles for one gesture is one too many. The two are then linked — the pointer resting on either holds both, and leaving either collapses both — so they behave as the single thing the one button revealed. The card meets the header in a line: it hangs from the header's bottom edge rather than from whichever control opened it, which had it starting a couple of pixels high. With no header to sit under — an embed — it still hangs from the control that reached for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the revealed sidebar is a panel under the header, not a card over the page No rounded corners, no inset from the window: it starts at the header's bottom edge and runs to the left and bottom edges, with one border down its right side and a shadow to lift it off the page. A card floating in from nowhere read as a separate thing; this reads as the sidebar the header sits on top of. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: only an app page gets the flush panel; elsewhere the card still floats Over an app the sidebar reads as the panel the header sits on — flush left and bottom, square, one border down its right side. On every other page it is what it was: a card inset from the window with rounded corners and a border all round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an app opens with the header already away Opening an app showed the header and then slid it out: the page claimed the viewport on mount, and the band animated from where it had been rendering. The slide belongs to the hover, not to arriving, so it is armed a frame after the claim — the first hide is a cut. The claim moves to the route, which mounts before the viewer waiting on the workspace to resolve. A cold load now paints the band away from the first frame instead of showing a header that leaves again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: an app's Edit sits with its name At the far end of the bar it was a journey across the window from the thing it edits — on a page whose header is only there while the pointer holds it, a journey with a time limit. It goes beside the app's path instead. The slot it uses is the one Runs' documentation tooltip already had, renamed from `hint` to `afterName` and documented for both: what belongs to the page's name, whether that is a mark or a control that acts on what the name points at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: developers collapse, operators detach One control each, rather than three for everyone. A developer switches the sidebar between icon-only and full width and never detaches it; an operator, who has no icon-only rail to collapse to, hides it instead — by the button in its footer or by the same drag that collapses it for a developer. The way back survives either way: a detached sidebar keeps its "Show sidebar" button for anyone, so a developer who detached before this was so is not left with a sidebar they cannot return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: configure pipeline triggers and outputs as drafts in the editor Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: one card for pipeline nodes, and the pipeline pages in the page header Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: drop a removed node's trigger drafts, and confirm triggers when leaving Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: show pipeline assets red when unused or awaited but never produced Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: route long pipeline edges around nodes in the target column, and flag scripts waiting on unproduced assets Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: a page is as tall as it is given, not as tall as the screen The band takes 44px off the top of every route, so a page sized to the viewport overflows by exactly that much: h-screen inside a column that already lost the band's height. These pages measure the box they are in instead. CenteredModal gains `inPage` for the five pages that render it inside the layout rather than over it — the same modal, centred in what is left of the page rather than in the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the band narrows, wraps, and draws its own edge The band publishes its width, so the pages filling it can answer it. Two tiers: under 1000px the breadcrumb keeps the workspace disc and its env badge and drops the name, and under PHONE_BAR (800) the trail stops yielding at 10rem — that floor is what makes the bar wrap, so a page's buttons take the line below rather than squeezing the name to an ellipsis. The band also draws the line between itself and the page, and only while something is scrolled under it. One capture listener on the window sees every scroller, since most pages scroll inside their own box and never move the layout's. An inset shadow, not a border: a border would move every page down a pixel the moment the line appeared. DetailPageLayout and InWorkspaceAppViewer drew that first line themselves; two lines would stack, so theirs go. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the script, app, runs and audit pages fill the band Each of these kept a header of its own above the page. They register with the band instead: the breadcrumb names what the page is on, and the page's buttons sit at the far end of the one row. They answer the band's width rather than the window's, since the buttons share that row with the breadcrumb. The script editor folds Diff and Settings into the menu it already shows when compact, the app editor drops the panel buttons, Awareness and Export and goes icon-only on Deploy and the mode switch, the flow editor menus its diff, and runs hides its counters. Runs also declares `actionsFlexible`: its search is the one control here that can give width back, and a box that refused to shrink would make it take its maximum instead. The run detail page loses its ActionRow, and the audit log its big title — the breadcrumb says where you are, and the page keeps its tooltip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: one sidebar column, shown as a rail or as a panel The rail and the drawer held two copies of the same column — the same sections, in the same order, in two pieces of markup, so a change to one silently skipped the other. Both now render `sidebarColumn`, and the shells keep only what differs: the rail its width, resize handle and collapse button, the panel its outside-click catcher and its transform. A phone gets that panel rather than a modal. There was a width test deciding between two presentations; the card under the band's handle works at any width, so it goes. What is left of the width test is the choice between a rail and a panel, plus a `(hover: hover)` test — the old `innerWidth >= 768` was standing in for "has a pointer", which is what decides whether the panel closes when the pointer leaves it. With the card everywhere, the backdrop, the `aria-modal` (nothing dims and nothing traps focus) and the drawer's close cross all go: the band's handle opens the panel and a tap outside closes it. The burger row goes with them. Entering panel width now collapses the rail transiently, so coming back to a wide window does not land on a rail the page was resized away from. The band also stops easing its right edge. That transition belongs to the rail, which moves the band's left; its right follows a splitter the page owns, and easing it left the band lying over a session's side panel — a blank strip across that panel's own header for 200ms after every drag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the home composer flies to the menu that restores it Removing the composer puts it away rather than throws it out, and the way back is one item in the band's menu — easy to miss on a page the composer has just vacated. A mark flies from the control that removed it to that menu, and the menu answers with one ring as the mark dissolves, so where it went is witnessed rather than explained. The cross and the menu item now read "Remove session chat", one name for one thing whichever a reader reaches for. Reduced motion skips both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: give pipeline asset nodes the trigger nodes' background again Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: taller pipeline nodes, with the asset + back on the node's edge Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: judge dbt relations by provenance, keep dynamic_skip on reopen, refresh saved scripts' titles Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: label the pipeline + buttons: add data source, add downstream step on hover Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: name a schedule node's cadence, like "Every day at 12:00" Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep the downstream-step pill open while its menu is, without flicker Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: room between pipeline node chips and the node's edge Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: refuse renaming a pipeline draft out of the pipeline's folder Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: title pipeline schedule nodes with the schedule's summary Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * revert: keep operators out of AI sessions That change is its own PR; this one is about the navigation. `prefersSessionHandoff` takes the role again and every entry point passes it, the sessions page refuses an operator arriving by URL, and the layout leaves them on the docked chat. Two clauses are new rather than restored, because the nav rework is what gives an operator a sidebar at all: the Workspace/AI Sessions switch and session mode are both hidden from them, since the page behind them declines. `askAiShown` gains the operator case for the same reason — they keep the docked pane whatever the beta gate says, so the sidebar has to offer a way into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: assets-only view of a pipeline, with producer and trigger chips Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: default schedule handlers, unique trigger draft paths, zoned cadence, valid script names Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: setup banner without icon, button actions, plainer activity rows Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: assets-only trigger chip under the title, editable and selectable Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: await schedule defaults before confirming, refuse colliding trigger renames Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: size assets-only cards to their content Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: pipeline draft sync status beside the folder name in edit mode Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: autosave edits to a deployed pipeline script from the first keystroke Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: show schedule handlers once their defaults load, draft suffix on static chips Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep the pipeline folder while the page tears down on leaving Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: discard the open pane's autosaved edits with Discard all Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: pipeline node context menus, add-at-cursor, and assets-only delete Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: borderless pipeline cards on the main surface, like flow steps Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: remember which pipelines are in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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> * style: more room between pipeline layers for the bottom + button Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: background on the trigger chip only when it opens an editor Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: assets-only delete in both modes, why it is blocked, and a Show scripts toggle Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: remove the script before its trigger, archive it for non-admins Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: scope assets-only memory by workspace, no delete for operators, flow reason Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: fly in the pipeline node menus Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: size the Show drafts toggle like the other header buttons Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: a No asset node for scripts that build nothing, in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: describe a pipeline edge's relationship on hover Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: name assets-only edges by what is built from what Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: open an asset's upstream trigger from its pane header Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: reopen the edit-mode pane when coming back from View Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: no red border on errored pipeline nodes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: Fix actions on misconfigured pipeline nodes, and trigger toggles on edges Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: folded errors and edge toggles in assets-only view, preset writer wizard Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: flag pipeline scripts nothing starts, with schedule/webhook/remove fixes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: an unwritten subscription is fixed on its script only, by removing it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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> * fix: an unwritten subscription is the subscriber's error, not the asset's Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: a script only started by reads of unwritten assets is flagged too Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: no add-downstream + on assets nothing in the workspace writes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: Save all treats a draft identical to the deployed script as saved Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: remove-from-View opens its dialog, error chip fits, no link to an undrawn asset Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: drag a script's dot onto an asset to subscribe it; unbuilt asset opens its script Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: toggle to hide reads that don't trigger runs in the pipeline graph Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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> * fix: a programmatic on/mute line joins the existing ones in the header Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: address review — live-version save check, bounded writer lookups, script-only trigger writes, dbt sources, hidden pane Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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> * fix: keep every control the band took over reachable Four controls the page-header conversion could put out of reach, each found by a review pass and reproduced in the browser: - The flow editor's preview buttons. `topbarWidth` is bound only in the branch a full-page editor does not render, so `compactTopbar` was permanently false there; below the 800px phone bar `phoneTopbar` dropped the buttons inline and the graph overlay never took them, leaving FlowPreviewButtons mounted nowhere and step testing, whole-flow testing and "Test flow & record" no-ops through optional calls. It now measures the header the way ScriptBuilder and AppEditorHeader do, and the inline and overlay conditions are exact complements. - The pin on a deployed app. The band lands over the corner handle that called it down, so the pointer that arrives there hits the band. The band carries the pin itself now, on the same pixels, in place of the sidebar handle it hides. - The detached sidebar's own menus. They portal to `body`, so moving into one left the card and closed it 200ms later, sliding it out from under the menu being read. The card holds while the pointer is in a menu and takes the close from the leave of that. - A deployed app's name for a read-only viewer. The breadcrumb was registered behind write access, so without it the trail fell back to the section name "Apps"; and the Edit button rode `afterName`, which the band drops for an embed along with the trail. The name is registered for whoever opened the app, and what sits after it survives `navHidden` as the page's own control. navPageFor gets a table test: it answers both the sidebar links and the breadcrumb label, and its longest-prefix and first-segment rules are what made the "Apps" fallback look like a correct answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: drop what the operator revert and this refactor left behind The operator tour's last step named AI sessions among the pages the sidebar opens, which operators have no way to reach. The nav handle's comment named the AI chat layout's menu button as a second way into the card; that button and its prop are gone, and the corner control on a page that hides the band is what stands in for the handle now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: prettier the three files this branch left unformatted `prettier --check` fails on all three at the previous head. Whitespace only, apart from one Button tag that fits on a line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: assets-only nodes name their script in a header tab instead of a side chip Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: no loading spinner in the pane while a clicked script loads Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: no subscribe drag dot in view mode Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: opening a draft no longer autosaves; header tab styling, selected edge, taller cards Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * revert: clicking an unbuilt asset opens the asset again, not its script Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: header tab sets card width, edit icons on script header and trigger chip, no pane header shift on open Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: no trash in the pane header; card width ignores the run chip so a click never re-lays the graph Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: taller trigger chip; node menu and guard badge on the card corner, below the header Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: give a page without the band's chrome what the band was carrying Second review round, three reachable defects plus one decision: - A public raw app was 150px tall. The bundle's iframe is `h-full`, and dropping its `min-h-screen` left nothing to resolve against on the share, embed and custom-path routes — none of which has a layout above them. Measured: 150px in a 900px viewport. The floor goes back for those routes and stays off inside the workspace, where the layout hands a height down and `min-h-screen` would overflow it by the band. - The band retracted out from under its own open menu on a deployed app. Last round taught the sidebar card to hold while the pointer is in a portaled menu and left its sibling alone, but the band opens menus too — the workspace disc, the fork family, every path level. One helper now, used by both closers. - `bandPeeked` outlived the page it belonged to. Clicking a control in the band navigates away, and the next page may not be one the band floats over, so the hover handlers that would clear it are gone — leaving the band already down when a full-bleed page came back. `beforeNavigate` clears it. - An embed has no band to carry a control, so the app's Edit floats over the canvas there, where it sat before the band existed. Registering it as the band's `afterName` under `navHidden` instead would have cost the app 44px of the iframe for a row holding one button. Reading "is the workspace navigation hidden" now has one definition (`isMenuHidden`), since a page has to answer it too: the flag is sticky inside an iframe, so the raw query param is the wrong thing to read. Also inset the band by the rail only when there is a rail — an embed reserved its width and crowded its own controls into a compact layout early. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a path that names nothing yet has no breadcrumb leaf `splitItemPath` documents that it gives up on a path still being typed, but `f/demo/` is three parts and returned a leaf with an empty name — a blank segment in the trail. The editor binds the path live, so the breadcrumb sees every keystroke. Tested here rather than `navPageFor`'s sort: no two `NAV_PAGES` paths nest, so the longest-prefix case that test claimed to pin has only one candidate. Renamed it to what it actually checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: prettier the four remaining files this branch left unformatted All four fail `prettier --check` on the branch and pass at the merge base. `raw_apps/utils.ts` also fails but already did at the base, so it is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: pipelines open in the assets-only view by default Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: a No asset node per script, banner-only when writes start it; no On asset change chip Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: delete a node's script, trigger, or both from its menu; red header for a script with errors Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: an errored script header stays red when selected Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: coloured run badge border; per-script delete when no combined delete; run button centred on the card Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: Add data source pill sits one small gap above the graph Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: Multiple triggers chip picks the trigger to edit; each folded trigger deletable from the node menu Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: add-step menu labels its path field Path Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: iterate NodeLists with Array.from so svelte-check passes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: no trigger delete for flow-built assets; Multiple triggers chip shows the one being edited Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: Archive button for archive-only deletes, single delete doc, inert Multiple triggers label in View Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: the raw app editor fills the band like the other three It was the one editor route still rendering its own bar under the band, so it showed two header rows and the band named the app "Apps raw" — `navPageFor`'s first-segment fallback, because nothing on the route registered an item. Its top bar now becomes the page header the way the script, flow and low-code app editors' do: the path and summary are the breadcrumb's, the file-sidebar toggle, autosave cloud, presence mark, menu, Diff, Jobs, export, AI and Deploy are the header's actions, and the path is a plain label since this editor renames from its deploy drawer. The actions render under the band, outside the subtree `RawAppEditor` declares the operating workspace in, and `OpenInSessionButton` asks that context who the acting user is — so the workspace travels with them. `operatingWorkspaceContextEntry()` hands it over without exporting the key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the folders a half-typed path already names The trailing-separator guard gave up on `f/demo/` entirely, which dropped the whole trail to the kind chip and brought `f/demo` back on the first character of the name. The levels the path does name come back now; only the leaf waits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: pin holds the first slot, and the nav table says what it shares Pinning brings the sidebar's handle back, and from behind it the unpin button sat one slot over from where the pin had just been clicked — so a second click in the same place opened the sidebar instead. An operator is detached by default, which makes that the default state of the feature rather than an edge case. `NAV_PAGES` claimed one table stops a page being called one thing in the nav and another in the header, which holds for the seven entries the sidebar's main group is built from and not for the four grouped separately — `/routes` was "Triggers" in the header and "HTTP" in the nav. The comment now says which is which, and that entry is named for the page rather than its group. `sidebarPageAllowed` gets a table test: it decides what an operator sees, and its `=== true` is what a future `Option`-style default would silently change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the band names the path Deploy will create, and keeps the summary readable A brand-new app is parked at a `draft_<uuid>` placeholder, and the band named that rather than the path its Deploy will create — `newEditedPath` refuses the placeholder for exactly this reason, and the editor's own bar read it. Both app editors register it now. Verified: the trail reads `u/admin/gentle_app` while the URL is still the draft. The band's summary gets the `title` the editor's own bar had, so one the bar truncates can still be read on hover. The wrapper that hid AppExportButton on a phone never hid anything: the component draws no trigger — it is the export drawer, which portals to `body` — so the class toggle was inert in both states and the comment read as if something moved. Dropped from both app editors. Two comments corrected: `NAV_PAGES`' `icon` does reach the breadcrumb for all eleven entries (`NavBreadcrumb` renders `routePage.icon`), so only `operatorKey` goes unread for the four the sidebar groups separately; and the raw editor's phone note described folding that does not happen — the file-sidebar toggle stays in the band, Jobs and the export are already in the menu, and AI is what stands down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an item the picker files under no kind keeps its name in the band The merge brought main's agents detail page, which reaches DetailPageHeader with no `errorHandlerKind` — and the band's item required one. `WorkspaceItemKindIcon` falls through to the script glyph for anything it does not know, so defaulting would have labelled an agent a script; the kind is optional now and the trail names the item without claiming it is one of the three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the file sidebar's toggle leaves the band The band is the workspace's header, not the raw editor's layout controls. ⌘B still folds the file sidebar, and the editor's own bar keeps the button where it draws one — a session pane. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the scroll edge stops measuring, and the comments describe the code The band's edge listener captures every scroller in the app — Monaco, the audit table, a run's logs — about once a frame while any of them moves, and it took two `getBoundingClientRect()` reads before looking at `scrollTop`. It now answers from `scrollTop` first and only measures when that answer differs from what the band draws, which is the transition rather than the steady state. The rect check still authorizes every change, so a scroller that does not start at the content's top still cannot claim the edge. The two agent routes main just added size themselves to the viewport, which is 44px more than the box the band leaves them. They measure 856 either way — the layout's `min-h-0` column clamps them — so this is the sweep the rest of the branch already had, making the height follow the box rather than depend on a parent's clamp. Six comments described the conversion rather than the code, which AGENTS.md calls out by name. Each now states the constraint a reader could break: why the summary keeps its popover here, why the audit hint is `afterName` and not an action, why a run registers a `section` rather than an item, why the Runs filters are `actionsFlexible`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "fix: the file sidebar's toggle leaves the band" This reverts commit1da8d16740. A folded file sidebar renders nothing of its own, so the toggle on the bar is the only way back short of knowing ⌘B. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the editors name their item in the band, and a pen renames it The band showed each editor's summary as an inline field, which put editing where the page names what it is working on. The summary is text there now, with its `title` for one the band truncates, and the pen that opens the path for editing sits after it — the same popover the editors' own bar carries, at the end of the name it renames. Every editor already has a field for the summary itself: the script's settings, the flow's Settings tab, and both app editors' deploy drawer. The popover moves into `PathEditPopover` rather than being written out four more times. It owns the open state and hands back the path as it was when it opened: the band's trail reads that while the popover is open, so a path growing under the pointer does not drag the pen — and floating-ui the popover — along with it. `EditorHeader` renders the same component, so the session pane and the band cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the pen renames the item and its summary The pen opened the path alone, which left the summary with nowhere to be edited from the band. It carries a Summary field above the path now — the item's name first, the address it lives at below — and the band's text follows it as the user types. `TextInput` with a label rather than the bar's inline field: `EditableInput`'s own docs rule itself out inside a popover, and it wraps this same input in its edit mode, so the field, the "Add a summary..." placeholder and the trim on commit are the behaviour that was there, in the shape the deploy drawer's Summary field already uses. The keydown stops at the field because every editor binds keys of its own and a summary is prose. `summaryEditable` carries the hosts' `customUi.topBar.editableSummary` through, and the editors' own bar passes it false: that bar keeps its inline field, and `hidePath` would take the pen with it, leaving a condensed session pane no way to name the item at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the agent editor fills the band too It was the last editor route drawing its own header row under the band, and the band named it "Agents" — `navPageFor`'s first-segment fallback, because nothing on the route registered an item. The agent's path is the breadcrumb now, its version badge reads after that name, the sentence that was the row's second line became the hint beside it, and the Chat/Form switch, Settings, Evals, version history and Deploy are the band's actions. No trail levels in the registration: this layout opens settings in a drawer and evals over the page, so unlike the dialog it never stands on a level — `trail` is its root alone here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the pipeline page fills the band Found by sweeping every route under the band: the pipeline editor drew its own row beneath it, 48px of a second header, while the band named only the section. Its name is the band's now, the folder it is open on sits with that name — the switcher is how you change pipelines, so it belongs to the name rather than to the actions — and the mode switch, deploy-all, activity, refresh and overflow menu are the bar's actions. The arrow back to the list stays: nothing else on the page points there, and the sidebar does not list pipelines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the app canvas controls stay reachable, and an unnamed item says so Codex found the app editor's canvas controls unreachable on an ordinary laptop: the owning route collapsed at 1350px, and a 1440 window with the rail out leaves 1232, so the width, theme and breakpoint toggles were hidden with nothing in the menu to replace them — and the breakpoint is the only way onto the mobile layout. Measured on the band, the toggles are 240px beside 334 of other actions and a ~430 trail, so they fit to about 1050: the threshold is 1100 now, and below it each of the three keeps a menu entry rather than disappearing. An item with no summary read as a bare `·` with an invisible pen after it, since the plain text replaced a field that had a placeholder. It now reads "Add a summary..." in the italic SummaryPathDisplay uses for the same state, and the pen stops hiding when there is nothing to hover. Three more from the review: - `actionsFlexible` says "these actions can give width back", which is true of the Runs filter row and not of the pipeline's row of fixed buttons. - `pathSnapshot ?? (newEditedPath || …)` keeps the `||` chain the five rename sites read and the declaration documents; `??` had made the rest unreachable, since `newEditedPath` is a string that is never undefined. - The band bridged `contexts` onto the actions alone, while the pen that decides whether a path is the caller's to change renders in `summaryContent`. The breadcrumb's `afterName` and the summary are bridged the same way now, so that permission check reads the tree that registered it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the widest action rows scroll, and the canvas toggles show only where they fit Measuring the band instead of estimating it: with the canvas toggles shown the app editor's actions are 826-911px and the trail with the summary another ~500, so the row overflowed the bar at every width up to about 1470 — the 1100 I set from an estimate was wrong, and the 1350 before it was too. The toggles come out at 1500 now, and the menu entries are what keeps all three reachable below that rather than merely out of the way. The two widest rows — this editor's and the pipeline's — scroll inside their own box, which is the escape the rows they replaced had (`overflow-x-auto whitespace-nowrap`). That answers pi's finding and clears the last 23-40px of band overflow at middling widths, which predated the conversion. Measured after: zero horizontal overflow from 852px of bar to 1392. From the CI round's other findings: - The pen is offered when either the summary or the path is editable, not the path alone: a host that allowed one and refused the other could no longer edit the summary from the band. `PathEditPopover` takes `pathEditable` to match, and its label names whichever it is given. - The four editors' comments called the summary read-only, which stopped being true when the pen beside it started editing it. - The pipeline's "centre group" comment described a layout it no longer has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: actionsFlexible covers both ways a row gives width back It read as if a search field were the only way to earn it, which stopped being true when the app and pipeline editors' rows started scrolling inside their own box. Both are the same bargain — the box may shrink because its contents can — and the flag is what the bar reads to allow it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: data pipelines in AI sessions (alpha) (#11453) * feat: pipelines in AI sessions with several folders open at once Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: deploy and discard a session's pipeline edits as one changes-list row Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: pipelines in the chat's listing and the preview picker; session tab shares the page's asset prefetch Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: a masked pipeline preselects only the scripts and triggers of its folder Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: review round — exact pipeline scripts in the fork mask, refuse a failed hand-off flush, deploy guard before awaits Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: review round — failed bundle save fails the deploy, removing a draft records no change Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: a session's pipeline tab opens node details in a modal, as the flow editor does Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: route pipeline graph edges orthogonally with spread ports, separate tracks and lanes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: pipeline graph edges merge by shared end, enter nodes by the side, keep fewest crossings Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: review round — incremental crossing search and cached routes, retry the leftover draft save, drop the pipeline row when its last draft goes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: retry the leftover draft save with what failed, never an empty draft Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * revert: pipeline graph edges back to bezier curves, detours curved too Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: hovering a pipeline graph node fades everything outside its lineage Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: hover fade eases edges too, through a stylesheet instead of new node objects Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: gentler hover fade without flicker on click; implied trigger edges recede in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: pipeline nodes ingest straight into DuckLake or data tables from Python/TS * fix: pipeline tools callable right after open_preview; write_script refuses pipeline nodes in sessions * feat: delete a data pipeline from the home list; pipeline rows match item rows * fix: upward detour edges keep their gutter lane * fix: pipeline delete takes only its own triggers and discards draft-only nodes by storage key Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: AI attaches trigger drafts to the pipeline nodes that declare them; materialize nodes keep no stale output Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: AI gives every pipeline node a succinct summary Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: session pipeline tools survive another tab taking the panel, reopening the editor on call Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: pipeline trigger lookups skip same-path flows; a failed trigger never hides its built node Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: deleting a pipeline clears it from open sessions so autosave cannot restore it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: data upload opens on its inputs alone, works in sessions, with a prominent Upload data button Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: closing the pipeline pane closes its modal instead of leaving it blank Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: a DuckLake asset opens on a Data tab with the database manager Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: inputs-only view infers draft inputs, keeps stored lineage, runs the live body Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: lineage follows the editor that inferred it; upload runs preview unsaved edits of deployed nodes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: readyDataUploadPaths is a ReadonlySet in the pipeline editor Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: selected pipeline nodes get their blue edge in dark mode too Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: no red flash on load while writer lookups and script bodies are in flight Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: deploy an open deployed script's edits, and don't resurrect them after a discard Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: deploy and discard close the open pane first instead of patching its edits Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep the pane closed while deploying, and clear its buffer once its edits are a draft Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: a draft edited while it deploys keeps the newer edits, chained on the new version Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: a draft whose summary or labels changed mid-deploy is kept too Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: the app viewer names the page it owns, and stops claiming the viewport A deployed app opened in an AI session's preview panel renamed the band from the session to the app, and put the app's Edit beside the session's name. The viewer registered wherever it was mounted; it takes `ownsPageHeader` now, the way the detail pages already did, and keeps Edit over its own canvas otherwise. Both viewers also claimed the whole viewport under a band that takes 44px of it, so a deployed app scrolled by exactly the height of its own header (the low-code one measured 900px in an 856px box). The height comes from the host now: the three standalone routes floor themselves at the viewport and everything inside them is `h-full`. A raw app reached through `/app_embed` had no height above it at all and collapsed to nothing; it fills the frame now, though that route still fetches no bundle secret, so what it shows is blank — as it is on main. The agent editor's pen was a second editor of the same path that reported nothing: an invalid path never reached the deploy gate the settings drawer's field reports to. It binds to the same slot now. Two things that surfaced with it: a rename never reached the trail, which read the deployed path; and typing the saved path back raised "path already used", since the popover's `initialPath` moves with the rename — `allowedExistingPath` is what says the item already occupies it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: the band's controls come from the design system, and the row has one copy `NavBreadcrumbTrigger` was `MeltButton` written again — same `use:melt` on a plain button, same spread — so the workspace chevron uses the component that already exists, and the sidebar handle is a `Button`. `DetailPageHeader` carried its action row twice, once for the band and once for the row it draws inside a session's preview panel, and the two had already drifted: different dropdown size, no `unifiedSize` on the buttons, and the deprecated `variant="contained" color="light" size="xs"` on the schedule button. Both placements render the same two snippets now, at the size each needs. `OperatorPageKey` admitted every `operator_settings` field, including the three write rights, which `sidebarPageAllowed`'s `=== true` rule answers wrongly: a write right is granted unless withdrawn, so its absence means "never configured". It is the ten visibility flags now. A test over the registry's merge: later registration wins field by field, actions sort by `actionsOrder` then registration, release gives its fields back. That rule is why a component registering where it is not the page renames someone else's band. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the band owns its bottom edge, and one pen renames everywhere The page header decides whether a separator is drawn, so a page cannot draw a second line under it. A registration asks for `onScroll` (the default, drawn only while something passes under the bar), `always` for a page whose content starts right beneath it, or `none` where the page genuinely draws its own first line. Detail pages, the agent editor, workspace settings and the pipeline list take `always`; the raw app editor takes `none`, since its splitpanes already draw that line. The pen that opens the rename popover becomes PathEditPen, shared by the editors and the deployed pages so the two cannot drift, and the deployed pages gain it. SummaryPathDisplay makes the pen the only trigger rather than the whole name block, so the name is selectable text again. Renaming an agent goes through ResourceService with only the fields the popover edits: the backend sets a column per field it is given, so writing the config back would clobber a concurrent edit. The popover also stops clearing a real collision verdict by telling Path which path is its own. Two Button fixes, both reaching call sites beyond this work: - `dropdownItems` given as a function was asked for `.length`, which is its arity and therefore 0, so a split button built that way rendered no caret. Both app editors' Deploy dropdowns were unreachable. - the anchor branch dropped `title`, so a link button had no tooltip. The audit log filters move into the header actions at the design system's sizes and trade absolutely positioned labels for the form layout in brand-guidelines.md: a label above its input, 4px apart, fields 8px apart. The deployed app viewer floors itself at `min-h-full` instead of claiming the viewport, which would overhang the page box by the height of the band, and carries Edit and a public-url link that fold into a menu on a narrow bar. The root layout drops the legacy menu block, which rendered a second hidden sidebar and 54 invisible tab stops, and marks the closed detached sidebar `inert`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: service logs and the audit filters move into the band Service logs: the page's name is the breadcrumb's, so the `<h1>` is gone and the search field, the timeframe and the two switches sit in the band beside it, with the documentation tooltip after the name. ServiceLogsInner registers the band itself, the way RunsPage does, since that route is its only caller, and the `title` snippet the page used to hand it goes with the heading. The search is the widest control there, because searching is what the page is for, and it is the one that gives width back under pressure. The two switches fold into a popover below a 1200px bar rather than at a phone's width, so the field keeps its full width down to a much narrower window — folded rather than dropped, because the page no longer carries a toolbar they could fall back to. The timeframe control took no size of its own, so it defaulted to `md` beside a `sm` field, and sat in a `w-48` box 20px narrower than its content, whose overflow is visible and so spilled into its neighbours. No `separator`: the two panes below start under the bar with borders of their own, so at rest there is nothing for the band to divide. Audit logs: the filters lose their labels. Each select says what it filters while unset — "all usernames", "all resources" — and once set the value names the field by itself. From and To become the TimeframeSelect the runs page uses, which absorbs the standalone Refresh button, since that control's left half is the refresh. `auditTimeframes` joins the runs and service-logs presets, with "All time" first. The bounds stay the filters' own `after`/`before` rather than a remembered preset label: the timeline's zoom writes them too, and a label could then contradict the chart. That takes the row from 1326px to 898px, so it stays inline down to a ~1500px viewport instead of ~2500. The fold measures the row against the bar less what the breadcrumb takes, plus a gap the two must never close — the breadcrumb yields width grudgingly and the row not at all, so sized to just meet they overlap rather than compress, and mid-resize the tooltip after the page's name lands on the timeframe button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an embedded page keeps its name in the band A page in a session's preview panel loads in a frame that sets `nomenubar`, so the layout renders the band with `navHidden`. That flag hid the whole breadcrumb — which is what carries the page's name — while the actions beside it had no such guard, leaving a row of controls belonging to nothing. Hiding the trail is still right: it leads to the host's workspace and offers a picker that would take the reader out of the session. So the breadcrumb gains a `nameOnly` mode that drops the workspace part and its picker and keeps the page's own name, and the band asks for it instead of rendering nothing. The summary line loses the same guard, so an item in there shows its path and summary. Only the pages whose own title the band took over are affected. Variables, Resources, Schedules and Triggers still draw their own `PageHeader` and render no band when embedded, so nothing is doubled. The bar's left padding is there to sit a button against — the sidebar handle, or the workspace disc, both of which carry their own margin. Embedded there is neither, and the name started as bare text 8px from the edge, so that case takes the same 16px the bar already keeps on its right. Workspace settings drops the copy-id button from the band: the General tab names the workspace ID with a field of its own, which is where someone looks for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: a session in full screen keeps its tabs in the band Full screen used to drop the session's name from the breadcrumb and leave the tab strip inside the panel. The band now carries both: the session's name where "Sessions" stood, and from the breadcrumb to the right edge one filled surface holding the tabs and the preview's controls, the bar's full height. The switch between the two layouts is the panel expanding to the left. The chat column is resized to nothing rather than unmounted, so it can be seen taking its place -- and so a toggle no longer tears down every mounted chat and rebuilds it on the way back. Panes transition their width only while that switch is in flight, since a splitter drag sets the same inline width and would trail the pointer. The band floats over the content in full screen as it already did beside a panel, so only its right edge moves and the page below never shifts by the bar's height. Leaving full screen, the panel travels back across ground the band has already claimed, so it passes one layer above it for the length of the move: pinned to the width the panel will end at, the bar would otherwise show through as a white patch over the panel's left edge. The breadcrumb also gains the acting fork, the way every other page shows it, which replaces the "Acting on" control the session header carried. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the band's inset survives a resize made in full screen The panel was remembered as a width, sampled while it stood still. A window resized during full screen left that width describing a layout that no longer existed, and the way out pinned the band to it for the length of the move. It is now remembered as the share of the row the panel takes, which the new row width turns back into the right inset: leaving full screen at 1200px after entering it at 1600px, the band starts at 628 rather than at 909. The pane transition's duration is read from the property the timer is set from, so the class cannot outlive the movement it describes, and two comments that described earlier drafts are rewritten: the band's name in full screen comes from the session's own header once it has mounted, and the tab strip's wrapper carries a fade, not a measured move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an agent's pen keeps the labels the agent already carries The pen on an agent's page was handed the summary and the path but not the labels, so the popover offered an empty list over a resource that had some. It saves every field it shows in one write, and the resource endpoint sets the column it is given, so adding a label there replaced the agent's own: an agent labelled `production` and `scheduled` came back from one edit labelled only with the label just added. A flow's page and a script's page both pass theirs; this one now does too. Verified against the running instance: an agent created with `["production","scheduled"]`, one label added through the pen, reads back `["production","scheduled","new"]` — and `["new"]` with the prop removed again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the sidebar remembers detaching per role, not per account Whether the sidebar starts detached depends on the role: an operator gets it out of the way, a developer gets it docked. That default held only until the control was pressed once, because the choice went to a single key read before the role. From then on both roles wore whichever was pressed last, and the one that had not asked for it could never get its own default back — switch a workspace where you are an operator for one where you are a developer, and the sidebar stayed detached. Each role now keeps its own choice, and falls back to its own default until it has one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: the handle's comment describes what the handle does It claimed a click holds the sidebar card open. Nothing distinguishes a click from a hover there — both call the same opener, and the pointer leaving schedules the close either way — so on a desktop the card follows the pointer and goes when it goes. Measured: hovering slides the rail from -213 to 5, the click moves nothing, and the pointer leaving puts it back at -213. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: a dev workspace's badge wears its fork's own colour Every surface that names a dev workspace drew the same blue badge, which said nothing about which workspace it belonged to. One component, DevWorkspaceBadge, now draws it: a fork with a colour fills the badge with that hue and writes the label in white, and a fork without one keeps whatever its row had before. The hue cannot carry white at a fixed lightness — HSL lightness is not perceived brightness, so yellow and purple at the same L differ by several stops — so forkAccentStyle solves the badge's lightness by bisection instead, taking the lightest shade of the hue that still holds white at WCAG AA. Measured across the wheel the contrast lands between 4.52 and 4.64. The session picker opens with every family collapsed, since the fork a session runs in is the session's own and not something that picker should offer to change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the name is what opens its rename editor, and the trail truncates from its start The pen beside a name is gone. The name itself is the trigger, with a hover fill behind it where the click opens an editor rather than going anywhere — the path segment that only copies keeps no fill, which is what tells the two apart. A bar with a summary field of its own (EditorHeader's condensed pane) still gets the pen, since it has no name to hang the trigger on. Clicking either half of a name opens the editor under that half with the cursor in the matching field. The band draws the path and the page draws the summary, so one popover cannot anchor under both: a page hands the band a `pathTrigger` snippet and the band renders its path segment inside it, passing the classes its own segment wears. Each editor supplies a second PathEditPopover for it, and `focusField` names the field to focus — `#path` explicitly, since melt otherwise lands on the row's first button. Those snippets render from the band's tree, which outlives the values they read by a flush, so each is gated on the value it reads. Without that an agent page whose draft had not loaded threw inside the band and left the breadcrumb empty. The trail now carries two weights: the segment that ends it is what the page is about and takes `font-medium text-emphasis`, everything before it is a step quieter. Which segment that is follows the route — the workspace on home (the fork alone inside a fork), a list page's own name, an item's summary. And it truncates instead of switching to icons at a breakpoint. The workspace part yields first, the family name before the fork, then the path's folders, and the item's own name last. The bar no longer wraps on a phone: a wrapping row never squeezes anything, it hands the trail a line of its own and leaves it whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a page keeps its path segment when it cannot offer a rename Four things the local reviews caught in the rename work: A detail page handed the band a `pathTrigger` whether or not it could save one. Without `onSaved` — every script, flow or agent page the reader cannot write — `SummaryPathDisplay` fell through to its read-only branch, which ignores the `label` it was given and draws its own block: the trail lost its path and the summary appeared twice, two lines deep, in a 44px bar. The page now offers the trigger only where a rename can be saved, and the component renders what it was handed rather than its own row. The band gives up its own segment the moment a page offers a `pathTrigger`, so a trigger whose own gate is closed left the trail with no path at all — for the whole of an agent draft's load, not just a flush. Each gated snippet now draws the path itself in that case. `openFocus` named the path field as `#path`, which melt resolves with a document-wide query; `Path` hardcodes that id at every call site, and the popover is portalled to the body, so it lost document order to any other `Path` on the page. It is scoped to the popover's own field now. And `open` was an exported `$bindable(false)` on an optional prop, which the frontend's own rules ban. No caller bound it, so it is local state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the badge solves its lightness against the colour the browser paints Three nits from the review round. The contrast bisection measured the hue at full float precision, but a browser paints 8-bit channels: rounding them moved the result up to 0.025 off the target, which took #993388 to 4.478 against white — under the AA floor the comment claims. Quantizing the channels inside `paintedLuminance` puts the worst case across 2880 hue and saturation combinations at 4.500, and your own fork's badge measures 4.53 as painted. An agent nobody can write still offered the rename behind its path segment: an editor with two read-only fields, in place of the click that copies the path. It is gated on `readOnly` now, the way a detail page is gated on `onSaved`. The script and flow editors take the same gate from the `customUi` flags that already decide whether their summary offers one — unreachable today, since the editors own the band only on routes that pass no `customUi`, but the four editors now answer the question the same way. And the registry's doc named an `onEditName` field that has never existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an operator is not offered a fork of a workspace A fork does not carry the role. The server writes the forker's membership row in the new workspace from the parent's `is_admin` and lets `operator` fall to its `false` default, so an operator who forks lands in a full-rights copy of the workspace they could only run things in. `canCreateFork` refuses an operator, which takes the entry out of the workspace picker, the session family picker and the session changes bar; `editInForkAllowed` refuses one too, so no editor offers "Edit in fork". The fork page refuses on its own, since a URL reaches it whatever the pickers show. And a workspace the user is an operator in is no longer a base the fork form will accept, so the base picker cannot route around the gate. This hides the affordance; it does not enforce the rule. `create_fork` asks only for membership — `require_admin` covers the dev-workspace case alone — so the API still answers an operator who calls it directly. The server-side fix is to carry `operator` into that membership row, the way the member-copy path already does, and it belongs in a backend change rather than here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: the fork refusal uses the design system's alert The refusal panel was hand-built from a div and two paragraphs with literal colors, copied from the Runs page's own operator refusal. The frontend's rules say to use the design-system component, and `Alert` already draws exactly this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: frontend type errors failing npm_check The dev workspace badge accepts the nullable label the API returns, the session strip transitions take the node Svelte passes them, and an unused import is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: the frontend type-checks again `check frontend build` has been red on every head for a day, through four review rounds that each came back clean — none of the reviewers type-check, and they say so in their own coverage sections. Ten errors: The dev badge's `label` and `color` were typed `string | undefined`, but the workspace rows carry `string | null`, so all seven of its call sites failed. `forkAccentStyle` took the same widening. A Svelte transition is called with the node it animates, and `stripArrive` and `stripLeave` were declared with no parameters at all. And `DevWorkspaceSetting` still imported `Badge` after its badge moved into the shared component. `svelte-check` now reports 0 errors. Note that it only agrees with CI once `src/lib/gen` has been regenerated — the gitignored client goes stale against main and invents errors CI never sees. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the home bar carries the CLI and hub buttons while it has the room A menu is where a thing goes when the bar cannot hold it, not where it belongs. Above 1024px of bar, "CLI / MCP" and "Hub" stand as their own buttons; below it they fold back into the ellipsis, which is still where they live on a phone. The threshold reads `pageHeader.barWidth`, not the viewport, so it tracks what the sidebar actually leaves behind. Home's trail is ~250px and its other registered actions ~400, so the two labelled buttons fit from about 850 up; 1024 leaves margin before the trail would start truncating, and it does not truncate at any width between 760 and 1600. The ellipsis hides itself when it would open an empty menu, which happens only once both side trips are in the bar and the composer is already on the page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: a fork's band counts its drafts beside its drift, and every count opens what it names A fork that had drifted stopped saying anything about its drafts: the banner printed the draft count only while the fork was in sync, and the drift badges replaced it the moment there was anything to deploy. A fork can be 86 ahead, 39 behind and holding 128 drafts; it now says so. All four counts open the page that holds them — ahead the deploy direction, behind `?dir=update`, conflicts the side they are resolved from before deploying, drafts the drafts half. The button beside them still chooses a direction on the reader's behalf; a badge means the one thing it names. The colours and the icons are the compare page's, so the counts in the bar and the rows behind them say the same thing the same way: ahead green, behind blue, conflicts orange. Drafts share blue with behind and are told apart by a pencil and by naming what they count, rather than by an arrow that would read as a third direction. Both arrows are straight, here and on the compare page. "Review changes" replaces "Review & Deploy Changes", and it renders in every state. What the comparison has to say — still counting, or failed — is a mark beside the button rather than a branch that replaces it: a fork whose count is unfinished is exactly when a reader wants the page that counts properly. The drafts banner outside a fork drops "This workspace has", so the count reads the same wherever it appears. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the home band keeps its hero's own cross, and the side trips stay quiet The CLI and hub buttons take the shape the hero gave them before the bar did — `xs`, hint-coloured, the hub keeping its outbound mark — so a side trip does not read as loudly as the thing the page is for. The ellipsis is gone while the hero is on the page: the hero's cross already puts it away, so a menu whose only item repeats that is a button that does nothing new. It comes back when it has something of its own to carry, which is the side trips on a narrow bar and the way back to a hero that has been put away. That entry is now "Pin session hero to homepage", and "Remove session chat" survives only as the cross's own words, where one action keeps one name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: a fork's counts and the way to review them are one control Four badges beside a button read as four things that happen to sit together. They are one thing — every segment opens the same page on the part it names — so they now share a box: one outline, square corners inside it, and the rounding on the box alone, which means no segment has to know where in the row it sits. The only rule in the strip is the one before the end cap: everything left of it is a reading, that is an action. With room every count says what it counts; below 1280px of bar they keep their icon and their number, which is the part a reader scans for, and the tooltip names them either way. The end cap keeps its words at every width — the counts have icons to fall back on and it has none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the compare page asks once, says it is working, and knows its own name The page re-requested the same summaries and on-behalf-of emails forever. Two faults compounding: `fetchOnBehalfOfInfo` skipped a key it had already answered with `onBehalfOfInfo[k] !== undefined`, but stored `undefined` on failure — so "asked, and there was nothing" could not be told from "never asked". An item that is only ahead does not exist in the parent, so that side always fails: a permanently un-cacheable set, re-requested on every pass. And the effect that drives both fetchers depended on what they write. Each reads its cache synchronously before its first `await`, which registers that cache as a dependency; the write that follows re-runs the effect. Write, re-run, write. Both now record what they asked for in a plain Set, marked before the request rather than after the answer, and the effect is untracked so it depends on the comparison alone. The caches are cleared when the compared pair changes, since a summary's key carries no workspace and those Sets would otherwise never ask again. While the comparison was in flight the page printed "No changes between this workspace and X" — an empty list and an unanswered one look alike from the layout, and that is a claim with no answer behind it. The page tracks the request now, and the layout spins instead. The breadcrumb called the page "Forks", which is what `navPageFor` falls back to when a route names itself nowhere: the first segment, capitalised. It registers "Compare" instead — in the page rather than in NAV_PAGES, which is what the sidebar renders from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "feat: a fork's counts and the way to review them are one control" This reverts commite4504d2843. The counts go back to separate badges and "Review changes" to a button of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the compare page follows the fork picked in the trail Switching a fork from the breadcrumb left the page comparing the pair it opened with. Two reasons, neither reactive: the page seeds `currentWorkspaceId` from `?workspace_id` once at mount and never reads it again, and the switch rewrites `?workspace`, a different param that this page does not read. It follows `$workspaceStore` now — picking a fork in the trail means comparing that fork. The param is rewritten so a reload keeps the pair on screen, and an arbitrary `?target=` is dropped, since it named a destination for the workspace just left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: the band is a step taller, and the runs row sizes with it The bar goes from h-11 to h-12, taken from one constant beside the top padding a floating band owes the page under it, so the two cannot drift. The runs page's controls now share the sm height the rest of a header's actions use: FilterSearchbar learns the size, dropping the top margin that only centres text at md and taking the shared leading instead, and sm buttons get px-3 so a label reads as a button. The actions sit in their own row with a wider gap, and the chart below meets the header with no gap of its own. The search field holds one width through typing. As flex-1 it was whatever the row had left over, so committing a chip grew it from 226 to 288 and took those pixels off the breadcrumb. A fork's count badges keep their labels while the bar is at least 1280 wide and fall back to icon and number below that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the trail holds a path's place instead of naming its kind A page registers its kind when it mounts and its path once it has loaded one. In between the trail printed "script" or "flow", which reads as the answer and then jumps when the real path lands. A skeleton of a plausible path's width holds the place, with aria-busy and a label naming what is loading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the compare page reads the workspace it was linked with The effect that follows the trail's fork also ran on mount, so a `?workspace_id` that differs from the navigation workspace was thrown away. That difference is the contract: a session's Review button passes the fork it committed to and leaves the navigation workspace alone, as does the prod -> dev link in UpdateDevWorkspaceModal. Both opened on the sidebar's workspace instead, comparing the wrong pair or none at all. Only a change in the store now retargets the page, measured against what it held when the page opened. The URL rewriting goes with it. The picker's items are links that carry every other param forward, so their navigation lands after the effect and put back the `workspace_id` it had just rewritten: switching fork and reloading left the trail naming one workspace and the page comparing another. The page reads `?workspace=` ahead of `?workspace_id=` instead — the param the picker writes and the root layout already applies to the store — and `?target=` lapses while the page is on another workspace rather than being deleted from a URL someone else is about to replace. Two 44px literals left over from the h-11 band: the detached sidebar card's measured-top fallback and the rail morph's end state, both now from BAR_HEIGHT_PX beside the class the band wears. The runs search field keeps its 18rem without growing, but as `shrink-0` nothing in that row could give width back, so a short bar had no answer but the trail's truncation and an overlong row would have run past the edge. `min-w-0` restores the yield without restoring the growth: a flex item's floor is its min-content width, which the chips inside the field raise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the compare page's target picker works, and a switch survives reload `?target=` was gated on the page still being on the workspace the URL named. Opened from "Merge into another workspace" in the workspace settings the URL names no workspace at all, so that comparison was `<id> === undefined` and the param was ignored for good: picking a target left the page on the "no parent workspace" prompt, which is the only place the migration can be started from. Read the param as it was read before. The follow effect writes `?workspace=` again. A plain click in the picker preventDefaults its own link and switches the store directly, so nothing navigates afterwards and nothing overwrites the write — while `fixupUrlAfterWorkspaceSwitch` rewrites the param only once it is already there, which on a page opened with `workspace_id` alone it never is. Without it, switching fork and reloading left the trail naming one workspace and the page comparing another. Exercised on a fork pair: the settings entry's picker loads the comparison it names, a plain-click switch survives a reload, and a `workspace_id` deep link still outranks the navigation workspace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: the compare page reads its pair instead of following it Net effect count: three removed, none added. The page derives the workspace it compares from the URL and the store rather than keeping it in state and running an effect to follow a switch. The workspace-switch link is what moves it: the picker's items carry every param forward, so a page-side rewrite raced that navigation and lost — the trace showed the fixup's replaceState landing first and the link's pushState putting the old params straight back. The href now swaps `workspace_id` alongside `workspace` and drops `target`, and `fixupUrlAfterWorkspaceSwitch` does the same for the paths that do not navigate, so both writers agree and the order stops mattering. Dropping `target` on a switch also closes the case where switching onto the workspace that was the target left the page comparing it with itself. CompareWorkspaces remounts on either side of the pair changing, not just the target: two forks of one parent shared a mount, since the target is the parent they have in common, and with it the selection, the deploy direction, the summaries, the pinned rows, the deploy permissions and the CI results. The cache-clearing effect that stood in for that is gone. Its summary loops outlive their mount, so they read the pair once at the start and stop writing when the instance is destroyed. The app viewer asked for a public secret on every app view to decide whether to draw a button; it reads the mode off the app it has already loaded and fetches the secret when the button is used. The summary popover seeds its fields on the open transition rather than from an effect that also read the item: a save landing while it was open reseeded the fields over what was being typed. Exercised against a running instance, 18 cases: idle issues nothing, a switch moves page, trail and URL together and survives a reload, a deep `workspace_id` link still outranks the navigation workspace, four fast switches settle with no foreign row, and the migration target picker loads the pair it names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the public url opens the tab the click itself opened The secret is fetched when the button is used, and the tab was opened once it arrived — after an await, where Safari no longer counts the click as the gesture that opened it and blocks the popup without saying so. Open the tab synchronously and point it at the url once the secret is known, closing it and saying why when there is none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the public url button survives a sandboxed app A sandboxed low-code app loads inside the opaque /app_embed iframe, so `loadApp` never runs on this page and `app` stays undefined — taking the button and its compact-menu entry with it, for the owner too. The mode now comes from the lite read that already runs for the Edit button, which is there for exactly this reason: it is the one description of the app this page has whether or not the app itself loads here. Exercised on four apps: anonymous sandboxed low-code, anonymous unsandboxed low-code and an anonymous raw app all show the button and fetch the secret only on click; a publisher app shows neither and asks nothing. At 640px the button folds into the menu, which carries it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: the removed hero leaves a blue flash on the ellipsis The mark that flew from the cross to the menu is gone, with its two element refs, its flight state and timer, and the `data-home-ai-menu` hook that existed only as its destination. What is left is the thing it was there to say: the ellipsis holds the way back, so it flashes blue as it appears. Written as a keyframe animation, not a transition. A transition needs the element painted at its start value before the end value is set, and this mounts in the same flush that collapses the hero, so the frame that would have shown it at rest is the frame it is already told to leave — one `requestAnimationFrame` is not reliably past that paint, and the flash was never drawn at all. `animate-fade-out` already exists for this (AssetsDropdownButton flashes a background the same way); mounting it only while pulsing gets a fresh run per removal without that component's reflow juggling. The flash rides the ellipsis rather than the row: with the CLI and hub buttons beside it the row is three controls wide, and marking all three names none of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the flash rests invisible, and its comment describes what is there `animate-fade-out` carries no fill mode, so at the end of the run the element returns to its own opacity. At the default 1 that is the flash coming back at full strength for as long as the unmount timer lags the animation — which the hero's collapse, happening right there, is well placed to cause. `opacity-0` at rest makes the revert land on invisible. The comment above it still described the mark that used to fly to the menu, and made its point three times over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the compare page asks only about the pair it is showing Switching workspace on this page fetched the previous pair's item paths from the new workspace, where none of them exist: 97 of 174 requests 404ing on one switch. The comparison was plain state, and the card is remounted when the pair changes — in the same render that changes it, before the effect that cleared the comparison could run, so the new mount started from the old pair's diffs. It now carries the pair it describes and is read only while that matches, which leaves nothing to clear. `computeFullScan` had the same shape: it read the target again after its await, so a switch mid-scan invalidated another pair's cache, named the wrong workspace in its toast, and left the scan button spinning on a pair that was never scanned. It captures the pair up front, re-compares only while the page is still on it, and reports it as its own. Each diff says which side holds the item, so the summary and on-behalf-of loops stop asking the side that does not: an item that is only ahead cannot answer in the parent, and asking was a guaranteed 404 and a console error per one-sided row. Those two loops also read the same object — a summary and an identity are fields of one payload — so every row was fetched twice per workspace. One read per item per workspace now serves both. A failed read is not kept: held, it would read as "this item has no identity", which is what decides whether a deploy offers to preserve one, and an item deployed without it silently becomes the deployer's. The header names the workspace being compared when that is not the one the app is pointed at, which is how a session's Review button and the prod -> dev link open this page. The trail said the parent while the page compared the fork. Measured on a 90-item pair, before -> after: a cold load 247 -> 170 requests and 167 -> 90 item reads; a workspace switch 174 requests with 97 404s -> 71 with none. Sixteen cases over cold load, idle, switching in both directions, four switches with no time to settle, a cross-family switch, reload, deep link, the migration picker and leaving an arbitrary target: no request in any of them names a pair the page has left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the scan toast reports the pair it scanned Off the scanned pair the re-compare is skipped, so there is nothing to report as compared — but the success branch said "Compared N items" anyway, and the error it checked first belonged to whichever pair the page had moved to. Each outcome now says what happened: the seed alone when the page has moved on, and the comparison's result only where one was asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Guilhem Lemouel <guilhemlemouel@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2921 lines
95 KiB
Svelte
2921 lines
95 KiB
Svelte
<script lang="ts">
|
||
import { buildWsUrl } from '$lib/wsUrl'
|
||
import { paneMinPercent } from '$lib/utils/splitpaneSizing'
|
||
import { processSecretArgs } from './secretArgUtils'
|
||
import type { Schema, SupportedLanguage } from '$lib/common'
|
||
import {
|
||
type CompletedJob,
|
||
type Job,
|
||
JobService,
|
||
type Preview,
|
||
type ScriptLang,
|
||
type ScriptModule
|
||
} from '$lib/gen'
|
||
import { enterpriseLicense, userStore } from '$lib/stores'
|
||
import { copyToClipboard, emptySchema, sendUserToast } from '$lib/utils'
|
||
import Editor from './Editor.svelte'
|
||
import { inferArgs, inferAssets, inferAnsibleExecutionMode } from '$lib/infer'
|
||
import { injectPartitionArg } from '$lib/scriptEditorSchema'
|
||
import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow'
|
||
import WacDiagram from '$lib/components/graph/WacDiagram.svelte'
|
||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||
import { canonicalModulePath, findModulePathClash } from './scriptModulePath'
|
||
import SchemaForm from './SchemaForm.svelte'
|
||
import PowerShellCommonParams from './PowerShellCommonParams.svelte'
|
||
import LogPanel from './scriptEditor/LogPanel.svelte'
|
||
import EditorBar, {
|
||
EDITOR_BAR_WIDTH_THRESHOLD,
|
||
EDITOR_BAR_HELPERS_COMPACT_THRESHOLD
|
||
} from './EditorBar.svelte'
|
||
import JobLoader from './JobLoader.svelte'
|
||
import JobProgressBar from '$lib/components/jobs/JobProgressBar.svelte'
|
||
import { createEventDispatcher, getContext, onDestroy, onMount, untrack } from 'svelte'
|
||
import { Button } from './common'
|
||
import SplitPanesWrapper from './splitPanes/SplitPanesWrapper.svelte'
|
||
import WindmillIcon from './icons/WindmillIcon.svelte'
|
||
import * as Y from 'yjs'
|
||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||
import { langToExt } from '$lib/editorLangUtils'
|
||
import { WebsocketProvider } from 'y-websocket'
|
||
import Modal from './common/modal/Modal.svelte'
|
||
import Popover from './meltComponents/Popover.svelte'
|
||
import DiffEditor from './DiffEditor.svelte'
|
||
import {
|
||
Bug,
|
||
ChevronDown,
|
||
Copy,
|
||
CornerDownLeft,
|
||
Disc,
|
||
Download,
|
||
ExternalLink,
|
||
Github,
|
||
GitBranch,
|
||
Play,
|
||
PlayIcon,
|
||
Plus,
|
||
Target,
|
||
Terminal,
|
||
Pencil,
|
||
WandSparkles,
|
||
X,
|
||
Zap
|
||
} from 'lucide-svelte'
|
||
import {
|
||
DebugToolbar,
|
||
DebugPanel,
|
||
DebugConsole,
|
||
getDAPClient,
|
||
debugState,
|
||
resetDAPClient,
|
||
getDebugServerUrl,
|
||
type DebugLanguage,
|
||
isDebuggable,
|
||
getDebugFileExtension,
|
||
fetchContextualVariables,
|
||
signDebugRequest,
|
||
signMultiplayerRequest,
|
||
getDebugErrorMessage
|
||
} from '$lib/components/debug'
|
||
import { SvelteSet } from 'svelte/reactivity'
|
||
import { setLicense } from '$lib/enterpriseUtils'
|
||
import type { ScriptEditorWhitelabelCustomUi } from './custom_ui'
|
||
import Tabs from './common/tabs/Tabs.svelte'
|
||
import Tab from './common/tabs/Tab.svelte'
|
||
import DraggableTabs, { type TabItem } from './common/tabs/DraggableTabs.svelte'
|
||
import { slide } from 'svelte/transition'
|
||
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
|
||
import CaptureButton from './triggers/CaptureButton.svelte'
|
||
import { setContext } from 'svelte'
|
||
import HideButton from './apps/editor/settingsPanel/HideButton.svelte'
|
||
import { base } from '$lib/base'
|
||
import DispatchEventsButton from '$lib/components/runs/DispatchEventsButton.svelte'
|
||
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './copilot/chat/script/core'
|
||
import { getStringError } from './copilot/chat/utils'
|
||
import type { ScriptOptions } from './copilot/chat/ContextManager.svelte'
|
||
import { aiChatManager, AIMode } from './copilot/chat/AIChatManager.svelte'
|
||
import OpenInSessionButton, {
|
||
type OpenInSessionSource
|
||
} from './sessions/OpenInSessionButton.svelte'
|
||
import { setOpenInSessionHandoff } from './sessions/openInSessionContext'
|
||
|
||
// Forward-looking hook for the upcoming session-pane feature: that PR will
|
||
// `setContext('aiChatManager', ...)` from the session wrapper so this editor
|
||
// can detect it and hide its own AI/VS Code controls. On main today nothing
|
||
// sets the context, so `inSessionPane` is always false and these buttons
|
||
// render normally — keep the check anyway to avoid re-touching this file
|
||
// when the session-pane PR lands. Untyped getContext to avoid coupling to
|
||
// the AIChatManager class export (which lives on the chat-visuals PR).
|
||
const inSessionPane = !!getContext('aiChatManager')
|
||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||
import AssetsDropdownButton from './assets/AssetsDropdownButton.svelte'
|
||
import { canHavePreprocessor } from '$lib/script_helpers'
|
||
import { assetEq, type AssetWithAltAccessType } from './assets/lib'
|
||
import type { ColumnLineage } from './assets/AssetGraph/parsePipelineAnnotations'
|
||
import {
|
||
computeContractMarkers,
|
||
type ContractMarker,
|
||
type SchemaContractGraphContext
|
||
} from './assets/AssetGraph/schemaContracts'
|
||
import { editor as meditor } from 'monaco-editor'
|
||
import type { ReviewChangesOpts } from './copilot/chat/monaco-adapter'
|
||
import GitRepoViewer from './GitRepoViewer.svelte'
|
||
import GitRepoResourcePicker from './GitRepoResourcePicker.svelte'
|
||
import { updateDelegateToGitRepoConfig, insertAdditionalInventories } from '$lib/ansibleUtils'
|
||
import { copilotInfo } from '$lib/aiStore'
|
||
import JsonInputs from '$lib/components/JsonInputs.svelte'
|
||
import { argsToJsonPayload } from '$lib/schema'
|
||
import Toggle from './Toggle.svelte'
|
||
import { deepEqual } from 'fast-equals'
|
||
import { usePreparedAssetSqlQueries } from '$lib/infer.svelte'
|
||
import { resource, watch } from 'runed'
|
||
import { buildScriptRecording, downloadRecordingJson } from './recording/runRecording'
|
||
import DropdownV2 from './DropdownV2.svelte'
|
||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||
|
||
const operatingWorkspace = useOperatingWorkspace()
|
||
|
||
interface Props {
|
||
// Exported
|
||
schema?: Schema | any
|
||
code: string
|
||
path: string | undefined
|
||
lang: Preview['language']
|
||
kind?: string | undefined
|
||
autoKind?: string | undefined
|
||
template?:
|
||
| 'pgsql'
|
||
| 'mysql'
|
||
| 'script'
|
||
| 'docker'
|
||
| 'powershell'
|
||
| 'bunnative'
|
||
| 'claudesandbox'
|
||
| 'pisandbox'
|
||
| 'wac_python'
|
||
| 'wac_typescript'
|
||
| 'ci_test_bun'
|
||
| 'ci_test_python'
|
||
tag: string | undefined
|
||
initialArgs?: Record<string, any>
|
||
fixedOverflowWidgets?: boolean
|
||
noSyncFromGithub?: boolean
|
||
editor?: Editor | undefined
|
||
diffEditor?: DiffEditor | undefined
|
||
collabMode?: boolean
|
||
edit?: boolean
|
||
noHistory?: boolean
|
||
saveToWorkspace?: boolean
|
||
watchChanges?: boolean
|
||
customUi?: ScriptEditorWhitelabelCustomUi | undefined
|
||
// Pipeline editor: an asset-parse failure should also turn the
|
||
// "parsable" badge red — asset lineage is load-bearing there, so a
|
||
// green badge must mean both the main function AND the asset parse
|
||
// succeeded.
|
||
requireValidAssets?: boolean
|
||
args: Record<string, any>
|
||
// Emitted with the test-form's full-schema validity whenever it changes, so
|
||
// a host (the pipeline editor) can gate a data-upload entry's readiness on
|
||
// whether every required field is filled, not just the S3 file. A callback
|
||
// rather than a bindable so we don't hit the `$bindable(default)` ban.
|
||
onIsValidChange?: (isValid: boolean) => void
|
||
// Custom timeout (in seconds) from the script settings. Forwarded to the
|
||
// preview run so "Test" honors the same timeout a deployed run would,
|
||
// instead of silently falling back to the instance default.
|
||
timeout?: number
|
||
selectedTab?: 'main' | 'preprocessor' | 'diagram'
|
||
hasPreprocessor?: boolean
|
||
captureTable?: CaptureTable | undefined
|
||
showCaptures?: boolean
|
||
stablePathForCaptures?: string
|
||
lastSavedCode?: string | undefined
|
||
lastDeployedCode?: string | undefined
|
||
disableAi?: boolean
|
||
assets?: AssetWithAltAccessType[]
|
||
// Body-inferred column lineage (DuckDB SQL AST), surfaced alongside
|
||
// `assets` so the pipeline editor can render inferred column lineage on
|
||
// the live graph. Empty/undefined for non-DuckDB or when the parser
|
||
// build predates the inference.
|
||
inferredColumnLineage?: ColumnLineage[]
|
||
modules?: { [key: string]: ScriptModule } | null
|
||
editorBarRight?: import('svelte').Snippet
|
||
enablePreprocessorSnippet?: boolean
|
||
/**
|
||
* Layout for the test/preview panel relative to the code editor.
|
||
* `right` (default) splits horizontally with the test panel on the
|
||
* right; `bottom` splits vertically with the test panel below — same
|
||
* orientation the flow editor uses for module step editing.
|
||
*/
|
||
previewLayout?: 'right' | 'bottom'
|
||
/**
|
||
* Fires whenever the test/preview run state changes — used by the
|
||
* pipeline editor to mirror the running script onto the canvas
|
||
* (animate its incoming/outgoing edges) so Test feels equivalent
|
||
* to clicking a run button on the graph.
|
||
*/
|
||
onTestStateChange?: (running: boolean) => void
|
||
// Fired whenever a test run is started from this editor, with the
|
||
// preview job id. Used by whitelabel embedders to track test jobs.
|
||
onTestJob?: (e: { jobId: string }) => void
|
||
// Drives the right-hand test/run pane collapsed state. Seeds the pane
|
||
// collapsed at mount when true, and is edge-triggered afterwards: a later
|
||
// change collapses/expands the pane (but the user's own toggles in between
|
||
// are preserved — the effect only acts on a transition). Defaults to false
|
||
// so the regular /scripts/edit route keeps its open-by-default UX; the
|
||
// session preview collapses it to save space and reopens it in full screen.
|
||
testPanelCollapsed?: boolean
|
||
// Lets the AI toolbar button open the script in a fresh AI session
|
||
// instead of the inline chat panel (see OpenInSessionButton for gating).
|
||
sessionOpen?: OpenInSessionSource
|
||
// Producer-side facts for the live schema-contract diagnostics
|
||
// (`on_schema_change=ignore` suppression + scd2 `_current` fallback),
|
||
// built by the pipeline page from the resolved graph. Absent outside the
|
||
// pipeline editor — the check still runs, just without suppression.
|
||
schemaContractContext?: SchemaContractGraphContext
|
||
// Workspace to scope this editor's calls to (tests, captures, toolbar lookups).
|
||
// Defaults to the operating workspace (see `useOperatingWorkspace`).
|
||
workspaceOverride?: string
|
||
}
|
||
|
||
let {
|
||
schema = $bindable(),
|
||
code = $bindable(),
|
||
path,
|
||
lang,
|
||
kind = undefined,
|
||
autoKind = undefined,
|
||
template = 'script',
|
||
tag,
|
||
fixedOverflowWidgets = true,
|
||
noSyncFromGithub = false,
|
||
editor = $bindable(undefined),
|
||
diffEditor = $bindable(undefined),
|
||
collabMode = false,
|
||
edit = true,
|
||
noHistory = false,
|
||
saveToWorkspace = false,
|
||
watchChanges = false,
|
||
customUi = undefined,
|
||
requireValidAssets = false,
|
||
args = $bindable(),
|
||
onIsValidChange,
|
||
timeout = undefined,
|
||
selectedTab = $bindable('main'),
|
||
hasPreprocessor = $bindable(false),
|
||
captureTable = $bindable(undefined),
|
||
showCaptures = true,
|
||
stablePathForCaptures = '',
|
||
lastSavedCode = undefined,
|
||
lastDeployedCode = undefined,
|
||
disableAi = false,
|
||
assets = $bindable(),
|
||
inferredColumnLineage = $bindable(),
|
||
modules = $bindable(undefined),
|
||
editorBarRight,
|
||
enablePreprocessorSnippet = false,
|
||
previewLayout = 'right',
|
||
onTestStateChange,
|
||
onTestJob,
|
||
testPanelCollapsed = false,
|
||
sessionOpen = undefined,
|
||
schemaContractContext = undefined,
|
||
workspaceOverride = undefined
|
||
}: Props = $props()
|
||
|
||
let opWs = $derived(workspaceOverride ?? $operatingWorkspace)
|
||
|
||
// Publish this editor's hand-off for AI entry points below it (the preview
|
||
// panel's "AI Fix"), withheld under `disableAi` so an embed that turned AI off
|
||
// gets no entry point that navigates its host to /sessions. Shadows an
|
||
// ancestor's hand-off deliberately: ScriptEditorDrawer mounts this without a
|
||
// `sessionOpen`, and falling through to FlowBuilder's would answer "fix this
|
||
// script" by opening the flow and abandoning the drawer's unsaved content.
|
||
setOpenInSessionHandoff({ source: () => (disableAi ? undefined : sessionOpen) })
|
||
|
||
$effect(() => {
|
||
onTestStateChange?.(testIsLoading)
|
||
})
|
||
|
||
let initialArgs = structuredClone($state.snapshot(args))
|
||
let jsonView = $state(false)
|
||
let schemaHeight = $state(0)
|
||
// Asset-trigger cascade choice for the Test button. Only meaningful when
|
||
// `customUi.previewPanel.downstreamSubscribers > 0`. Defaults to off so
|
||
// iteration on a pipeline script doesn't accidentally fan out runs to
|
||
// downstream subscribers. The user can flip it via the split-button
|
||
// caret next to Test, and the choice sticks for the rest of the session.
|
||
let cascadeDownstream = $state(false)
|
||
let cascadeMenuOpen = $state(false)
|
||
let psCommonParams: Record<string, any> = $state({})
|
||
let showPsCommonParams = $derived(lang === 'powershell' && /^\s*\[CmdletBinding/im.test(code))
|
||
|
||
// Module tab state
|
||
let activeModuleTab: string | null = $state(null)
|
||
// Per-module test panel state (args + schema), persisted across tab switches
|
||
let moduleTestState: Record<string, { args: Record<string, any>; schema: Schema }> = $state({})
|
||
let testPanelArgs: Record<string, any> = $state({})
|
||
let testPanelSchema: Schema = $state(emptySchema())
|
||
// Bumped whenever the args under test are replaced from outside the arg panel. Both arg
|
||
// views key off it: without a bump the JSON editor keeps showing, and on the next
|
||
// keystroke commits, the payload it was seeded with for the previous args.
|
||
let argsRender = $state(0)
|
||
// editorCode is what the editor shows; code always holds the main script content
|
||
let editorCode: string = $state(code)
|
||
// Sync editorCode when code changes externally (template reset, copilot,
|
||
// new-script template init, etc.). We also re-run schema inference in that
|
||
// case — otherwise if the code prop is set after ScriptEditor's onMount (as
|
||
// happens on /scripts/add when initContent sets script.content after the
|
||
// editor has already mounted with an empty string), the schema would stay
|
||
// empty until the user typed into the editor.
|
||
let lastSyncedCode = code
|
||
$effect.pre(() => {
|
||
if (activeModuleTab === null && code !== lastSyncedCode) {
|
||
editorCode = code
|
||
lastSyncedCode = code
|
||
editor?.setCode(editorCode) // immediate sync, don't wait for the 800ms debounce
|
||
untrack(() => inferSchema(code))
|
||
}
|
||
})
|
||
|
||
function switchToModule(modulePath: string) {
|
||
// Re-clicking the tab you are already on is a no-op. Re-running the body would reset this
|
||
// module's test state whenever its inference is still pending or has failed (the catch
|
||
// leaves `moduleTestState` unwritten), losing both the filled-in args and the arg views.
|
||
if (activeModuleTab === modulePath) {
|
||
return
|
||
}
|
||
if (activeModuleTab !== null && modules) {
|
||
// Switching from another module: save its content and test state
|
||
modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode }
|
||
moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema }
|
||
}
|
||
if (modules && modules[modulePath]) {
|
||
activeModuleTab = modulePath
|
||
editorCode = modules[modulePath].content
|
||
editor?.setCode(editorCode)
|
||
// Restore or initialize test state for the new module
|
||
if (moduleTestState[modulePath]) {
|
||
testPanelArgs = moduleTestState[modulePath].args
|
||
testPanelSchema = moduleTestState[modulePath].schema
|
||
} else {
|
||
testPanelArgs = {}
|
||
testPanelSchema = emptySchema()
|
||
// Inference lands after the bump below, so the editor opens on `{}` and the arg
|
||
// views follow the schema in once it arrives. Remounting them again on arrival
|
||
// instead would discard anything typed while it was in flight.
|
||
inferModuleSchema()
|
||
}
|
||
argsRender++
|
||
}
|
||
}
|
||
|
||
function switchToMain() {
|
||
if (activeModuleTab === null) {
|
||
return
|
||
}
|
||
if (modules) {
|
||
// Save current module content and test state
|
||
modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode }
|
||
moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema }
|
||
}
|
||
activeModuleTab = null
|
||
editorCode = code
|
||
lastSyncedCode = code
|
||
editor?.setCode(editorCode)
|
||
argsRender++
|
||
}
|
||
|
||
// Whether the open file is tested as a runnable of its own. A `__mod` helper
|
||
// is.
|
||
let onModuleArgs = $derived(activeModuleTab !== null)
|
||
|
||
let effectiveLang = $derived(
|
||
activeModuleTab && modules?.[activeModuleTab]
|
||
? (modules[activeModuleTab].language as Preview['language'])
|
||
: lang
|
||
)
|
||
|
||
let isWacV2 = $derived.by(() => {
|
||
const mainCode = code
|
||
const isTsWac =
|
||
mainCode.includes('windmill-client') &&
|
||
mainCode.includes('workflow') &&
|
||
mainCode.includes('task')
|
||
const isPyWac =
|
||
(mainCode.includes('import wmill') || mainCode.includes('from wmill')) &&
|
||
mainCode.includes('workflow') &&
|
||
mainCode.includes('task')
|
||
return isTsWac || isPyWac
|
||
})
|
||
let supportsModules = $derived((lang === 'bun' || lang === 'python3') && isWacV2)
|
||
let mainFileName = $derived('script.' + langToExt(scriptLangToEditorLang(lang)))
|
||
|
||
// A module path always ends in a file extension, so this id can't collide with one.
|
||
const MAIN_FILE_TAB_ID = '__main__'
|
||
// Tab order is view state only: `modules` is stored as JSONB, which re-sorts its keys on
|
||
// deploy, so an order written into it would not survive.
|
||
let moduleTabOrder: string[] = $state([])
|
||
let fileTabs = $derived.by<TabItem[]>(() => {
|
||
const paths = Object.keys(modules ?? {})
|
||
const rank = new Map(paths.map((p, i) => [p, i + moduleTabOrder.length]))
|
||
moduleTabOrder.forEach((p, i) => rank.has(p) && rank.set(p, i))
|
||
return [
|
||
{ id: MAIN_FILE_TAB_ID, label: mainFileName, closable: false, pinned: 'left' },
|
||
// Not closable through the strip: closing deletes the module's code, so it stays on
|
||
// the explicit × rather than Delete/Backspace and middle-click.
|
||
...paths
|
||
.sort((a, b) => rank.get(a)! - rank.get(b)!)
|
||
.map((modulePath) => ({
|
||
id: modulePath,
|
||
label: modulePath,
|
||
title: modulePath,
|
||
closable: false
|
||
}))
|
||
]
|
||
})
|
||
|
||
let modulePathInput = $state('')
|
||
let showAddModulePopover = $state(false)
|
||
let modulePathInputEl: HTMLInputElement | undefined = $state(undefined)
|
||
let modulePathError = $state('')
|
||
|
||
let renameModuleInput = $state('')
|
||
let renameModuleError = $state('')
|
||
let renameModuleInputEl: HTMLInputElement | undefined = $state(undefined)
|
||
|
||
const ALL_MODULE_EXTENSIONS: Record<string, ScriptModule['language']> = {
|
||
'.ts': 'bun',
|
||
'.py': 'python3',
|
||
'.go': 'go',
|
||
'.sh': 'bash',
|
||
'.ps1': 'powershell',
|
||
'.sql': 'postgresql',
|
||
'.gql': 'graphql',
|
||
'.php': 'php',
|
||
'.rs': 'rust',
|
||
'.yml': 'ansible',
|
||
'.cs': 'csharp',
|
||
'.nu': 'nu',
|
||
'.java': 'java',
|
||
'.rb': 'ruby'
|
||
}
|
||
|
||
/** Map main script language to allowed module file extensions. */
|
||
const LANG_MODULE_EXTENSIONS: Partial<Record<Preview['language'] & string, string[]>> = {
|
||
python3: ['.py'],
|
||
bun: ['.ts'],
|
||
deno: ['.ts'],
|
||
nativets: ['.ts'],
|
||
go: ['.go'],
|
||
bash: ['.sh'],
|
||
powershell: ['.ps1'],
|
||
postgresql: ['.sql'],
|
||
mysql: ['.sql'],
|
||
bigquery: ['.sql'],
|
||
snowflake: ['.sql'],
|
||
mssql: ['.sql'],
|
||
oracledb: ['.sql'],
|
||
duckdb: ['.sql'],
|
||
graphql: ['.gql'],
|
||
php: ['.php'],
|
||
rust: ['.rs'],
|
||
ansible: ['.yml'],
|
||
csharp: ['.cs'],
|
||
nu: ['.nu'],
|
||
java: ['.java'],
|
||
ruby: ['.rb'],
|
||
bunnative: ['.ts']
|
||
}
|
||
|
||
let allowedModuleExtensions = $derived(
|
||
lang
|
||
? (LANG_MODULE_EXTENSIONS[lang] ?? Object.keys(ALL_MODULE_EXTENSIONS))
|
||
: Object.keys(ALL_MODULE_EXTENSIONS)
|
||
)
|
||
|
||
function inferModuleLang(filePath: string): ScriptModule['language'] | undefined {
|
||
for (const [ext, moduleLang] of Object.entries(ALL_MODULE_EXTENSIONS)) {
|
||
if (filePath.endsWith(ext)) return moduleLang
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function getModuleDefaultContent(filePath: string): string {
|
||
if (filePath.endsWith('.py')) {
|
||
return `def hello() -> str:\n return "world"\n`
|
||
} else if (filePath.endsWith('.ts')) {
|
||
return `export function hello(): string {\n return "world"\n}\n`
|
||
} else if (filePath.endsWith('.go')) {
|
||
return `package inner\n\nfunc Hello() string {\n\treturn "world"\n}\n`
|
||
} else if (filePath.endsWith('.sh')) {
|
||
return `#!/bin/bash\necho "world"\n`
|
||
} else if (filePath.endsWith('.ps1')) {
|
||
return `function Hello {\n return "world"\n}\n`
|
||
} else if (filePath.endsWith('.sql')) {
|
||
return `SELECT 'world' as result;\n`
|
||
} else if (filePath.endsWith('.gql')) {
|
||
return `query Hello {\n hello\n}\n`
|
||
} else if (filePath.endsWith('.php')) {
|
||
return `<?php\nfunction hello(): string {\n return "world";\n}\n`
|
||
} else if (filePath.endsWith('.rs')) {
|
||
return `pub fn hello() -> String {\n "world".to_string()\n}\n`
|
||
} else if (filePath.endsWith('.yml')) {
|
||
return `---\n- name: Hello\n debug:\n msg: "world"\n`
|
||
} else if (filePath.endsWith('.cs')) {
|
||
return `public static string Hello() {\n return "world";\n}\n`
|
||
} else if (filePath.endsWith('.nu')) {
|
||
return `def hello [] {\n "world"\n}\n`
|
||
} else if (filePath.endsWith('.java')) {
|
||
return `public class Helper {\n public static String hello() {\n return "world";\n }\n}\n`
|
||
} else if (filePath.endsWith('.rb')) {
|
||
return `def hello\n "world"\nend\n`
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function validateModulePath(path: string): string {
|
||
if (!path.trim()) return ''
|
||
const canonical = canonicalModulePath(path)
|
||
if ('error' in canonical) return canonical.error
|
||
const moduleLang = inferModuleLang(canonical.path)
|
||
if (!moduleLang) {
|
||
const exts = allowedModuleExtensions.join(', ')
|
||
return `File must end with a supported extension: ${exts}`
|
||
}
|
||
const matchedExt = allowedModuleExtensions.find((ext) => canonical.path.endsWith(ext))
|
||
if (!matchedExt) {
|
||
const exts = allowedModuleExtensions.join(', ')
|
||
return `File must end with a supported extension for this language: ${exts}`
|
||
}
|
||
const clash = findModulePathClash(modules, canonical.path)
|
||
if (clash) {
|
||
return `Module ${clash} already exists`
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function addModule() {
|
||
if (!modulePathInput.trim()) return
|
||
const canonical = canonicalModulePath(modulePathInput)
|
||
if ('error' in canonical) {
|
||
modulePathError = canonical.error
|
||
return
|
||
}
|
||
const modulePath = canonical.path
|
||
const error = validateModulePath(modulePath)
|
||
if (error) {
|
||
modulePathError = error
|
||
return
|
||
}
|
||
if (!modules) {
|
||
modules = {}
|
||
}
|
||
modules[modulePath] = {
|
||
content: getModuleDefaultContent(modulePath),
|
||
language: inferModuleLang(modulePath)!
|
||
}
|
||
modulePathInput = ''
|
||
modulePathError = ''
|
||
showAddModulePopover = false
|
||
switchToModule(modulePath)
|
||
}
|
||
|
||
function removeModule(modulePath: string) {
|
||
if (!modules) return
|
||
if (activeModuleTab === modulePath) {
|
||
switchToMain()
|
||
}
|
||
delete modules[modulePath]
|
||
delete moduleTestState[modulePath]
|
||
modules = { ...modules }
|
||
moduleTabOrder = moduleTabOrder.filter((p) => p !== modulePath)
|
||
}
|
||
|
||
function validateRenameModulePath(newPath: string, oldPath: string): string {
|
||
if (!newPath.trim()) return ''
|
||
const canonical = canonicalModulePath(newPath)
|
||
if ('error' in canonical) return canonical.error
|
||
const moduleLang = inferModuleLang(canonical.path)
|
||
if (!moduleLang) {
|
||
const exts = allowedModuleExtensions.join(', ')
|
||
return `File must end with a supported extension: ${exts}`
|
||
}
|
||
const matchedExt = allowedModuleExtensions.find((ext) => canonical.path.endsWith(ext))
|
||
if (!matchedExt) {
|
||
const exts = allowedModuleExtensions.join(', ')
|
||
return `File must end with a supported extension for this language: ${exts}`
|
||
}
|
||
const clash = findModulePathClash(modules, canonical.path, oldPath)
|
||
if (clash) {
|
||
return `Module ${clash} already exists`
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/// A spelling of the name the module already has. Nothing to do, so the button
|
||
/// that would submit it stays disabled rather than being a dead click.
|
||
function renameIsNoop(input: string, oldPath: string): boolean {
|
||
const canonical = canonicalModulePath(input)
|
||
return 'path' in canonical && canonical.path === oldPath
|
||
}
|
||
|
||
function prefillRename(modulePath: string) {
|
||
renameModuleInput = modulePath
|
||
renameModuleError = ''
|
||
}
|
||
|
||
function renameModule(oldPath: string) {
|
||
if (!renameModuleInput.trim()) return
|
||
const canonical = canonicalModulePath(renameModuleInput)
|
||
if ('error' in canonical) {
|
||
renameModuleError = canonical.error
|
||
return
|
||
}
|
||
const newPath = canonical.path
|
||
if (newPath === oldPath) return
|
||
const error = validateRenameModulePath(newPath, oldPath)
|
||
if (error) {
|
||
renameModuleError = error
|
||
return
|
||
}
|
||
if (!modules) return
|
||
const mod = modules[oldPath]
|
||
const newLang = inferModuleLang(newPath)
|
||
delete modules[oldPath]
|
||
modules[newPath] = { ...mod, language: newLang ?? mod.language }
|
||
modules = { ...modules }
|
||
moduleTabOrder = moduleTabOrder.map((p) => (p === oldPath ? newPath : p))
|
||
if (moduleTestState[oldPath]) {
|
||
moduleTestState[newPath] = moduleTestState[oldPath]
|
||
delete moduleTestState[oldPath]
|
||
}
|
||
if (activeModuleTab === oldPath) {
|
||
activeModuleTab = newPath
|
||
}
|
||
renameModuleInput = ''
|
||
renameModuleError = ''
|
||
}
|
||
|
||
/** Save the active module tab's editor content back into the modules map (no UI side-effects). */
|
||
function flushModuleContent() {
|
||
if (activeModuleTab !== null && modules) {
|
||
modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode }
|
||
}
|
||
}
|
||
|
||
/** Flush module content and reset the editor back to the main script tab. */
|
||
export function flushModuleState() {
|
||
if (activeModuleTab !== null && modules) {
|
||
flushModuleContent()
|
||
moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema }
|
||
activeModuleTab = null
|
||
editorCode = code
|
||
}
|
||
}
|
||
|
||
$effect.pre(() => {
|
||
if (schema == undefined) {
|
||
schema = emptySchema()
|
||
}
|
||
})
|
||
let showHistoryDrawer = $state(false)
|
||
|
||
let jobProgressBar: JobProgressBar | undefined = $state(undefined)
|
||
let diffMode = $state(false)
|
||
|
||
let websocketAlive = $state({
|
||
pyright: false,
|
||
deno: false,
|
||
go: false,
|
||
ruff: false,
|
||
shellcheck: false
|
||
})
|
||
|
||
let inferAssetsRes = resource([() => lang, () => code, () => code], () => inferAssets(lang, code))
|
||
let preparedSqlQueries = usePreparedAssetSqlQueries(
|
||
() => inferAssetsRes.current?.sql_queries,
|
||
() => opWs
|
||
)
|
||
// Asset-parse validity for the editor badge. `undefined` while loading (so
|
||
// the badge doesn't flicker red); only an explicit parser error counts as
|
||
// invalid. Surfaced only when `requireValidAssets` (pipeline editor).
|
||
let validAssets = $derived(
|
||
requireValidAssets ? inferAssetsRes.current?.status !== 'error' : undefined
|
||
)
|
||
|
||
const dispatch = createEventDispatcher()
|
||
|
||
$effect(() => {
|
||
watchChanges &&
|
||
(code != undefined || schema != undefined) &&
|
||
dispatch('change', { code, schema })
|
||
})
|
||
|
||
watch(
|
||
() => inferAssetsRes.current,
|
||
() => {
|
||
if (!inferAssetsRes.current || inferAssetsRes.current?.status === 'error') {
|
||
// Clear stale lineage on parse error / unset, so a script switch
|
||
// whose new body fails to parse can't leave the previous script's
|
||
// inferred column lineage bound to the new path.
|
||
if (inferredColumnLineage !== undefined) inferredColumnLineage = undefined
|
||
return
|
||
}
|
||
let newAssets = inferAssetsRes.current.assets as AssetWithAltAccessType[]
|
||
for (const asset of newAssets) {
|
||
const old = assets?.find((a) => assetEq(a, asset))
|
||
if (old?.alt_access_type) asset.alt_access_type = old.alt_access_type
|
||
}
|
||
const normalizedAssets = newAssets.length > 0 ? newAssets : undefined
|
||
if (!deepEqual(assets, normalizedAssets)) assets = normalizedAssets
|
||
|
||
const newLineage = inferAssetsRes.current.column_lineage
|
||
const normalizedLineage = newLineage && newLineage.length > 0 ? newLineage : undefined
|
||
if (!deepEqual(inferredColumnLineage, normalizedLineage))
|
||
inferredColumnLineage = normalizedLineage
|
||
}
|
||
)
|
||
|
||
// Live schema-contract diagnostics (pipelines gap #2b): diff the buffer's
|
||
// asset refs against the captured producer schemas and surface mismatches
|
||
// as Monaco warning squiggles — the as-you-type mirror of the authoritative
|
||
// save-time check. The result is a prop on Editor (not an imperative call)
|
||
// because this can resolve before Monaco initializes on mount. Sequenced so
|
||
// a slow schema fetch can't overwrite the markers of a newer keystroke.
|
||
let contractMarkers: ContractMarker[] = $state([])
|
||
let contractCheckSeq = 0
|
||
watch([() => inferAssetsRes.current, () => schemaContractContext], () => {
|
||
const res = inferAssetsRes.current
|
||
const workspace = opWs
|
||
const seq = ++contractCheckSeq
|
||
if (!workspace || !res || res.status === 'error') {
|
||
contractMarkers = []
|
||
return
|
||
}
|
||
const bufferCode = code
|
||
computeContractMarkers(
|
||
workspace,
|
||
bufferCode,
|
||
(res.assets ?? []) as AssetWithAltAccessType[],
|
||
schemaContractContext
|
||
)
|
||
.then((markers) => {
|
||
if (seq === contractCheckSeq) contractMarkers = markers
|
||
})
|
||
.catch((e) => console.error('schema-contract diagnostics failed', e))
|
||
})
|
||
|
||
watch([() => code, () => lang], () => {
|
||
if (lang !== 'ansible') return
|
||
inferAnsibleExecutionMode(code).then((v) => {
|
||
if (
|
||
v !== undefined &&
|
||
(v.delegate_to_git_repo_details === null ||
|
||
v.delegate_to_git_repo_details.resource !== ansibleAlternativeExecutionMode?.resource ||
|
||
v.delegate_to_git_repo_details.playbook !== ansibleAlternativeExecutionMode?.playbook ||
|
||
v.delegate_to_git_repo_details.inventories_location !==
|
||
ansibleAlternativeExecutionMode?.inventories_location ||
|
||
v.delegate_to_git_repo_details.commit !== ansibleAlternativeExecutionMode?.commit ||
|
||
v.git_ssh_identity !== ansibleGitSshIdentity)
|
||
) {
|
||
ansibleAlternativeExecutionMode = v.delegate_to_git_repo_details
|
||
ansibleGitSshIdentity = v.git_ssh_identity
|
||
}
|
||
})
|
||
})
|
||
|
||
let width = $state(1200)
|
||
|
||
let jobLoader: JobLoader | undefined = $state(undefined)
|
||
|
||
let isValid: boolean = $state(true)
|
||
// Mirror the test-form validity out to an optional host callback.
|
||
$effect(() => {
|
||
onIsValidChange?.(isValid)
|
||
})
|
||
let scriptProgress = $state(undefined)
|
||
|
||
let logPanel: LogPanel | undefined = $state(undefined)
|
||
// Test
|
||
let testIsLoading = $state(false)
|
||
let testJob: Job | undefined = $state()
|
||
let pastPreviews: CompletedJob[] = $state([])
|
||
let historyTabActive = false
|
||
let pastPreviewsRequest: ReturnType<typeof JobService.listCompletedJobs> | undefined
|
||
let validCode = $state(true)
|
||
|
||
// Recording: nothing is captured live — a "record" run just remembers the
|
||
// completed job id plus the code/args/schema as they were at run time, and
|
||
// the recording is built from the completed job on download.
|
||
let recordingArmed = false
|
||
let lastRecordingJobId: string | undefined = $state(undefined)
|
||
let recordingMeta:
|
||
| {
|
||
scriptPath: string
|
||
code: string
|
||
language: string
|
||
args: Record<string, any>
|
||
schema?: Record<string, any>
|
||
}
|
||
| undefined = undefined
|
||
let downloadingRecording = $state(false)
|
||
|
||
let wsProvider: WebsocketProvider | undefined = $state(undefined)
|
||
let yContent: Y.Text | undefined = $state(undefined)
|
||
let peers: { name: string }[] = $state([])
|
||
let showCollabPopup = $state(false)
|
||
|
||
let ansibleAlternativeExecutionMode = $state<
|
||
| { resource?: string; commit?: string; inventories_location?: string; playbook?: string }
|
||
| null
|
||
| undefined
|
||
>()
|
||
let ansibleGitSshIdentity = $state<string[]>([])
|
||
|
||
// Debug mode state
|
||
let debugMode = $state(false)
|
||
let debugBreakpoints = new SvelteSet<number>()
|
||
let breakpointDecorations: string[] = $state([])
|
||
let currentLineDecoration: string[] = $state([])
|
||
let hoverBreakpointDecoration: string[] = $state([])
|
||
// Line currently showing the ghost breakpoint, used to short-circuit redundant
|
||
// deltaDecorations calls on every mousemove event.
|
||
let hoverBreakpointLine: number | null = null
|
||
// Get the DAP server URL based on language
|
||
const dapServerUrl = $derived(getDebugServerUrl((lang || 'python3') as DebugLanguage))
|
||
const debugFilePath = $derived(`/tmp/script${getDebugFileExtension(lang || '')}`)
|
||
let dapClient = $state<ReturnType<typeof getDAPClient> | null>(null)
|
||
const isDebuggableScript = $derived(isDebuggable(lang || ''))
|
||
// Derived: show debug panel when connected and (running or stopped, but not terminated)
|
||
const showDebugPanel = $derived(
|
||
debugMode && $debugState.connected && ($debugState.running || $debugState.stopped)
|
||
)
|
||
// Derived: debug has a result (script completed)
|
||
const hasDebugResult = $derived(debugMode && $debugState.result !== undefined)
|
||
// Show debug console at bottom of editor when debugging is active
|
||
let showDebugConsole = $state(true)
|
||
const debugConsoleVisible = $derived(showDebugPanel && showDebugConsole)
|
||
// Selected stack frame ID - shared between DebugPanel and DebugConsole
|
||
let selectedDebugFrameId: number | null = $state(null)
|
||
// Use selected frame or first frame for console context
|
||
const currentDebugFrameId = $derived(selectedDebugFrameId ?? $debugState.stackFrames[0]?.id)
|
||
// Job ID of the current debug session (for expression signing/audit logging)
|
||
let debugSessionJobId: string | null = $state(null)
|
||
// Pane sizes for editor/console split (percentage)
|
||
let editorPaneSize = $state(75)
|
||
let consolePaneSize = $state(25)
|
||
|
||
// Breakpoint decoration options
|
||
// stickiness: 1 = NeverGrowsWhenTypingAtEdges - decorations track their position when code changes
|
||
const breakpointDecorationType: meditor.IModelDecorationOptions = {
|
||
glyphMarginClassName: 'debug-breakpoint-glyph',
|
||
glyphMarginHoverMessage: { value: 'Breakpoint (click to remove)' },
|
||
stickiness: 1
|
||
}
|
||
|
||
// Ghost breakpoint shown while hovering the gutter on a line without a breakpoint
|
||
const hoverBreakpointDecorationType: meditor.IModelDecorationOptions = {
|
||
glyphMarginClassName: 'debug-breakpoint-glyph-hover',
|
||
glyphMarginHoverMessage: { value: 'Click to add a breakpoint' },
|
||
stickiness: 1
|
||
}
|
||
|
||
const currentLineDecorationType = {
|
||
isWholeLine: true,
|
||
className: 'debug-current-line',
|
||
glyphMarginClassName: 'debug-current-line-glyph'
|
||
}
|
||
|
||
const url = new URL(window.location.toString())
|
||
let initialCollab = /true|1/i.test(url.searchParams.get('collab') ?? '0')
|
||
|
||
if (initialCollab) {
|
||
setCollaborationMode()
|
||
url.searchParams.delete('collab')
|
||
url.searchParams.delete('path')
|
||
history.replaceState(null, '', url)
|
||
}
|
||
|
||
function onKeyDown(event: KeyboardEvent) {
|
||
if ((event.ctrlKey || event.metaKey) && event.key == 'Enter') {
|
||
event.preventDefault()
|
||
runTest()
|
||
} else if ((event.ctrlKey || event.metaKey) && event.key == 'u') {
|
||
event.preventDefault()
|
||
toggleTestPanel()
|
||
}
|
||
}
|
||
|
||
export function setArgs(nargs: Record<string, any>) {
|
||
args = nargs
|
||
argsRender++
|
||
}
|
||
|
||
export async function runTest(opts?: { cascade?: boolean; skipDdlGuard?: boolean }) {
|
||
// Intercept DDL statements (offer to turn them into data table migrations)
|
||
// on every run path, not just the editor's Cmd+Enter. `skipDdlGuard` is set
|
||
// by the Cmd+Enter action, which already guarded before calling us.
|
||
if (!opts?.skipDdlGuard) {
|
||
if ((await editor?.guardDdlBeforeRun()) === false) return
|
||
// The guard may have rewritten the code (migrated statements stripped);
|
||
// `editorCode` is kept in sync by the editor binding, so mirror the
|
||
// on:change handler and pull it into `code` before we run.
|
||
if (activeModuleTab === null) code = editorCode
|
||
}
|
||
// When the caller forces a cascade choice (e.g. the canvas runnable
|
||
// menu's "Run + trigger N downstream"), also flip the persistent
|
||
// `cascadeDownstream` state so the split button's label/icon reflect
|
||
// the active mode after the run kicks off — keeps "what mode am I in"
|
||
// visible instead of having a one-off run silently disagree with the
|
||
// UI. The caller is welcome to bump the signal repeatedly; we just
|
||
// keep the latest choice as the active mode.
|
||
if (opts?.cascade !== undefined) cascadeDownstream = opts.cascade
|
||
// Discard any previous recording when running a normal test
|
||
if (!recordingArmed) {
|
||
lastRecordingJobId = undefined
|
||
}
|
||
// Not defined if JobProgressBar not loaded
|
||
jobProgressBar?.reset()
|
||
// Flush module edits back to modules map before running preview
|
||
flushModuleContent()
|
||
|
||
const onModule = onModuleArgs
|
||
const testCode = onModule ? editorCode : code
|
||
const testLang = onModule ? effectiveLang : lang
|
||
const rawTestArgs = onModule
|
||
? testPanelArgs
|
||
: selectedTab === 'preprocessor' || kind === 'preprocessor'
|
||
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...(args ?? {}) }
|
||
: (args ?? {})
|
||
const testSchema = onModule ? testPanelSchema : schema
|
||
const testArgs = await processSecretArgs(rawTestArgs, testSchema, opWs)
|
||
if (showPsCommonParams) {
|
||
for (const [k, v] of Object.entries(psCommonParams)) {
|
||
if (v !== undefined && v !== false && v !== '') {
|
||
testArgs[k] = v
|
||
}
|
||
}
|
||
}
|
||
// In pipeline-editor contexts (downstreamSubscribers prop set), default
|
||
// the test run to *just this step* by passing the backend opt-out arg.
|
||
// The user can run with cascade via the split button's caret option,
|
||
// which flips `cascadeDownstream` and lets dispatch fire normally.
|
||
if ((customUi?.previewPanel?.downstreamSubscribers ?? 0) > 0 && !cascadeDownstream) {
|
||
testArgs['_wmill_skip_asset_dispatch'] = true
|
||
}
|
||
|
||
//@ts-ignore
|
||
let job = await jobLoader.runPreview(
|
||
path,
|
||
testCode,
|
||
testLang,
|
||
testArgs,
|
||
tag,
|
||
undefined,
|
||
undefined,
|
||
{
|
||
done(x) {
|
||
if (recordingArmed) {
|
||
recordingArmed = false
|
||
lastRecordingJobId = x?.id
|
||
}
|
||
if (historyTabActive) {
|
||
loadPastTests()
|
||
}
|
||
},
|
||
doneError({ id, error }) {
|
||
// A failed run is still a completed job and records fine.
|
||
if (recordingArmed) {
|
||
recordingArmed = false
|
||
lastRecordingJobId = id
|
||
}
|
||
console.error(error)
|
||
}
|
||
},
|
||
undefined,
|
||
// A `__mod` helper is tested alone, so its siblings are left out.
|
||
onModule ? undefined : modules,
|
||
undefined,
|
||
timeout
|
||
)
|
||
if (job) {
|
||
onTestJob?.({ jobId: job })
|
||
}
|
||
logPanel?.setFocusToLogs()
|
||
return job
|
||
}
|
||
|
||
async function recordAndTest() {
|
||
lastRecordingJobId = undefined
|
||
recordingMeta = {
|
||
scriptPath: path ?? '',
|
||
code,
|
||
language: lang ?? '',
|
||
args: JSON.parse(JSON.stringify(args ?? {})),
|
||
schema: schema ? JSON.parse(JSON.stringify(schema)) : undefined
|
||
}
|
||
recordingArmed = true
|
||
await runTest()
|
||
}
|
||
|
||
async function downloadRecording() {
|
||
if (!lastRecordingJobId || !recordingMeta || downloadingRecording) return
|
||
downloadingRecording = true
|
||
try {
|
||
const recording = await buildScriptRecording(opWs!, lastRecordingJobId, recordingMeta)
|
||
downloadRecordingJson(
|
||
recording,
|
||
`script-recording-${(recording.script_path || 'untitled').replace(/\//g, '-')}`
|
||
)
|
||
} catch (e: any) {
|
||
sendUserToast('Could not build the recording', true, undefined, e?.toString())
|
||
} finally {
|
||
downloadingRecording = false
|
||
}
|
||
}
|
||
|
||
async function loadPastTests(): Promise<void> {
|
||
pastPreviewsRequest?.cancel()
|
||
const req = JobService.listCompletedJobs({
|
||
workspace: opWs!,
|
||
jobKinds: 'preview',
|
||
createdBy: $userStore?.username,
|
||
scriptPathExact: path,
|
||
hasNullParent: true
|
||
})
|
||
pastPreviewsRequest = req
|
||
try {
|
||
const result = await req
|
||
if (pastPreviewsRequest === req) {
|
||
pastPreviews = result
|
||
}
|
||
} catch (err) {
|
||
if (!(err instanceof Error) || err.name !== 'CancelError') {
|
||
throw err
|
||
}
|
||
} finally {
|
||
if (pastPreviewsRequest === req) {
|
||
pastPreviewsRequest = undefined
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function inferSchema(
|
||
code: string,
|
||
{
|
||
nlang,
|
||
resetArgs = false,
|
||
applyInitialArgs = false
|
||
}: {
|
||
nlang?: SupportedLanguage
|
||
resetArgs?: boolean
|
||
applyInitialArgs?: boolean
|
||
} = {}
|
||
) {
|
||
let nschema = schema ?? emptySchema()
|
||
|
||
try {
|
||
const result = await inferArgs(
|
||
nlang ?? lang,
|
||
code,
|
||
nschema,
|
||
selectedTab === 'preprocessor' || kind === 'preprocessor' ? 'preprocessor' : undefined
|
||
)
|
||
|
||
if (kind === 'preprocessor') {
|
||
hasPreprocessor = false
|
||
selectedTab = 'main'
|
||
} else {
|
||
hasPreprocessor =
|
||
(selectedTab === 'preprocessor' ? !result?.auto_kind : result?.has_preprocessor) ?? false
|
||
|
||
if (!hasPreprocessor && selectedTab === 'preprocessor') {
|
||
selectedTab = 'main'
|
||
}
|
||
}
|
||
|
||
validCode = true
|
||
if (resetArgs) {
|
||
args = {}
|
||
}
|
||
if (applyInitialArgs) {
|
||
// we reapply initial args as the schema form might have cleared them between mount and the schema inference
|
||
args = initialArgs
|
||
}
|
||
injectPartitionArg(nschema, args, nlang ?? lang, code)
|
||
schema = nschema
|
||
} catch (e) {
|
||
validCode = false
|
||
}
|
||
}
|
||
|
||
async function inferModuleSchema() {
|
||
if (activeModuleTab === null) return
|
||
try {
|
||
await inferArgs(effectiveLang, editorCode, testPanelSchema)
|
||
injectPartitionArg(testPanelSchema, testPanelArgs, effectiveLang, editorCode)
|
||
moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema }
|
||
} catch (e) {
|
||
// Module code may be in-progress; silently ignore
|
||
}
|
||
}
|
||
|
||
let gitRepoResourcePickerOpen = $state(false)
|
||
let commitHashForGitRepo = $derived(ansibleAlternativeExecutionMode?.commit)
|
||
|
||
// Check if delegate_to_git_repo exists in the code
|
||
let hasDelegateToGitRepo = $derived(code && code.includes('delegate_to_git_repo:'))
|
||
|
||
function handleDelegateConfigUpdate(event: {
|
||
detail: { resourcePath: string; playbook?: string; inventoriesLocation?: string }
|
||
}) {
|
||
if (!editor) return
|
||
|
||
const currentCode = editor.getCode()
|
||
const newCode = updateDelegateToGitRepoConfig(currentCode, {
|
||
resource: event.detail.resourcePath,
|
||
playbook: event.detail.playbook,
|
||
inventories_location: event.detail.inventoriesLocation
|
||
})
|
||
editor.setCode(newCode)
|
||
|
||
// Trigger schema inference to update assets
|
||
inferSchema(newCode)
|
||
}
|
||
|
||
function handleAddInventories(event: { detail: { inventoryPaths: string[] } }) {
|
||
if (!editor) return
|
||
|
||
const currentCode = editor.getCode()
|
||
const newCode = insertAdditionalInventories(currentCode, event.detail.inventoryPaths)
|
||
editor.setCode(newCode)
|
||
|
||
// Trigger schema inference to update assets
|
||
inferSchema(newCode)
|
||
}
|
||
|
||
// Debug functions
|
||
function toggleBreakpoint(line: number): void {
|
||
if (debugBreakpoints.has(line)) {
|
||
debugBreakpoints.delete(line)
|
||
} else {
|
||
debugBreakpoints.add(line)
|
||
}
|
||
updateBreakpointDecorations()
|
||
clearHoverBreakpointDecoration()
|
||
}
|
||
|
||
function updateHoverBreakpointDecoration(line: number): void {
|
||
if (hoverBreakpointLine === line) return
|
||
const monacoEditor = editor?.getEditor?.()
|
||
if (!monacoEditor) return
|
||
|
||
const decorations = [
|
||
{
|
||
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
|
||
options: hoverBreakpointDecorationType
|
||
}
|
||
]
|
||
|
||
const oldDecorations = untrack(() => hoverBreakpointDecoration)
|
||
hoverBreakpointDecoration = monacoEditor.deltaDecorations(oldDecorations, decorations)
|
||
hoverBreakpointLine = line
|
||
}
|
||
|
||
function clearHoverBreakpointDecoration(): void {
|
||
if (hoverBreakpointLine === null) return
|
||
const monacoEditor = editor?.getEditor?.()
|
||
if (!monacoEditor) return
|
||
const oldDecorations = untrack(() => hoverBreakpointDecoration)
|
||
if (oldDecorations.length > 0) {
|
||
hoverBreakpointDecoration = monacoEditor.deltaDecorations(oldDecorations, [])
|
||
}
|
||
hoverBreakpointLine = null
|
||
}
|
||
|
||
function updateBreakpointDecorations(): void {
|
||
const monacoEditor = editor?.getEditor?.()
|
||
if (!monacoEditor) return
|
||
|
||
const decorations = Array.from(debugBreakpoints).map((line) => ({
|
||
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
|
||
options: breakpointDecorationType
|
||
}))
|
||
|
||
// Use untrack to prevent reactive loop when reading the old decorations
|
||
const oldDecorations = untrack(() => breakpointDecorations)
|
||
breakpointDecorations = monacoEditor.deltaDecorations(oldDecorations, decorations)
|
||
}
|
||
|
||
// Refresh breakpoint line numbers from decoration positions after code edits
|
||
function refreshBreakpointPositions(): void {
|
||
const monacoEditor = editor?.getEditor?.()
|
||
if (!monacoEditor || breakpointDecorations.length === 0) return
|
||
|
||
const model = monacoEditor.getModel()
|
||
if (!model) return
|
||
|
||
// Get current line numbers from decorations (Monaco tracks positions when code changes)
|
||
const newLines = new Set<number>()
|
||
for (const decorationId of breakpointDecorations) {
|
||
const range = model.getDecorationRange(decorationId)
|
||
if (range) {
|
||
newLines.add(range.startLineNumber)
|
||
}
|
||
}
|
||
|
||
// Check if positions changed
|
||
const oldLines = Array.from(debugBreakpoints).sort((a, b) => a - b)
|
||
const updatedLines = Array.from(newLines).sort((a, b) => a - b)
|
||
|
||
const positionsChanged =
|
||
oldLines.length !== updatedLines.length ||
|
||
oldLines.some((line, i) => line !== updatedLines[i])
|
||
|
||
if (positionsChanged) {
|
||
// Update breakpoints set with new positions
|
||
debugBreakpoints.clear()
|
||
for (const line of newLines) {
|
||
debugBreakpoints.add(line)
|
||
}
|
||
// Sync updated positions with server if connected
|
||
syncBreakpointsWithServer()
|
||
}
|
||
}
|
||
|
||
// Sync breakpoints with DAP server when connected
|
||
async function syncBreakpointsWithServer(): Promise<void> {
|
||
if (!dapClient || !dapClient.isConnected()) return
|
||
|
||
try {
|
||
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
|
||
} catch (error) {
|
||
console.error('Failed to sync breakpoints:', error)
|
||
}
|
||
}
|
||
|
||
function updateCurrentLineDecoration(line: number | undefined): void {
|
||
const monacoEditor = editor?.getEditor?.()
|
||
if (!monacoEditor) return
|
||
|
||
// Use untrack to prevent reactive loop when reading the old decorations
|
||
const oldDecorations = untrack(() => currentLineDecoration)
|
||
|
||
if (!line) {
|
||
currentLineDecoration = monacoEditor.deltaDecorations(oldDecorations, [])
|
||
return
|
||
}
|
||
|
||
const decorations = [
|
||
{
|
||
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
|
||
options: currentLineDecorationType
|
||
}
|
||
]
|
||
|
||
currentLineDecoration = monacoEditor.deltaDecorations(oldDecorations, decorations)
|
||
monacoEditor.revealLineInCenter(line)
|
||
}
|
||
|
||
async function startDebugging(): Promise<void> {
|
||
try {
|
||
// Show console when starting a debug session
|
||
showDebugConsole = true
|
||
// Reset selected frame when starting new session
|
||
selectedDebugFrameId = null
|
||
|
||
// Always reset and create a fresh DAP client with the correct URL for the current language
|
||
// This ensures we connect to the correct endpoint even if language changed
|
||
resetDAPClient()
|
||
dapClient = getDAPClient(dapServerUrl)
|
||
|
||
// Fetch contextual variables (WM_WORKSPACE, WM_TOKEN, etc.) from backend
|
||
const env = await fetchContextualVariables(opWs ?? '')
|
||
|
||
// Sign the debug request (creates audit log entry)
|
||
let signedPayload
|
||
try {
|
||
signedPayload = await signDebugRequest(opWs ?? '', code ?? '', lang ?? 'python3')
|
||
debugSessionJobId = signedPayload.job_id
|
||
} catch (signError) {
|
||
sendUserToast(getDebugErrorMessage(signError), true)
|
||
return
|
||
}
|
||
|
||
await dapClient.connect()
|
||
await dapClient.initialize()
|
||
await dapClient.setBreakpoints(debugFilePath, Array.from(debugBreakpoints))
|
||
await dapClient.configurationDone()
|
||
// Pass the signed token along with other launch parameters
|
||
await dapClient.launch({
|
||
code,
|
||
cwd: '/tmp',
|
||
args: args ?? {},
|
||
callMain: true,
|
||
env,
|
||
// JWT token for verification by the debugger
|
||
token: signedPayload.token
|
||
})
|
||
} catch (error) {
|
||
console.error('Failed to start debugging:', error)
|
||
sendUserToast(getDebugErrorMessage(error), true)
|
||
}
|
||
}
|
||
|
||
async function stopDebugging(): Promise<void> {
|
||
if (!dapClient) return
|
||
try {
|
||
await dapClient.terminate()
|
||
dapClient.disconnect()
|
||
} catch (error) {
|
||
console.error('Failed to stop debugging:', error)
|
||
} finally {
|
||
// Clear the job ID when debug session ends
|
||
debugSessionJobId = null
|
||
}
|
||
}
|
||
|
||
async function continueExecution(): Promise<void> {
|
||
if (!dapClient) return
|
||
await dapClient.continue_()
|
||
}
|
||
|
||
async function stepOver(): Promise<void> {
|
||
if (!dapClient) return
|
||
await dapClient.stepOver()
|
||
}
|
||
|
||
async function stepIn(): Promise<void> {
|
||
if (!dapClient) return
|
||
await dapClient.stepIn()
|
||
}
|
||
|
||
async function stepOut(): Promise<void> {
|
||
if (!dapClient) return
|
||
await dapClient.stepOut()
|
||
}
|
||
|
||
function clearAllBreakpoints(): void {
|
||
debugBreakpoints.clear()
|
||
updateBreakpointDecorations()
|
||
}
|
||
|
||
function toggleDebugMode(): void {
|
||
if (debugMode) {
|
||
// Exiting debug mode - clean up
|
||
debugMode = false
|
||
stopDebugging()
|
||
clearAllBreakpoints()
|
||
updateCurrentLineDecoration(undefined)
|
||
} else {
|
||
debugMode = true
|
||
// The debug UI mounts inside the test pane, which is collapsed to 0 in
|
||
// AI sessions. Must stay a one-shot expand at the toggle, not a reactive
|
||
// effect: an effect would reopen the pane whenever the user collapsed it
|
||
// while debugging.
|
||
if (testPanelSize === 0) {
|
||
expandTestPanel()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Subscribe to debug state changes for current line highlighting
|
||
$effect(() => {
|
||
const currentLine = $debugState.currentLine
|
||
if (debugMode) {
|
||
untrack(() => updateCurrentLineDecoration(currentLine))
|
||
}
|
||
})
|
||
|
||
// Watch for language changes - exit debug mode and reset client when language changes
|
||
let lastDebugLang: typeof lang | undefined = undefined
|
||
$effect(() => {
|
||
const currentLang = lang
|
||
if (lastDebugLang !== undefined && lastDebugLang !== currentLang && debugMode) {
|
||
// Language changed while in debug mode - exit debug mode
|
||
untrack(() => {
|
||
// Stop any running debug session
|
||
if (dapClient) {
|
||
dapClient
|
||
.terminate()
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
dapClient?.disconnect()
|
||
})
|
||
}
|
||
// Reset the singleton
|
||
resetDAPClient()
|
||
dapClient = null
|
||
// Exit debug mode
|
||
debugMode = false
|
||
// Clear decorations
|
||
clearAllBreakpoints()
|
||
updateCurrentLineDecoration(undefined)
|
||
})
|
||
}
|
||
lastDebugLang = currentLang
|
||
})
|
||
|
||
// Set up glyph margin click handler for breakpoints when debug mode is enabled
|
||
$effect(() => {
|
||
const monacoEditor = editor?.getEditor?.()
|
||
if (!monacoEditor) return
|
||
|
||
if (debugMode && isDebuggableScript) {
|
||
// Enable glyph margin for breakpoints
|
||
monacoEditor.updateOptions({ glyphMargin: true })
|
||
|
||
// Add click handler for glyph margin (breakpoint toggle)
|
||
const mouseDownDisposable = monacoEditor.onMouseDown((e) => {
|
||
if (e.target.type === meditor.MouseTargetType.GUTTER_GLYPH_MARGIN) {
|
||
const line = e.target.position?.lineNumber
|
||
if (line) {
|
||
toggleBreakpoint(line)
|
||
}
|
||
}
|
||
})
|
||
|
||
// Show a ghost breakpoint while hovering anywhere in the gutter on an empty line.
|
||
// Hover area is intentionally wider than the click target — clicks still only
|
||
// toggle when landing on the glyph margin itself, but the ghost helps users find it.
|
||
const mouseMoveDisposable = monacoEditor.onMouseMove((e) => {
|
||
const t = e.target.type
|
||
const isGutter =
|
||
t === meditor.MouseTargetType.GUTTER_GLYPH_MARGIN ||
|
||
t === meditor.MouseTargetType.GUTTER_LINE_NUMBERS ||
|
||
t === meditor.MouseTargetType.GUTTER_LINE_DECORATIONS
|
||
const line = e.target.position?.lineNumber
|
||
if (isGutter && line && !debugBreakpoints.has(line)) {
|
||
updateHoverBreakpointDecoration(line)
|
||
} else {
|
||
clearHoverBreakpointDecoration()
|
||
}
|
||
})
|
||
|
||
const mouseLeaveDisposable = monacoEditor.onMouseLeave(() => {
|
||
clearHoverBreakpointDecoration()
|
||
})
|
||
|
||
// Add F9 keyboard shortcut for toggling breakpoint at cursor
|
||
monacoEditor.addCommand(120, () => {
|
||
// KeyCode.F9 = 120
|
||
const position = monacoEditor.getPosition()
|
||
if (position) {
|
||
toggleBreakpoint(position.lineNumber)
|
||
}
|
||
})
|
||
|
||
// Debug stepping keyboard shortcuts (only active when stopped)
|
||
// F8 = Continue (KeyCode.F8 = 119)
|
||
monacoEditor.addCommand(119, () => {
|
||
if ($debugState.stopped) continueExecution()
|
||
})
|
||
|
||
// F6 = Step Over (KeyCode.F6 = 117)
|
||
monacoEditor.addCommand(117, () => {
|
||
if ($debugState.stopped) stepOver()
|
||
})
|
||
|
||
// F7 = Step Into (KeyCode.F7 = 118)
|
||
monacoEditor.addCommand(118, () => {
|
||
if ($debugState.stopped) stepIn()
|
||
})
|
||
|
||
// Shift+F8 = Step Out (KeyMod.Shift | KeyCode.F8 = 1024 | 119 = 1143)
|
||
monacoEditor.addCommand(1143, () => {
|
||
if ($debugState.stopped) stepOut()
|
||
})
|
||
|
||
return () => {
|
||
mouseDownDisposable.dispose()
|
||
mouseMoveDisposable.dispose()
|
||
mouseLeaveDisposable.dispose()
|
||
clearHoverBreakpointDecoration()
|
||
// Disable glyph margin when exiting debug mode
|
||
monacoEditor.updateOptions({ glyphMargin: false })
|
||
}
|
||
} else {
|
||
// Ensure glyph margin is disabled when not in debug mode
|
||
monacoEditor.updateOptions({ glyphMargin: false })
|
||
}
|
||
})
|
||
|
||
onMount(async () => {
|
||
await inferSchema(code, { applyInitialArgs: true })
|
||
// Retry once if the initial inference failed silently (e.g. transient WASM
|
||
// init race). Without this, users had to modify the code to trigger a
|
||
// second on:change-driven inference.
|
||
if (!validCode && code && lang) {
|
||
await inferSchema(code, { applyInitialArgs: true })
|
||
}
|
||
aiChatManager.saveAndClear()
|
||
aiChatManager.changeMode(AIMode.SCRIPT)
|
||
if (customUi?.previewPanel?.loadLastRunOnMount) {
|
||
void loadLastRunIntoTestPanel()
|
||
}
|
||
})
|
||
|
||
// Pull the most recent top-level completed job for this script path and
|
||
// feed it through the JobLoader so the preview pane renders its logs +
|
||
// result. Best-effort: any error (no job yet, network blip) leaves the
|
||
// panel empty rather than surfacing a toast — this is a passive "show
|
||
// what's there" affordance, not a user action. Skipped when a test is
|
||
// already running so a live job's stream is never clobbered.
|
||
async function loadLastRunIntoTestPanel(): Promise<void> {
|
||
if (!path || !opWs) return
|
||
if (testIsLoading || testJob !== undefined) return
|
||
try {
|
||
const jobs = await JobService.listCompletedJobs({
|
||
workspace: opWs,
|
||
scriptPathExact: path,
|
||
hasNullParent: true,
|
||
perPage: 1,
|
||
orderDesc: true
|
||
})
|
||
const lastId = jobs[0]?.id
|
||
if (lastId && !testIsLoading && testJob === undefined) {
|
||
await jobLoader?.watchJob(lastId)
|
||
}
|
||
} catch (e) {
|
||
// Silent: empty preview pane is the natural fallback.
|
||
}
|
||
}
|
||
|
||
setLicense()
|
||
export async function setCollaborationMode() {
|
||
await setLicense()
|
||
if (!$enterpriseLicense) {
|
||
sendUserToast(`Multiplayer is an enterprise feature`, true, [
|
||
{
|
||
label: 'Upgrade',
|
||
callback: () => {
|
||
window.open('https://www.windmill.dev/pricing', '_blank')
|
||
}
|
||
}
|
||
])
|
||
return
|
||
}
|
||
|
||
let token: string | undefined
|
||
try {
|
||
token = await signMultiplayerRequest(opWs ?? '')
|
||
} catch (e) {
|
||
console.error('Failed to sign multiplayer request:', e)
|
||
sendUserToast('Failed to authorize multiplayer session', true)
|
||
return
|
||
}
|
||
|
||
const ydoc = new Y.Doc()
|
||
if (wsProvider) {
|
||
wsProvider.destroy()
|
||
}
|
||
let yContentInit = ydoc.getText('content')
|
||
|
||
wsProvider = new WebsocketProvider(
|
||
buildWsUrl('/ws_mp/'),
|
||
opWs + '/' + (path ?? 'no-room-name'),
|
||
ydoc,
|
||
{ connect: false, params: { token } }
|
||
)
|
||
|
||
wsProvider.on('sync', (isSynced: boolean) => {
|
||
if (isSynced && yContentInit?.toJSON() == '') {
|
||
showCollabPopup = true
|
||
yContentInit?.insert(0, code)
|
||
}
|
||
yContent = yContentInit
|
||
})
|
||
|
||
wsProvider.on('connection-error', (WSErrorEvent) => {
|
||
console.error(WSErrorEvent)
|
||
sendUserToast('Multiplayer server connection had an error', true)
|
||
})
|
||
wsProvider.connect()
|
||
const awareness = wsProvider.awareness
|
||
|
||
awareness.setLocalStateField('user', {
|
||
name: $userStore?.username
|
||
})
|
||
|
||
function setPeers() {
|
||
peers = Array.from(awareness.getStates().values()).map((x) => x?.['user'])
|
||
}
|
||
|
||
setPeers()
|
||
// You can observe when a user updates their awareness information
|
||
awareness.on('change', (changes) => {
|
||
setPeers()
|
||
})
|
||
}
|
||
|
||
export function disableCollaboration() {
|
||
if (!wsProvider?.shouldConnect) return
|
||
peers = []
|
||
wsProvider?.disconnect()
|
||
wsProvider.destroy()
|
||
wsProvider = undefined
|
||
}
|
||
|
||
onDestroy(() => {
|
||
pastPreviewsRequest?.cancel()
|
||
pastPreviewsRequest = undefined
|
||
disableCollaboration()
|
||
aiChatManager.scriptEditorApplyCode = undefined
|
||
aiChatManager.scriptEditorShowDiffMode = undefined
|
||
aiChatManager.scriptEditorGetLintErrors = undefined
|
||
aiChatManager.scriptEditorOptions = undefined
|
||
aiChatManager.saveAndClear()
|
||
aiChatManager.changeMode(AIMode.NAVIGATOR)
|
||
// Clean up debug mode
|
||
if (debugMode) {
|
||
stopDebugging()
|
||
resetDAPClient()
|
||
}
|
||
})
|
||
|
||
function asKind(str: string | undefined) {
|
||
return str as 'script' | 'approval' | 'trigger' | undefined
|
||
}
|
||
|
||
function collabUrl() {
|
||
let url = new URL(window.location.toString().split('#')[0])
|
||
url.search = ''
|
||
return (
|
||
`${url}?collab=1&workspace=${encodeURIComponent(opWs ?? '')}&lang=${encodeURIComponent(lang ?? '')}` +
|
||
(edit ? '' : `&path=${path}`)
|
||
)
|
||
}
|
||
|
||
const WAC_CONTEXT_LANGUAGES = ['python3', 'bun']
|
||
let isWac = $derived(
|
||
template === 'wac_python' ||
|
||
template === 'wac_typescript' ||
|
||
autoKind === 'wac' ||
|
||
(code && lang ? isWorkflowAsCode(code, lang) : false)
|
||
)
|
||
let workflowAsCodeAiContext = $derived(
|
||
activeModuleTab === null && isWac && WAC_CONTEXT_LANGUAGES.includes(lang ?? '')
|
||
)
|
||
let showTabs = $derived(hasPreprocessor || isWac)
|
||
$effect(() => {
|
||
!hasPreprocessor && (selectedTab = 'main')
|
||
})
|
||
// `main` and `preprocessor` describe the same args under different schemas; every other tab
|
||
// (`diagram`) runs against main's schema, so it collapses into `main` here.
|
||
let lastSchemaTab = untrack(() => (selectedTab === 'preprocessor' ? 'preprocessor' : 'main'))
|
||
$effect(() => {
|
||
// Only depend on selectedTab (preprocessor ↔ main toggle).
|
||
// Code changes are handled by the editor on:change handler and
|
||
// explicit inferSchema calls (initContent, onMount), so we read
|
||
// `code` inside untrack to avoid a redundant double-inference race.
|
||
selectedTab &&
|
||
untrack(() => {
|
||
const schemaTab = selectedTab === 'preprocessor' ? 'preprocessor' : 'main'
|
||
const switched = schemaTab !== lastSchemaTab
|
||
lastSchemaTab = schemaTab
|
||
if (!code) return
|
||
// Bump on the switch itself, not on the inference it starts: the other tab's schema
|
||
// only lands once that resolves, and remounting the arg views then would discard
|
||
// anything typed while it was in flight. An untouched editor follows the schema in.
|
||
if (switched) {
|
||
argsRender++
|
||
}
|
||
inferSchema(code)
|
||
})
|
||
})
|
||
|
||
export async function updateArgs(newArgs: Record<string, any>) {
|
||
if (Object.keys(newArgs).length > 0) {
|
||
args = { ...newArgs }
|
||
argsRender++
|
||
}
|
||
}
|
||
|
||
setContext('disableTooltips', untrack(() => customUi)?.disableTooltips === true)
|
||
|
||
// Pixel minimum size for the test pane while open. The pane's `minSize`
|
||
// is a percentage of the *split axis* (width for `right` layout, height
|
||
// for `bottom`), so we derive it from whichever container dimension the
|
||
// splitter actually distributes. Without this layout switch, the bottom
|
||
// layout would translate a 400px width-fraction into a height-fraction,
|
||
// which on wide screens is far too tall and on tall narrow screens far
|
||
// too short.
|
||
let splitContainerWidth = $state(0)
|
||
let splitContainerHeight = $state(0)
|
||
const testPaneMinPx = $derived(previewLayout === 'bottom' ? 220 : 400)
|
||
const splitAxisExtent = $derived(
|
||
previewLayout === 'bottom' ? splitContainerHeight : splitContainerWidth
|
||
)
|
||
const testPaneMinPercent = $derived(paneMinPercent(splitAxisExtent, testPaneMinPx))
|
||
|
||
// Raw user-controlled test size (what the splitter wrote, or what the
|
||
// toggle set). The size we actually pass to <Pane> is clamped to the
|
||
// dynamic minimum below — so when the editor shrinks, the displayed test
|
||
// pane grows to honor the new minimum without needing an effect. The code
|
||
// pane's size is purely derived from it (100 - test).
|
||
// `testPanelCollapsed` seeds the raw value at 0 (collapsed) while
|
||
// keeping the "remembered" size at 30, so the user's first toggle expands
|
||
// the pane to a sensible width rather than 0.
|
||
let rawTestPanelSize = $state(untrack(() => (testPanelCollapsed ? 0 : 30)))
|
||
let storedTestPanelSize = 30
|
||
const testPanelSize = $derived(
|
||
rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent)
|
||
)
|
||
const codePanelSize = $derived(100 - testPanelSize)
|
||
|
||
function expandTestPanel() {
|
||
// Restore the remembered *intent* only. `testPanelSize` clamps up to the
|
||
// dynamic pixel-min reactively, so we must NOT bake `testPaneMinPercent`
|
||
// into the raw size here: when the container is still narrow (e.g. the
|
||
// frame the session preview enters full screen, before the pane widens),
|
||
// that min is a huge fraction and would stick as an oversized pane.
|
||
rawTestPanelSize = storedTestPanelSize
|
||
}
|
||
|
||
function collapseTestPanel() {
|
||
// Store the raw (unclamped) preference so reopening on a wider screen
|
||
// restores the user's intent, not the pixel-min that inflated the pane.
|
||
storedTestPanelSize = rawTestPanelSize
|
||
rawTestPanelSize = 0
|
||
}
|
||
|
||
function toggleTestPanel() {
|
||
if (testPanelSize > 0) {
|
||
collapseTestPanel()
|
||
} else {
|
||
expandTestPanel()
|
||
}
|
||
}
|
||
|
||
// React to an external `testPanelCollapsed` change (e.g. the session preview
|
||
// entering/leaving full screen) without clobbering the user's own toggles:
|
||
// only act on a genuine transition, reading the live size untracked so a drag
|
||
// never re-runs this. The mount seed already matches `testPanelCollapsed`, so
|
||
// the initial run is a no-op.
|
||
$effect(() => {
|
||
const collapsed = testPanelCollapsed
|
||
untrack(() => {
|
||
if (collapsed && testPanelSize > 0) {
|
||
collapseTestPanel()
|
||
} else if (!collapsed && testPanelSize === 0) {
|
||
expandTestPanel()
|
||
}
|
||
})
|
||
})
|
||
|
||
// When the compact preview shows a SchemaForm above the logs
|
||
// (`argsAboveLogs`), give the preview pane extra height so the args
|
||
// form doesn't shrink the logs/result area. This is a deliberate
|
||
// $effect (not $derived): the schema loads async and the same
|
||
// code/test sizes are two-way bound to the splitpane drag handle, so
|
||
// the offset is folded into the user-resizable state once on the
|
||
// has-args transition — manual resizing still works afterwards.
|
||
let hasArgsAboveLogs = $derived(
|
||
customUi?.previewPanel?.argsAboveLogs === true &&
|
||
!!schema?.properties &&
|
||
Object.keys(schema.properties).length > 0
|
||
)
|
||
let argsHeightBonus = $state(0)
|
||
$effect(() => {
|
||
const want = hasArgsAboveLogs ? 18 : 0
|
||
untrack(() => {
|
||
const delta = want - argsHeightBonus
|
||
if (delta === 0) return
|
||
if (testPanelSize > 0) {
|
||
rawTestPanelSize += delta
|
||
} else {
|
||
// preview collapsed — bake the bonus into the size it
|
||
// restores to so it lands correctly on expand.
|
||
storedTestPanelSize += delta
|
||
}
|
||
argsHeightBonus = want
|
||
})
|
||
})
|
||
|
||
function getError(job: Job | undefined) {
|
||
if (job != undefined && job.type === 'CompletedJob' && !job.success) {
|
||
return getStringError(job.result)
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function showDiffMode() {
|
||
const model = editor?.getModel()
|
||
if (model == undefined) return
|
||
diffMode = true
|
||
diffEditor?.showWithModelAndOriginal(lastDeployedCode ?? '', model)
|
||
editor?.hide()
|
||
}
|
||
|
||
function hideDiffMode() {
|
||
diffMode = false
|
||
diffEditor?.hide()
|
||
editor?.show()
|
||
}
|
||
|
||
let error = $derived(getError(testJob))
|
||
|
||
$effect(() => {
|
||
;[
|
||
editor,
|
||
lastSavedCode,
|
||
lastDeployedCode,
|
||
diffMode,
|
||
workflowAsCodeAiContext,
|
||
args,
|
||
error,
|
||
lang,
|
||
path
|
||
]
|
||
untrack(() => {
|
||
const options: ScriptOptions = {
|
||
getCode: () => code,
|
||
lang: lang as ScriptLang,
|
||
error,
|
||
args: args ?? {},
|
||
path,
|
||
lastSavedCode,
|
||
lastDeployedCode,
|
||
diffMode,
|
||
workflowAsCode: workflowAsCodeAiContext
|
||
}
|
||
aiChatManager.scriptEditorOptions = options
|
||
aiChatManager.scriptEditorApplyCode = async (code: string, opts?: ReviewChangesOpts) => {
|
||
hideDiffMode()
|
||
await editor?.reviewAndApplyCode(code, opts)
|
||
}
|
||
aiChatManager.scriptEditorShowDiffMode = showDiffMode
|
||
aiChatManager.scriptEditorGetLintErrors = () => {
|
||
return (
|
||
editor?.getLintErrors() ?? { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
|
||
)
|
||
}
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<JobLoader
|
||
noCode={true}
|
||
workspaceOverride={opWs}
|
||
bind:scriptProgress
|
||
bind:this={jobLoader}
|
||
bind:isLoading={testIsLoading}
|
||
bind:job={testJob}
|
||
/>
|
||
|
||
<svelte:window onkeydown={onKeyDown} />
|
||
|
||
<!-- Standalone triggerable registration for the script editor -->
|
||
<div
|
||
style="display: none"
|
||
use:triggerableByAI={{
|
||
id: 'script-editor',
|
||
description: 'Component to edit a script'
|
||
}}
|
||
></div>
|
||
|
||
<Modal title="Invite others" bind:open={showCollabPopup}>
|
||
<div>Have others join by sharing the following url:</div>
|
||
<div class="flex gap-2 pr-4">
|
||
<input type="text" disabled value={collabUrl()} />
|
||
|
||
<Button
|
||
color="light"
|
||
startIcon={{ icon: Copy }}
|
||
iconOnly
|
||
on:click={() => copyToClipboard(collabUrl())}
|
||
/>
|
||
</div>
|
||
</Modal>
|
||
|
||
<div class="border-b shadow-sm px-1 pr-4" bind:clientWidth={width}>
|
||
<div class="flex justify-between space-x-2">
|
||
{#if args}
|
||
<EditorBar
|
||
workspace={opWs}
|
||
scriptPath={edit ? path : undefined}
|
||
on:toggleCollabMode={() => {
|
||
if (wsProvider?.shouldConnect) {
|
||
disableCollaboration()
|
||
} else {
|
||
setCollaborationMode()
|
||
}
|
||
}}
|
||
on:showDiffMode={showDiffMode}
|
||
on:hideDiffMode={hideDiffMode}
|
||
customUi={customUi?.editorBar}
|
||
collabLive={wsProvider?.shouldConnect}
|
||
{collabMode}
|
||
{validCode}
|
||
{validAssets}
|
||
iconOnly={width < EDITOR_BAR_WIDTH_THRESHOLD}
|
||
compactHelpers={width < EDITOR_BAR_HELPERS_COMPACT_THRESHOLD}
|
||
on:collabPopup={() => (showCollabPopup = true)}
|
||
{editor}
|
||
{lang}
|
||
on:createScriptFromInlineScript
|
||
{websocketAlive}
|
||
collabUsers={peers}
|
||
kind={asKind(kind)}
|
||
{template}
|
||
{args}
|
||
{noHistory}
|
||
{saveToWorkspace}
|
||
lastDeployedCode={lastDeployedCode && lastDeployedCode !== code
|
||
? lastDeployedCode
|
||
: undefined}
|
||
{diffMode}
|
||
bind:showHistoryDrawer
|
||
>
|
||
{#snippet right()}
|
||
{@render editorBarRight?.()}
|
||
{/snippet}
|
||
</EditorBar>
|
||
{/if}
|
||
{#if !noSyncFromGithub && customUi?.editorBar?.useVsCode != false && !inSessionPane}
|
||
<div class="py-1">
|
||
<Button
|
||
target="_blank"
|
||
href="https://www.windmill.dev/docs/cli_local_dev/vscode-extension"
|
||
variant="subtle"
|
||
unifiedSize="md"
|
||
iconOnly={width < EDITOR_BAR_WIDTH_THRESHOLD}
|
||
title="Use VS Code"
|
||
startIcon={{
|
||
icon: Github
|
||
}}
|
||
>
|
||
VScode
|
||
</Button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<SplitPanesWrapper>
|
||
<div
|
||
bind:clientWidth={splitContainerWidth}
|
||
bind:clientHeight={splitContainerHeight}
|
||
class="h-full"
|
||
>
|
||
<Splitpanes class="!overflow-visible" horizontal={previewLayout === 'bottom'}>
|
||
<Pane size={codePanelSize} minSize={0} class="!overflow-visible">
|
||
{#if lang === 'ansible' && ansibleAlternativeExecutionMode != null}
|
||
<!-- Vertical split for ansible with assets -->
|
||
<Splitpanes horizontal class="!overflow-visible h-full">
|
||
<Pane size={60} minSize={30} class="!overflow-visible">
|
||
{@render editorContent()}
|
||
</Pane>
|
||
<Pane size={40} minSize={20} class="!overflow-visible">
|
||
<div
|
||
class="h-full flex flex-col bg-surface border-l border-gray-200 dark:border-gray-700"
|
||
>
|
||
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
|
||
<h4 class="text-sm font-semibold text-primary">File Browser</h4>
|
||
</div>
|
||
<GitRepoViewer
|
||
workspace={opWs}
|
||
gitRepoResourcePath={ansibleAlternativeExecutionMode?.resource || ''}
|
||
gitSshIdentity={ansibleGitSshIdentity}
|
||
bind:commitHashInput={commitHashForGitRepo}
|
||
/>
|
||
</div>
|
||
</Pane>
|
||
</Splitpanes>
|
||
{:else}
|
||
<!-- Original single editor layout -->
|
||
{@render editorContent()}
|
||
{/if}
|
||
</Pane>
|
||
<Pane
|
||
bind:size={() => testPanelSize, (v) => (rawTestPanelSize = v)}
|
||
minSize={testPaneMinPercent}
|
||
class={customUi?.previewPanel?.hideArgs ? '!overflow-visible' : ''}
|
||
>
|
||
<div
|
||
data-test-panel
|
||
class={customUi?.previewPanel?.hideArgs
|
||
? 'flex flex-col h-full !overflow-visible'
|
||
: 'flex flex-col h-full'}
|
||
>
|
||
{#if showTabs}
|
||
<div transition:slide={{ duration: 200 }}>
|
||
<Tabs bind:selected={selectedTab}>
|
||
<Tab value="main" label="Main" />
|
||
{#if hasPreprocessor}
|
||
<div transition:slide={{ duration: 200, axis: 'x' }}>
|
||
<Tab value="preprocessor" label="Preprocessor" />
|
||
</div>
|
||
{/if}
|
||
{#if isWac}
|
||
<div transition:slide={{ duration: 200, axis: 'x' }}>
|
||
<Tab value="diagram" label="Diagram" />
|
||
</div>
|
||
{/if}
|
||
</Tabs>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if debugMode && isDebuggableScript}
|
||
<div transition:slide={{ duration: 200 }}>
|
||
<DebugToolbar
|
||
connected={$debugState.connected}
|
||
running={$debugState.running}
|
||
stopped={$debugState.stopped}
|
||
breakpointCount={debugBreakpoints.size}
|
||
onStart={startDebugging}
|
||
onStop={stopDebugging}
|
||
onContinue={continueExecution}
|
||
onStepOver={stepOver}
|
||
onStepIn={stepIn}
|
||
onStepOut={stepOut}
|
||
onClearBreakpoints={clearAllBreakpoints}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if selectedTab === 'diagram'}
|
||
<div class="flex-1 min-h-0">
|
||
<WacDiagram {code} language={lang ?? ''} />
|
||
</div>
|
||
{:else}
|
||
{#if previewLayout !== 'bottom'}
|
||
<!-- min-h keeps room for the absolute controls when debug mode hides the run buttons -->
|
||
<div class="flex justify-center pt-1 relative min-h-9">
|
||
<div class="absolute top-2 left-2">
|
||
<HideButton
|
||
hidden={false}
|
||
direction="right"
|
||
panelName="Test"
|
||
shortcut="U"
|
||
size="md"
|
||
on:click={() => {
|
||
toggleTestPanel()
|
||
}}
|
||
/>
|
||
</div>
|
||
{#if !(debugMode && isDebuggableScript)}
|
||
<div class="flex flex-row gap-2">
|
||
<div
|
||
class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch"
|
||
>
|
||
{#if testIsLoading}
|
||
{@render cancelTestButton('md', 'w-full')}
|
||
{:else}
|
||
{@const disableTriggerButton =
|
||
customUi?.previewPanel?.disableTriggerButton === true}
|
||
{@render runTestButton(
|
||
'md',
|
||
`w-full ${!disableTriggerButton ? 'rounded-r-none' : ''}`
|
||
)}
|
||
{#if !disableTriggerButton}
|
||
<CaptureButton on:openTriggers />
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
{#if lastRecordingJobId}
|
||
<Button
|
||
on:click={downloadRecording}
|
||
unifiedSize="md"
|
||
startIcon={{ icon: Download }}
|
||
iconOnly
|
||
loading={downloadingRecording}
|
||
title="Download recording"
|
||
/>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
<div class="absolute top-2 right-2 flex items-center gap-2">
|
||
{#if customUi?.previewPanel?.disableJsonView !== true}
|
||
<Toggle size="2xs" bind:checked={jsonView} options={{ right: 'JSON' }} />
|
||
{/if}
|
||
<DropdownV2
|
||
size="xs"
|
||
items={[
|
||
{
|
||
displayName: 'Test & record',
|
||
icon: Disc,
|
||
action: () => recordAndTest()
|
||
}
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{#if customUi?.previewPanel?.hideArgs}
|
||
<!-- Compact preview layout used by the pipeline editor: no args
|
||
column (most pipeline scripts take no inputs) and a full-width
|
||
LogPanel. The controls sit in normal flow rather than floating
|
||
onto the editor band, which Firefox clipped (transform +
|
||
overflow: visible on the parent splitpane Pane). -->
|
||
<div class="h-full flex flex-col">
|
||
<!-- Test sits at the top right, the last run's link under it. -->
|
||
<div class="shrink-0 flex flex-col items-end gap-1 px-2 pt-2 pb-1">
|
||
<div>
|
||
{#if testIsLoading}
|
||
{@render cancelTestButton('sm', 'shadow-md')}
|
||
{:else if (customUi?.previewPanel?.downstreamSubscribers ?? 0) > 0 || customUi?.previewPanel?.onBoundedRun}
|
||
<!-- Split button: primary "Test" runs just this step
|
||
(skips the asset-trigger cascade); the caret
|
||
opens a popover with the cascade option labelled
|
||
by the downstream count. The active mode is
|
||
reflected in both the button label and the
|
||
check-mark on the menu item so the user always
|
||
knows whether the next run will fan out. A
|
||
pure-reader-only root has no subscriber downstream
|
||
but still gets `onBoundedRun`, so the split button
|
||
also opens for it (with the cascade item hidden). -->
|
||
{@const downstream = customUi?.previewPanel?.downstreamSubscribers ?? 0}
|
||
<div class="flex items-stretch shadow-md rounded-md overflow-hidden">
|
||
<Button
|
||
on:click={() => runTest()}
|
||
unifiedSize="sm"
|
||
btnClasses="rounded-r-none"
|
||
variant="accent-secondary"
|
||
startIcon={{
|
||
icon: cascadeDownstream ? Zap : Play,
|
||
classes: 'animate-none'
|
||
}}
|
||
shortCut={{ Icon: CornerDownLeft }}
|
||
>
|
||
{cascadeDownstream ? `Test + trigger ${downstream}` : 'Test'}
|
||
</Button>
|
||
<Popover
|
||
placement="bottom-end"
|
||
bind:isOpen={cascadeMenuOpen}
|
||
usePointerDownOutside
|
||
contentClasses="p-0"
|
||
>
|
||
{#snippet trigger()}
|
||
<!-- min-h-7 matches Button's unifiedSize="sm" (28px);
|
||
the bg/text/hover classes mirror Button's
|
||
`variant="accent-secondary"` so the split reads as
|
||
one connected control instead of two mismatched
|
||
buttons. The left seam uses a translucent
|
||
foreground-tinted border so it stays visible
|
||
against both the base and hover bg without
|
||
fighting the variant's color. -->
|
||
<button
|
||
type="button"
|
||
class="self-stretch min-h-7 px-1.5 flex items-center justify-center bg-surface-accent-secondary hover:bg-surface-accent-secondary-hover focus-visible:bg-surface-accent-secondary-clicked text-white dark:text-deep-blue-900 border-l border-white/30 dark:border-deep-blue-900/30 transition-colors"
|
||
title="Run options"
|
||
aria-label="Run options"
|
||
>
|
||
<ChevronDown size={14} />
|
||
</button>
|
||
{/snippet}
|
||
{#snippet content({ close })}
|
||
<div class="w-72 py-1 text-xs">
|
||
<button
|
||
type="button"
|
||
class="w-full text-left px-3 py-2 hover:bg-surface-hover flex items-start gap-2"
|
||
onclick={() => {
|
||
cascadeDownstream = false
|
||
close()
|
||
void runTest()
|
||
}}
|
||
>
|
||
<Play
|
||
size={14}
|
||
class="mt-0.5 shrink-0 text-emerald-700 dark:text-emerald-400"
|
||
/>
|
||
<div class="flex flex-col min-w-0">
|
||
<span class="font-medium">
|
||
Test {!cascadeDownstream ? '(current)' : ''}
|
||
</span>
|
||
<span class="text-2xs text-secondary"
|
||
>Run just this step. Downstream subscribers are not triggered.</span
|
||
>
|
||
</div>
|
||
</button>
|
||
{#if downstream > 0}
|
||
<button
|
||
type="button"
|
||
class="w-full text-left px-3 py-2 hover:bg-surface-hover flex items-start gap-2"
|
||
onclick={() => {
|
||
cascadeDownstream = true
|
||
close()
|
||
void runTest()
|
||
}}
|
||
>
|
||
<Zap
|
||
size={14}
|
||
class="mt-0.5 shrink-0 text-amber-600 dark:text-amber-400"
|
||
/>
|
||
<div class="flex flex-col min-w-0">
|
||
<span class="font-medium">
|
||
Test + trigger {downstream} downstream {cascadeDownstream
|
||
? '(current)'
|
||
: ''}
|
||
</span>
|
||
<span class="text-2xs text-secondary">
|
||
Let the asset-trigger cascade fan out to the {downstream}
|
||
subscribed script{downstream === 1 ? '' : 's'} after this run succeeds.
|
||
</span>
|
||
</div>
|
||
</button>
|
||
{/if}
|
||
{#if customUi?.previewPanel?.onBoundedRun}
|
||
<button
|
||
type="button"
|
||
class="w-full text-left px-3 py-2 hover:bg-surface-hover flex items-start gap-2 border-t"
|
||
onclick={() => {
|
||
close()
|
||
customUi!.previewPanel!.onBoundedRun!()
|
||
}}
|
||
>
|
||
<Target
|
||
size={14}
|
||
class="mt-0.5 shrink-0 text-blue-600 dark:text-blue-400"
|
||
/>
|
||
<div class="flex flex-col min-w-0">
|
||
<span class="font-medium">Run downstream up to…</span>
|
||
<span class="text-2xs text-secondary">
|
||
Pick end node(s) on the graph, then run only the cascade between
|
||
this script and them.
|
||
</span>
|
||
</div>
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/snippet}
|
||
</Popover>
|
||
</div>
|
||
{:else}
|
||
{@render runTestButton('sm', 'shadow-md')}
|
||
{/if}
|
||
</div>
|
||
{#if testJob?.id && testJob.type === 'CompletedJob' && opWs}
|
||
<!-- Right-side affordances when we're displaying a *completed*
|
||
job (either the user just ran a test, or the on-mount
|
||
last-run loader populated the panel). The job-id link
|
||
opens the full run page in a new tab; the dispatch
|
||
button mounts a popover that shows what downstream
|
||
jobs this run triggered (rendered only when the run
|
||
actually dispatched anything). -->
|
||
<div class="flex items-center gap-2">
|
||
<a
|
||
class="text-3xs text-blue-600 hover:underline font-mono"
|
||
href={`${base}/run/${testJob.id}?workspace=${opWs}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
title="Open this run"
|
||
>
|
||
{testJob.id.slice(0, 8)}… ↗
|
||
</a>
|
||
<DispatchEventsButton
|
||
workspace={testJob.workspace_id ?? opWs}
|
||
jobId={testJob.id}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{#if customUi?.previewPanel?.argsAboveLogs && schema?.properties && Object.keys(schema.properties).length > 0}
|
||
<div
|
||
class="shrink-0 overflow-auto max-h-[45%] border-b border-gray-200 dark:border-gray-700 px-4 py-3"
|
||
>
|
||
{#key argsRender}
|
||
<SchemaForm
|
||
workspace={opWs}
|
||
helperScript={{
|
||
source: 'inline',
|
||
code,
|
||
//@ts-ignore
|
||
lang
|
||
}}
|
||
compact
|
||
{schema}
|
||
bind:args
|
||
bind:isValid
|
||
noVariablePicker={customUi?.previewPanel?.disableVariablePicker === true}
|
||
showSchemaExplorer
|
||
/>
|
||
{/key}
|
||
</div>
|
||
{/if}
|
||
<div class="grow min-h-0">
|
||
{@render testLogPanel()}
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
{#key previewLayout}
|
||
<!-- min-h-0 lets this shrink to the space the header row leaves it. Without it the
|
||
100% height wins, the panes settle one header too tall, and any reflow during a
|
||
run snaps them up and back. -->
|
||
<Splitpanes horizontal={previewLayout !== 'bottom'} class="min-h-0">
|
||
<Pane size={previewLayout === 'bottom' ? 40 : 33}>
|
||
{#if previewLayout === 'bottom' && !(debugMode && isDebuggableScript)}
|
||
<div class="px-3 pt-2 pb-1 flex items-center gap-2">
|
||
{#if testIsLoading}
|
||
{@render cancelTestButton('sm', 'w-full')}
|
||
{:else}
|
||
{@render runTestButton('sm', 'w-full')}
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{#if jsonView}
|
||
<div
|
||
class="py-2"
|
||
style="height: {!schemaHeight || schemaHeight < 600 ? 600 : schemaHeight}px"
|
||
data-schema-picker
|
||
>
|
||
{#key argsRender}
|
||
<JsonInputs
|
||
on:select={(e) => {
|
||
if (e.detail) {
|
||
if (onModuleArgs) {
|
||
testPanelArgs = e.detail
|
||
} else {
|
||
args = e.detail
|
||
}
|
||
}
|
||
}}
|
||
initialCode={onModuleArgs
|
||
? argsToJsonPayload(testPanelSchema, testPanelArgs)
|
||
: argsToJsonPayload(schema, args)}
|
||
updateOnBlur={false}
|
||
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/> "foo": "12"<br/>}`}
|
||
/>
|
||
{/key}
|
||
</div>
|
||
{:else}
|
||
<div class="px-4">
|
||
<div
|
||
class="break-words relative font-sans"
|
||
bind:clientHeight={schemaHeight}
|
||
>
|
||
{#key argsRender}
|
||
{#if onModuleArgs}
|
||
<SchemaForm
|
||
workspace={opWs}
|
||
helperScript={{
|
||
source: 'inline',
|
||
code: editorCode,
|
||
//@ts-ignore
|
||
lang: effectiveLang
|
||
}}
|
||
compact
|
||
schema={testPanelSchema}
|
||
bind:args={testPanelArgs}
|
||
bind:isValid
|
||
noVariablePicker={customUi?.previewPanel?.disableVariablePicker ===
|
||
true}
|
||
showSchemaExplorer
|
||
/>
|
||
{:else}
|
||
<SchemaForm
|
||
workspace={opWs}
|
||
helperScript={{
|
||
source: 'inline',
|
||
code,
|
||
//@ts-ignore
|
||
lang
|
||
}}
|
||
compact
|
||
{schema}
|
||
bind:args
|
||
bind:isValid
|
||
noVariablePicker={customUi?.previewPanel?.disableVariablePicker ===
|
||
true}
|
||
showSchemaExplorer
|
||
/>
|
||
{/if}
|
||
{/key}
|
||
{#if showPsCommonParams}
|
||
<div class="mt-2">
|
||
<PowerShellCommonParams bind:args={psCommonParams} />
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</Pane>
|
||
<Pane size={previewLayout === 'bottom' ? 60 : 67} class="relative">
|
||
{@render testLogPanel()}
|
||
</Pane>
|
||
</Splitpanes>
|
||
{/key}
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
</Pane>
|
||
</Splitpanes>
|
||
</div>
|
||
</SplitPanesWrapper>
|
||
|
||
{#snippet cancelTestButton(size: 'sm' | 'md', btnClasses: string)}
|
||
<Button on:click={() => jobLoader?.cancelJob()} unifiedSize={size} {btnClasses}>
|
||
<WindmillIcon
|
||
white={true}
|
||
class="mr-2 text-white"
|
||
height={size === 'md' ? '16px' : '14px'}
|
||
width={size === 'md' ? '20px' : '16px'}
|
||
spin="fast"
|
||
/>
|
||
Cancel
|
||
</Button>
|
||
{/snippet}
|
||
|
||
{#snippet runTestButton(size: 'sm' | 'md', btnClasses: string)}
|
||
<Button
|
||
on:click={() => runTest()}
|
||
unifiedSize={size}
|
||
{btnClasses}
|
||
variant="accent-secondary"
|
||
startIcon={{ icon: Play, classes: 'animate-none' }}
|
||
shortCut={{ Icon: CornerDownLeft }}
|
||
>
|
||
<!-- Named, because a run that silently narrowed to whichever file happens to
|
||
be open is the kind of surprise a warehouse bill discovers. -->
|
||
Test
|
||
</Button>
|
||
{/snippet}
|
||
|
||
<!-- Single source of truth for the preview LogPanel — rendered by both the
|
||
compact (hideArgs) layout and the splitpane layouts. One invocation
|
||
prevents prop drift between copies (the history tab's lazy-load via
|
||
onTabChange was lost in one copy when they diverged). -->
|
||
{#snippet testLogPanel()}
|
||
<LogPanel
|
||
bind:this={logPanel}
|
||
workspace={opWs}
|
||
{lang}
|
||
previewJob={debugMode
|
||
? ({
|
||
id: 'debug',
|
||
logs: $debugState.logs,
|
||
result: $debugState.result,
|
||
success: !$debugState.error,
|
||
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
|
||
} as any)
|
||
: testJob}
|
||
{pastPreviews}
|
||
onTabChange={(tab) => {
|
||
historyTabActive = tab === 'history'
|
||
if (historyTabActive) {
|
||
loadPastTests()
|
||
}
|
||
}}
|
||
previewIsLoading={debugMode ? $debugState.running && !$debugState.stopped : testIsLoading}
|
||
{editor}
|
||
{diffEditor}
|
||
args={onModuleArgs ? testPanelArgs : args}
|
||
{showCaptures}
|
||
customUi={customUi?.previewPanel}
|
||
showCustomResultPanel={showDebugPanel}
|
||
>
|
||
{#if scriptProgress && !debugMode}
|
||
<!-- Put to the slot in logpanel -->
|
||
<JobProgressBar job={testJob} {scriptProgress} bind:this={jobProgressBar} compact={true} />
|
||
{/if}
|
||
{#snippet capturesTab()}
|
||
<div class="h-full p-2">
|
||
<CaptureTable
|
||
bind:this={captureTable}
|
||
workspace={opWs}
|
||
{hasPreprocessor}
|
||
canHavePreprocessor={canHavePreprocessor(lang)}
|
||
isFlow={false}
|
||
path={stablePathForCaptures}
|
||
canEdit={true}
|
||
on:applyArgs
|
||
on:updateSchema
|
||
on:addPreprocessor
|
||
/>
|
||
</div>
|
||
{/snippet}
|
||
{#snippet customResultPanel()}
|
||
<DebugPanel
|
||
stackFrames={$debugState.stackFrames}
|
||
scopes={$debugState.scopes}
|
||
variables={$debugState.variables}
|
||
client={dapClient}
|
||
bind:selectedFrameId={selectedDebugFrameId}
|
||
/>
|
||
{/snippet}
|
||
</LogPanel>
|
||
{/snippet}
|
||
|
||
{#snippet addModuleForm(close: () => void)}
|
||
<div class="flex flex-col gap-2">
|
||
<label for="module-name-input" class="text-xs font-semibold text-emphasis">File name</label>
|
||
<input
|
||
id="module-name-input"
|
||
type="text"
|
||
class="border rounded px-2 py-1.5 text-sm bg-surface"
|
||
bind:this={modulePathInputEl}
|
||
bind:value={modulePathInput}
|
||
placeholder={'helper' + (allowedModuleExtensions[0] ?? '.ts')}
|
||
oninput={() => {
|
||
modulePathError = validateModulePath(modulePathInput)
|
||
}}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'Enter') addModule()
|
||
if (e.key === 'Escape') close()
|
||
}}
|
||
/>
|
||
{#if modulePathError}
|
||
<p class="text-red-500 text-2xs">{modulePathError}</p>
|
||
{/if}
|
||
<p class="text-tertiary text-2xs"
|
||
>Supports subfolders, e.g. <code class="text-2xs"
|
||
>utils/math{allowedModuleExtensions[0] ?? '.ts'}</code
|
||
></p
|
||
>
|
||
<div class="flex justify-end gap-2">
|
||
<Button
|
||
variant="default"
|
||
size="xs"
|
||
onclick={() => {
|
||
modulePathInput = ''
|
||
modulePathError = ''
|
||
close()
|
||
}}>Cancel</Button
|
||
>
|
||
<Button
|
||
variant="accent"
|
||
size="xs"
|
||
onclick={addModule}
|
||
disabled={!modulePathInput.trim() || !!modulePathError}>Add</Button
|
||
>
|
||
</div>
|
||
</div>
|
||
{/snippet}
|
||
|
||
{#snippet renameModuleForm(oldPath: string, close: () => void)}
|
||
<div class="flex flex-col gap-2">
|
||
<label for="rename-module-input" class="text-xs font-semibold text-emphasis"
|
||
>Rename module</label
|
||
>
|
||
<input
|
||
id="rename-module-input"
|
||
type="text"
|
||
class="border rounded px-2 py-1.5 text-sm bg-surface"
|
||
bind:this={renameModuleInputEl}
|
||
bind:value={renameModuleInput}
|
||
oninput={() => {
|
||
renameModuleError = validateRenameModulePath(renameModuleInput, oldPath)
|
||
}}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
renameModule(oldPath)
|
||
close()
|
||
}
|
||
if (e.key === 'Escape') close()
|
||
}}
|
||
/>
|
||
{#if renameModuleError}
|
||
<p class="text-red-500 text-2xs">{renameModuleError}</p>
|
||
{/if}
|
||
<div class="flex justify-end gap-2">
|
||
<Button
|
||
variant="default"
|
||
size="xs"
|
||
onclick={() => {
|
||
renameModuleInput = ''
|
||
renameModuleError = ''
|
||
close()
|
||
}}>Cancel</Button
|
||
>
|
||
<Button
|
||
variant="accent"
|
||
size="xs"
|
||
onclick={() => {
|
||
renameModule(oldPath)
|
||
close()
|
||
}}
|
||
disabled={!renameModuleInput.trim() ||
|
||
renameIsNoop(renameModuleInput, oldPath) ||
|
||
!!renameModuleError}>Rename</Button
|
||
>
|
||
</div>
|
||
</div>
|
||
{/snippet}
|
||
|
||
{#snippet editorContent()}
|
||
<div class="h-full !overflow-visible bg-surface dark:bg-surface-secondary relative flex flex-col">
|
||
{#if supportsModules}
|
||
<DraggableTabs
|
||
tabs={fileTabs}
|
||
activeId={activeModuleTab ?? MAIN_FILE_TAB_ID}
|
||
onSelect={(id) => (id === MAIN_FILE_TAB_ID ? switchToMain() : switchToModule(id))}
|
||
onReorder={(next) => (moduleTabOrder = next.map((t) => t.id))}
|
||
class="shrink-0 border-b border-light bg-surface-secondary/50"
|
||
>
|
||
{#snippet tabAccessory(tab)}
|
||
{#if tab.id !== MAIN_FILE_TAB_ID}
|
||
<!-- Stops the trigger's click, mouse or Enter/Space alike, from also selecting the tab. -->
|
||
<span role="presentation" class="inline-flex" onclick={(e) => e.stopPropagation()}>
|
||
<Popover
|
||
placement="bottom-start"
|
||
openFocus={renameModuleInputEl}
|
||
contentClasses="p-3 w-72"
|
||
class="inline-flex"
|
||
triggerAttrs={{
|
||
'aria-label': `Rename ${tab.id}`,
|
||
onpointerdown: () => prefillRename(tab.id),
|
||
onkeydown: (e: KeyboardEvent) =>
|
||
(e.key === 'Enter' || e.key === ' ') && prefillRename(tab.id)
|
||
}}
|
||
>
|
||
{#snippet trigger()}
|
||
<span
|
||
class="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 rounded hover:bg-surface-hover w-4 h-4 inline-flex items-center justify-center"
|
||
>
|
||
<Pencil size={10} />
|
||
</span>
|
||
{/snippet}
|
||
{#snippet content({ close })}
|
||
{@render renameModuleForm(tab.id, close)}
|
||
{/snippet}
|
||
</Popover>
|
||
</span>
|
||
<Button
|
||
variant="subtle"
|
||
unifiedSize="2xs"
|
||
iconOnly
|
||
startIcon={{ icon: X }}
|
||
wrapperClasses="opacity-0 group-hover:opacity-100 focus-within:opacity-100"
|
||
aria-label={`Delete ${tab.id}`}
|
||
title={`Delete ${tab.id}`}
|
||
onClick={() => removeModule(tab.id)}
|
||
/>
|
||
{/if}
|
||
{/snippet}
|
||
{#snippet afterTabs()}
|
||
<Popover
|
||
placement="bottom-start"
|
||
bind:isOpen={showAddModulePopover}
|
||
openFocus={modulePathInputEl}
|
||
contentClasses="p-3 w-72"
|
||
class="ml-0.5 h-6 w-6 rounded-md inline-flex items-center justify-center text-hint hover:text-secondary hover:bg-surface-hover focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected"
|
||
triggerAttrs={{ 'aria-label': 'Add module' }}
|
||
>
|
||
{#snippet trigger()}
|
||
<Plus size={12} />
|
||
{/snippet}
|
||
{#snippet content({ close })}
|
||
{@render addModuleForm(close)}
|
||
{/snippet}
|
||
</Popover>
|
||
{/snippet}
|
||
</DraggableTabs>
|
||
{/if}
|
||
<div class="relative flex-1 min-h-0 min-w-0 !overflow-visible">
|
||
<div class="absolute bg-surface top-2 right-4 z-10 flex flex-row gap-2">
|
||
{#if assets?.length}
|
||
<AssetsDropdownButton {assets} />
|
||
{/if}
|
||
|
||
{#if isDebuggableScript && customUi?.editorBar?.debug != false}
|
||
<Button
|
||
variant={debugMode ? 'accent' : 'default'}
|
||
unifiedSize="sm"
|
||
onclick={toggleDebugMode}
|
||
startIcon={{ icon: Bug }}
|
||
iconOnly
|
||
btnClasses={debugMode
|
||
? ''
|
||
: 'bg-surface hover:bg-surface-hover border border-tertiary/30'}
|
||
title={debugMode ? 'Exit Debug Mode' : 'Toggle Debug Mode'}
|
||
/>
|
||
{/if}
|
||
{#if showDebugPanel && !showDebugConsole}
|
||
<Button
|
||
variant="default"
|
||
unifiedSize="sm"
|
||
onclick={() => (showDebugConsole = true)}
|
||
startIcon={{ icon: Terminal }}
|
||
title="Show Debug Console"
|
||
>
|
||
Console
|
||
</Button>
|
||
{/if}
|
||
{#if lang === 'ansible' && hasDelegateToGitRepo}
|
||
<Button
|
||
variant="default"
|
||
unifiedSize="sm"
|
||
onclick={() => (gitRepoResourcePickerOpen = true)}
|
||
startIcon={{ icon: GitBranch }}
|
||
>
|
||
Delegating to git repo
|
||
</Button>
|
||
{/if}
|
||
{#if testPanelSize === 0}
|
||
<HideButton
|
||
hidden={true}
|
||
direction="right"
|
||
unifiedSize="sm"
|
||
variant="accent-secondary"
|
||
panelName="test"
|
||
shortcut="U"
|
||
customHiddenIcon={{
|
||
icon: PlayIcon
|
||
}}
|
||
on:click={() => {
|
||
toggleTestPanel()
|
||
}}
|
||
/>
|
||
{/if}
|
||
{#if !aiChatManager.open && !disableAi && !inSessionPane}
|
||
{#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')}
|
||
<OpenInSessionButton source={sessionOpen}>
|
||
{#snippet fallback()}
|
||
<HideButton
|
||
hidden={true}
|
||
direction="right"
|
||
panelName="AI"
|
||
shortcut="L"
|
||
unifiedSize="sm"
|
||
usePopoverOverride={!$copilotInfo.enabled}
|
||
customHiddenIcon={{
|
||
icon: WandSparkles
|
||
}}
|
||
btnClasses="!text-ai"
|
||
variant="default"
|
||
on:click={() => {
|
||
if (!aiChatManager.open) {
|
||
aiChatManager.changeMode(AIMode.SCRIPT)
|
||
}
|
||
aiChatManager.toggleOpen()
|
||
}}
|
||
>
|
||
{#snippet popoverOverride()}
|
||
<div class="text-sm">
|
||
Enable Windmill AI in the <a
|
||
href="{base}/workspace_settings?tab=ai"
|
||
target="_blank"
|
||
class="inline-flex flex-row items-center gap-1"
|
||
>
|
||
workspace settings <ExternalLink size={16} />
|
||
</a>
|
||
</div>
|
||
{/snippet}
|
||
</HideButton>
|
||
{/snippet}
|
||
</OpenInSessionButton>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
|
||
{#if debugConsoleVisible}
|
||
<!-- Use Splitpanes when debug console is visible for resizing -->
|
||
<Splitpanes horizontal class="h-full !overflow-visible">
|
||
<Pane bind:size={editorPaneSize} minSize={20} class="!overflow-visible">
|
||
{@render editorPane()}
|
||
</Pane>
|
||
<Pane bind:size={consolePaneSize} minSize={10}>
|
||
<DebugConsole
|
||
client={dapClient}
|
||
currentFrameId={currentDebugFrameId}
|
||
onClose={() => (showDebugConsole = false)}
|
||
workspace={opWs}
|
||
jobId={debugSessionJobId ?? undefined}
|
||
/>
|
||
</Pane>
|
||
</Splitpanes>
|
||
{:else}
|
||
<!-- Normal editor without console -->
|
||
<div class="h-full !overflow-visible">
|
||
{@render editorPane()}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/snippet}
|
||
|
||
{#snippet editorPane()}
|
||
{#key effectiveLang}
|
||
<Editor
|
||
lineNumbersMinChars={4}
|
||
folding
|
||
{path}
|
||
bind:code={editorCode}
|
||
bind:websocketAlive
|
||
bind:this={editor}
|
||
schemaContractMarkers={contractMarkers}
|
||
{yContent}
|
||
awareness={wsProvider?.awareness}
|
||
on:change={(e) => {
|
||
if (activeModuleTab === null) {
|
||
// `editorCode`, not the payload: `setCode` dispatches the string it was
|
||
// handed, but Monaco may have normalized it (EOL) while applying it, and
|
||
// the re-entrant `updateCode` that runs inside `setCode` has already put
|
||
// that normalized text here. Taking the payload would leave `code`
|
||
// disagreeing with the buffer.
|
||
code = editorCode
|
||
lastSyncedCode = code
|
||
inferSchema(e.detail)
|
||
} else {
|
||
flushModuleContent()
|
||
inferModuleSchema()
|
||
}
|
||
// Refresh breakpoint positions when code changes (decorations track their lines)
|
||
if (debugMode && breakpointDecorations.length > 0) {
|
||
refreshBreakpointPositions()
|
||
}
|
||
}}
|
||
on:saveDraft
|
||
on:toggleTestPanel={toggleTestPanel}
|
||
cmdEnterAction={async () => {
|
||
if (activeModuleTab === null) {
|
||
await inferSchema(editorCode)
|
||
} else {
|
||
await inferModuleSchema()
|
||
}
|
||
// The Editor already ran the DDL guard before invoking this action.
|
||
runTest({ skipDdlGuard: true })
|
||
}}
|
||
formatAction={async () => {
|
||
if (activeModuleTab === null) {
|
||
await inferSchema(editorCode)
|
||
}
|
||
dispatch('format')
|
||
}}
|
||
class="flex flex-1 h-full !overflow-visible"
|
||
scriptLang={effectiveLang}
|
||
automaticLayout={true}
|
||
{fixedOverflowWidgets}
|
||
{args}
|
||
workflowAsCode={workflowAsCodeAiContext}
|
||
{enablePreprocessorSnippet}
|
||
preparedAssetsSqlQueries={preparedSqlQueries.current}
|
||
customTag={tag}
|
||
/>
|
||
<DiffEditor
|
||
className="h-full"
|
||
bind:this={diffEditor}
|
||
modifiedModel={editor?.getModel() as meditor.ITextModel}
|
||
automaticLayout
|
||
defaultLang={scriptLangToEditorLang(lang)}
|
||
{fixedOverflowWidgets}
|
||
buttons={diffMode
|
||
? [
|
||
{
|
||
text: 'See changes history',
|
||
onClick: () => {
|
||
showHistoryDrawer = true
|
||
}
|
||
},
|
||
{
|
||
text: 'Quit diff mode',
|
||
onClick: () => {
|
||
hideDiffMode()
|
||
},
|
||
color: 'red'
|
||
}
|
||
]
|
||
: []}
|
||
/>
|
||
{/key}
|
||
{/snippet}
|
||
|
||
<GitRepoResourcePicker
|
||
workspace={opWs}
|
||
bind:open={gitRepoResourcePickerOpen}
|
||
currentResource={ansibleAlternativeExecutionMode?.resource}
|
||
currentCommit={commitHashForGitRepo || ansibleAlternativeExecutionMode?.commit}
|
||
currentInventories={ansibleAlternativeExecutionMode?.inventories_location}
|
||
currentPlaybook={ansibleAlternativeExecutionMode?.playbook}
|
||
gitSshIdentity={ansibleGitSshIdentity}
|
||
on:selected={handleDelegateConfigUpdate}
|
||
on:addInventories={handleAddInventories}
|
||
/>
|
||
|
||
<style global>
|
||
/* Debug breakpoint glyph - red circle in the glyph margin */
|
||
.debug-breakpoint-glyph {
|
||
background-color: #e51400;
|
||
border-radius: 50%;
|
||
width: 10px !important;
|
||
height: 10px !important;
|
||
margin-left: 5px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* Ghost breakpoint shown on gutter hover before the user clicks */
|
||
.debug-breakpoint-glyph-hover {
|
||
background-color: #e51400;
|
||
opacity: 0.35;
|
||
border-radius: 50%;
|
||
width: 10px !important;
|
||
height: 10px !important;
|
||
margin-left: 5px;
|
||
margin-top: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Current execution line - yellow background */
|
||
.debug-current-line {
|
||
background-color: rgba(255, 238, 0, 0.2);
|
||
}
|
||
|
||
/* Current execution line glyph - yellow arrow in the glyph margin */
|
||
.debug-current-line-glyph {
|
||
background-color: #ffcc00;
|
||
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||
width: 10px !important;
|
||
height: 14px !important;
|
||
margin-left: 5px;
|
||
margin-top: 2px;
|
||
}
|
||
</style>
|