feat(pipeline): compact preview layout, two-way Test/Run sync

This commit is contained in:
Ruben Fiszel
2026-05-10 11:49:57 +00:00
parent 835daea364
commit 497b63b623
5 changed files with 339 additions and 162 deletions
+253 -160
View File
@@ -161,6 +161,13 @@
* 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
}
let {
@@ -195,9 +202,14 @@
modules = $bindable(undefined),
editorBarRight,
enablePreprocessorSnippet = false,
previewLayout = 'right'
previewLayout = 'right',
onTestStateChange
}: Props = $props()
$effect(() => {
onTestStateChange?.(testIsLoading)
})
let initialArgs = structuredClone($state.snapshot(args))
let jsonView = $state(false)
let schemaHeight = $state(0)
@@ -1485,8 +1497,16 @@
{@render editorContent()}
{/if}
</Pane>
<Pane bind:size={testPanelSize} minSize={0}>
<div class="flex flex-col h-full">
<Pane
bind:size={testPanelSize}
minSize={0}
class={customUi?.previewPanel?.hideArgs ? '!overflow-visible' : ''}
>
<div
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}>
@@ -1604,171 +1624,244 @@
</div>
</div>
{/if}
{#key previewLayout}
<Splitpanes
horizontal={previewLayout !== 'bottom'}
class="!max-h-[calc(100%-{debugMode && isDebuggableScript
? '83'
: previewLayout === 'bottom'
? '0'
: '43'}px)]"
>
<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}
<Button on:click={jobLoader?.cancelJob} unifiedSize="sm" btnClasses="w-full">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="14px"
width="16px"
spin="fast"
/>
Cancel
</Button>
{:else}
<Button
on:click={() => runTest()}
unifiedSize="sm"
btnClasses="w-full"
variant="accent-secondary"
startIcon={{ icon: Play, classes: 'animate-none' }}
shortCut={{ Icon: CornerDownLeft }}
>
Test
</Button>
{/if}
</div>
{/if}
{#if jsonView}
<div
class="py-2"
style="height: {!schemaHeight || schemaHeight < 600 ? 600 : schemaHeight}px"
data-schema-picker
>
<JsonInputs
on:select={(e) => {
if (e.detail) {
if (activeModuleTab !== null) {
testPanelArgs = e.detail
} else {
args = e.detail
}
}
}}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
{#if customUi?.previewPanel?.hideArgs}
<!-- Compact preview layout used by the pipeline editor:
no args column (the script is known to take no
inputs), LogPanel takes the full width, and the
Test/Cancel button floats at the top-left with a
-translate-y so it visually attaches to the editor
panel above instead of stealing vertical space
inside the preview band. -->
<div class="relative h-full">
<div class="absolute top-0 left-2 -translate-y-1/2 z-10">
{#if testIsLoading}
<Button on:click={jobLoader?.cancelJob} unifiedSize="sm" btnClasses="shadow-md">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="14px"
width="16px"
spin="fast"
/>
</div>
Cancel
</Button>
{:else}
<div class="px-4">
<div class="break-words relative font-sans" bind:clientHeight={schemaHeight}>
{#key argsRender}
{#if activeModuleTab !== null}
<SchemaForm
helperScript={{
source: 'inline',
code: editorCode,
//@ts-ignore
lang: effectiveLang
}}
compact
schema={testPanelSchema}
bind:args={testPanelArgs}
bind:isValid
noVariablePicker={customUi?.previewPanel?.disableVariablePicker ===
true}
showSchemaExplorer
<Button
on:click={() => runTest()}
unifiedSize="sm"
btnClasses="shadow-md"
variant="accent-secondary"
startIcon={{ icon: Play, classes: 'animate-none' }}
shortCut={{ Icon: CornerDownLeft }}
>
Test
</Button>
{/if}
</div>
<LogPanel
bind:this={logPanel}
{lang}
previewJob={debugMode
? ({
id: 'debug',
logs: $debugState.logs,
result: $debugState.result,
success: !$debugState.error,
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
} as any)
: testJob}
{pastPreviews}
previewIsLoading={debugMode
? $debugState.running && !$debugState.stopped
: testIsLoading}
{editor}
{diffEditor}
args={activeModuleTab !== null ? testPanelArgs : args}
{showCaptures}
customUi={customUi?.previewPanel}
showCustomResultPanel={showDebugPanel}
>
{#if scriptProgress && !debugMode}
<JobProgressBar
job={testJob}
{scriptProgress}
bind:this={jobProgressBar}
compact={true}
/>
{/if}
</LogPanel>
</div>
{:else}
{#key previewLayout}
<Splitpanes
horizontal={previewLayout !== 'bottom'}
class="!max-h-[calc(100%-{debugMode && isDebuggableScript
? '83'
: previewLayout === 'bottom'
? '0'
: '43'}px)]"
>
<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}
<Button
on:click={jobLoader?.cancelJob}
unifiedSize="sm"
btnClasses="w-full"
>
<WindmillIcon
white={true}
class="mr-2 text-white"
height="14px"
width="16px"
spin="fast"
/>
{:else}
<SchemaForm
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>
Cancel
</Button>
{:else}
<Button
on:click={() => runTest()}
unifiedSize="sm"
btnClasses="w-full"
variant="accent-secondary"
startIcon={{ icon: Play, classes: 'animate-none' }}
shortCut={{ Icon: CornerDownLeft }}
>
Test
</Button>
{/if}
</div>
</div>
{/if}
</Pane>
<Pane size={previewLayout === 'bottom' ? 60 : 67} class="relative">
<LogPanel
bind:this={logPanel}
{lang}
previewJob={debugMode
? ({
id: 'debug',
logs: $debugState.logs,
result: $debugState.result,
success: !$debugState.error,
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
} as any)
: testJob}
{pastPreviews}
previewIsLoading={debugMode
? $debugState.running && !$debugState.stopped
: testIsLoading}
{editor}
{diffEditor}
args={activeModuleTab !== null ? 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}
{hasPreprocessor}
canHavePreprocessor={canHavePreprocessor(lang)}
isFlow={false}
path={stablePathForCaptures}
canEdit={true}
on:applyArgs
on:updateSchema
on:addPreprocessor
{#if jsonView}
<div
class="py-2"
style="height: {!schemaHeight || schemaHeight < 600 ? 600 : schemaHeight}px"
data-schema-picker
>
<JsonInputs
on:select={(e) => {
if (e.detail) {
if (activeModuleTab !== null) {
testPanelArgs = e.detail
} else {
args = e.detail
}
}
}}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
</div>
{/snippet}
{#snippet customResultPanel()}
<DebugPanel
stackFrames={$debugState.stackFrames}
scopes={$debugState.scopes}
variables={$debugState.variables}
client={dapClient}
bind:selectedFrameId={selectedDebugFrameId}
/>
{/snippet}
</LogPanel>
</Pane>
</Splitpanes>
{/key}
{:else}
<div class="px-4">
<div class="break-words relative font-sans" bind:clientHeight={schemaHeight}>
{#key argsRender}
{#if activeModuleTab !== null}
<SchemaForm
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
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">
<LogPanel
bind:this={logPanel}
{lang}
previewJob={debugMode
? ({
id: 'debug',
logs: $debugState.logs,
result: $debugState.result,
success: !$debugState.error,
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
} as any)
: testJob}
{pastPreviews}
previewIsLoading={debugMode
? $debugState.running && !$debugState.stopped
: testIsLoading}
{editor}
{diffEditor}
args={activeModuleTab !== null ? 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}
{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>
</Pane>
</Splitpanes>
{/key}
{/if}
{/if}
</div>
</Pane>
@@ -110,6 +110,19 @@
// gets a "Recomputing…" banner so the user knows the rendered
// snapshot is about to be replaced.
activeRunnable?: { kind: 'script' | 'flow'; path: string } | undefined
// Forwarded from ScriptEditor when the user hits the Test button —
// gives the page a chance to mark the script as the active
// runnable on the canvas (animates its edges) so Test and the
// canvas Run button feel equivalent.
onTestStateChange?: (running: boolean) => void
// Counter pattern (cf. `requestRemoveSignal`) — bumped by the
// page when the user clicks the canvas Run button on the
// currently-open script, so we can route the dispatch through
// ScriptEditor.runTest() and surface the running state in the
// preview panel (logs/result/cancel) instead of just animating
// the edges. Counter rather than boolean so back-to-back runs
// re-fire the effect even if no other prop changed.
requestRunSignal?: number
// Folder-scoped non-editable prefix shown next to the suffix
// editor when the user renames a draft (e.g. `f/<folder>/`). The
// new path = pathPrefix + suffix.
@@ -144,11 +157,24 @@
runsPendingJobId,
onRunCompleted,
activeRunnable,
onTestStateChange,
requestRunSignal,
pathPrefix = '',
onDraftPathChange,
requestRemoveSignal
}: Props = $props()
// Held ref to ScriptEditor so we can route a canvas-side Run dispatch
// through .runTest() — gives the test panel logs/result/cancel for
// runs initiated from the graph, not just the in-pane Test button.
let scriptEditorRef: { runTest: () => Promise<unknown> } | undefined = $state(undefined)
$effect(() => {
// Track the counter; ignore the initial 0/undefined.
const sig = requestRunSignal
if (sig === undefined || sig === 0) return
void scriptEditorRef?.runTest()
})
// True when the script that writes to the currently-selected asset is
// running right now. Drives the "Recomputing…" banner above the
// preview and pairs with `onRunCompleted` (which bumps
@@ -671,6 +697,7 @@
{:else if script}
{#key (script.hash ?? `draft:${script.path}`) + script.language}
<ScriptEditor
bind:this={scriptEditorRef}
showCaptures={false}
noSyncFromGithub
lang={script.language}
@@ -683,12 +710,21 @@
disableHistory: true,
disableTracing: true,
disableTriggerCaptures: true,
disableJsonView: true
disableJsonView: true,
// Pipeline scripts in this view are dispatched without
// args (the page's runScriptByPath / runScriptPreview
// passes `{}`), so the SchemaForm column is dead
// space. Drop it, render the LogPanel full-width with
// logs|result side by side, and float the Test/Cancel
// button onto the editor band above.
hideArgs: true,
logsResultSideBySide: true
}
}}
bind:code={script.content}
bind:schema={script.schema}
bind:assets={liveBodyAssets}
{onTestStateChange}
{args}
>
{#snippet editorBarRight()}
+10
View File
@@ -61,6 +61,16 @@ export type PreviewPanelUi = {
disableVariablePicker?: boolean
disableDownload?: boolean
tagLabel?: string
// Hide the args/SchemaForm pane entirely (use cases where the script
// is known to take no inputs — e.g. the pipeline editor's per-script
// preview). When set, the Test/Cancel button is rendered as a small
// floating affordance at the top-left of the preview area instead of
// inside the (now-absent) args column.
hideArgs?: boolean
// Render the LogPanel's logs/result as a left/right split instead of
// the default top/bottom. Pairs naturally with `hideArgs` when the
// preview area is the full bottom band.
logsResultSideBySide?: boolean
}
export type EditorBarUi = {
@@ -175,7 +175,7 @@
</div>
{:else}
<SplitPanesWrapper>
<Splitpanes horizontal>
<Splitpanes horizontal={customUi?.logsResultSideBySide !== true}>
<Pane class="relative">
<LogViewer
jobId={previewJob?.id}
@@ -782,6 +782,12 @@
// AssetRunsPanel reports the run is done. The same hook will later
// be reused for live pipeline status.
let activeRunnable = $state<{ kind: 'script' | 'flow'; path: string } | undefined>(undefined)
// Counter bumped when the canvas Run button targets the currently-open
// script — the pane intercepts and routes through ScriptEditor.runTest
// so logs/result/cancel land in the test panel instead of going off
// into nowhere with only edge animation as feedback. Counter rather
// than boolean so back-to-back runs re-fire.
let requestRunSignal = $state(0)
// Counter bumped from the runnable-node action menu to ask the pane to
// open its archive/delete confirmation modal for the loaded script.
// Counter (vs boolean) so successive triggers re-fire even if the user
@@ -1092,6 +1098,20 @@
// immediately — its background poll only kicks in
// for already-listed in-flight jobs.
if (!$workspaceStore || producer.kind !== 'script') return undefined
// If the producer being run is the script currently
// edited in the pane, route through ScriptEditor's
// Test path — the test panel then shows logs/result
// and the user can cancel from there. Same UX as
// hitting the Test button directly.
const openPath =
activeDraftPath ??
(selection?.kind === 'runnable' && selection.runnable_kind === 'script'
? selection.path
: undefined)
if (openPath === producer.path) {
requestRunSignal++
return undefined
}
let jobId: string | undefined
if (producer.unsaved) {
const draft = drafts.get(producer.path)
@@ -1150,7 +1170,25 @@
{runsPendingJobId}
{activeRunnable}
onRunCompleted={() => (activeRunnable = undefined)}
onTestStateChange={(running) => {
// Bridge: ScriptEditor's Test button triggers the
// same canvas-level "is running" hint as the
// per-node Run button. The currently-edited script
// is whichever path is open in the pane (active
// draft, or the persisted-script selection).
const openPath =
activeDraftPath ??
(selection?.kind === 'runnable' && selection.runnable_kind === 'script'
? selection.path
: undefined)
if (running && openPath) {
activeRunnable = { kind: 'script', path: openPath }
} else if (!running && activeRunnable?.path === openPath) {
activeRunnable = undefined
}
}}
{requestRemoveSignal}
{requestRunSignal}
draftScript={activeDraft?.script}
{pathPrefix}
onDraftPathChange={renameDraft}