Files
windmill/frontend/src/lib/components/ScriptPicker.svelte
T
Guilhem 8828341a2b fix(frontend): scope session pipeline trigger editors to the session workspace (#10032)
* fix(frontend): scope session pipeline trigger editors to the session workspace

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): hoist triggerWorkspace decl above GCP init-time getBaseUrl call

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): scope nested trigger pickers to the session workspace

The triggerWorkspace resolver scoped direct trigger CRUD calls to the
session's (forked) workspace, but nested pickers still defaulted to the
nav `$workspaceStore`: in a fork session, resource lists/creation,
variable creation, and path-existence checks ran against the parent
workspace while save/delete targeted the fork — misleading options and
false path-validation failures.

Thread `wsId` into the nested controls of the 9 pipeline-canvas kinds:
- `<Path workspaceOverride={wsId}>` (8 editors) — path + folder checks
- `<ResourcePicker workspace={wsId}>` (6 config sections; add the
  resolver to MqttEditorConfigSection, which lacked `wsId`)
- SQS `<VariableEditor workspace={wsId}>` — variable creation

Also drop the per-site `wsId` rationale comment repeated across ~20
files; the invariant is documented once in triggerWorkspace.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): scope trigger runnable picker to the session workspace

The runnable picker (`ScriptPicker`, reached via `TriggerRunnablePicker`
and directly in the schedule editor) listed scripts/flows/apps from the
nav `$workspaceStore` with no override, so a forked session offered the
parent workspace's runnables when attaching a script/flow to a trigger.

Add an optional `workspace` prop to `ScriptPicker` (defaults to
`$workspaceStore` → no change for existing callers), pass it through
`TriggerRunnablePicker`, and wire `wsId` from the 7 trigger editors that
use it plus the schedule editor's 3 direct pickers.

Completes the nested-picker workspace scoping; the excluded kinds
(azure/http/websocket/native) keep their own ScriptPickers unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): scope trigger error-handler, folder default, and runnable actions

Follow-up to the trigger-workspace scoping: three subtrees still read the
nav `$workspaceStore` in a forked session.

- ErrorOrRecoveryHandler (via TriggerRetriesAndErrorHandler in the 7 in-scope
  editors): add a `workspace` prop (defaults to `$workspaceStore`) and route
  handler lookup/schema, Slack/Teams settings, test jobs, and run links
  through it, so the error handler is resolved/tested/saved in the session
  workspace instead of A while the trigger lives in B.
- useFolderDefaultPermissionedAs: accept an optional workspace getter so a
  `f/...` trigger's default permissioned-as is read from the session
  workspace, not the nav one (PermissionedAsLine passes `() => wsId`).
- ScriptPicker actions: scope the View drawer (`getScriptByPath`) and
  `FlowPathViewer` to `effectiveWorkspace`, and carry `?workspace=` onto the
  Edit/View routes when an explicit override is set (the layout consumes the
  param, same mechanism as editInFork). The param is only appended when a
  workspace override is passed, so existing callers' links are unchanged.

Also consolidate the repeated workspace-scoping comment in
PipelineTriggerEditors (the invariant lives in triggerWorkspace.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): scope error-handler/schedule nested consumers to the session workspace

Address CI Codex review on #10032 — the error-handler and schedule
subtrees still had nested consumers reading the nav workspace:

- ErrorOrRecoveryHandler: pass the resolved workspace to its own nested
  `ScriptPicker` (custom-handler list + View/Edit) and add a `workspace`
  prop to `ChannelSelector` (Teams channel listing); carry the acting
  workspace onto the "create from template" link.
- ScheduleEditorInner: pass `workspace={wsId}` to the error/recovery/
  success `ErrorOrRecoveryHandler` panels, `workspaceId={wsId}` to
  `WorkerTagPicker`, and the workspace query param onto the dynamic-skip
  template link.

Template-link and picker overrides only diverge from `$workspaceStore`
when a session override is set, so non-session callers are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 19:48:08 +02:00

246 lines
6.6 KiB
Svelte

<script lang="ts">
import { ScriptService, FlowService, type Script, AppService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { base } from '$lib/base'
import { createEventDispatcher, untrack } from 'svelte'
import Select from './select/Select.svelte'
import { getScriptByPath } from '$lib/scripts'
import { Button, Drawer, DrawerContent } from './common'
import HighlightCode from './HighlightCode.svelte'
import FlowPathViewer from './flows/content/FlowPathViewer.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import { Code, Code2, ExternalLink, Pen, RefreshCw } from 'lucide-svelte'
import type { SupportedLanguage } from '$lib/common'
import FlowIcon from './home/FlowIcon.svelte'
import DarkModeObserver from './DarkModeObserver.svelte'
import { truncate } from '$lib/utils'
interface Props {
initialPath?: string | undefined
scriptPath?: string | undefined
allowFlow?: boolean
itemKind?: 'script' | 'flow' | 'app'
kinds?: Script['kind'][]
disabled?: boolean
allowRefresh?: boolean
allowEdit?: boolean
allowView?: boolean
clearable?: boolean
/** Workspace to list runnables from. Defaults to the navigation
* `$workspaceStore`; pass the session's acting workspace so a forked
* session lists its own scripts/flows/apps rather than the parent's. */
workspace?: string
}
let {
initialPath = undefined,
scriptPath = $bindable(undefined),
allowFlow = false,
itemKind = $bindable('script'),
kinds = ['script'],
disabled = false,
allowRefresh = false,
allowEdit = true,
allowView = true,
clearable = false,
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore)
// Only carry the workspace onto Edit/View routes when an explicit override
// was passed, so existing callers' links are unchanged.
let wsParam = $derived(workspace ? `?workspace=${encodeURIComponent(workspace)}` : '')
let items: { value: string; label: string }[] = $state([])
let drawerViewer: Drawer | undefined = $state()
let drawerFlowViewer: Drawer | undefined = $state()
let code: string = $state('')
let lang: SupportedLanguage | undefined = $state()
let options: [[string, any, any, string | undefined]] = [['Script', 'script', Code2, undefined]]
untrack(() => allowFlow) && options.push(['Flow', 'flow', FlowIcon, '#14b8a6'])
const dispatch = createEventDispatcher()
async function loadItems(): Promise<void> {
if (itemKind == 'flow') {
items = (
await FlowService.listFlows({ workspace: effectiveWorkspace!, withoutDescription: true })
).map((flow) => ({
value: flow.path,
label: `${flow.path}${flow.summary ? ` | ${truncate(flow.summary, 20)}` : ''}`,
withoutDescription: true
}))
} else if (itemKind == 'script') {
items = (
await ScriptService.listScripts({
workspace: effectiveWorkspace!,
kinds: kinds.join(','),
withoutDescription: true
})
).map((script) => ({
value: script.path,
label: `${script.path}${script.summary ? ` | ${truncate(script.summary, 20)}` : ''}`
}))
} else if (itemKind == 'app') {
items = (await AppService.listApps({ workspace: effectiveWorkspace! })).map((app) => ({
value: app.path,
label: `${app.path}${app.summary ? ` | ${truncate(app.summary, 20)}` : ''}`
}))
}
}
$effect(() => {
itemKind && effectiveWorkspace && untrack(() => loadItems())
})
let darkMode: boolean = $state(false)
</script>
<DarkModeObserver bind:darkMode />
<Drawer bind:this={drawerViewer} size="900px">
<DrawerContent title="Script {scriptPath}" on:close={drawerViewer.closeDrawer}>
<HighlightCode {code} language={lang} />
</DrawerContent>
</Drawer>
<Drawer bind:this={drawerFlowViewer} size="900px">
<DrawerContent title="Flow {scriptPath}" on:close={drawerFlowViewer.closeDrawer}>
<FlowPathViewer path={scriptPath ?? ''} workspace={effectiveWorkspace} />
</DrawerContent>
</Drawer>
<div class="flex flex-row items-center gap-1 w-full">
{#if options.length > 1}
<div>
<ToggleButtonGroup
bind:selected={itemKind}
on:selected={() => {
scriptPath = ''
}}
>
{#snippet children({ item })}
{#each options as [label, value, icon, selectedColor]}
<ToggleButton {icon} {disabled} {value} {label} {selectedColor} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
</div>
{/if}
{#if disabled}
<input type="text" value={scriptPath ?? initialPath ?? ''} disabled />
{:else}
<Select
bind:value={
() => (scriptPath ?? initialPath) || undefined,
(path) => {
scriptPath = path
dispatch('select', { path, itemKind })
}
}
class="grow shrink max-w-full"
{items}
{clearable}
placeholder="Pick {itemKind === 'app' ? 'an' : 'a'} {itemKind}"
/>
{/if}
{#if allowRefresh}
<Button
variant="subtle"
unifiedSize="md"
on:click={loadItems}
startIcon={{ icon: RefreshCw }}
iconOnly
/>
{/if}
{#if scriptPath !== undefined && scriptPath !== ''}
{#if itemKind == 'flow'}
<div class="flex gap-1">
{#if allowEdit}
<Button
endIcon={{ icon: ExternalLink }}
target="_blank"
variant="default"
size="xs"
href="{base}/flows/edit/{scriptPath}{wsParam}">Edit</Button
>
{/if}
{#if allowView}
<Button
variant="default"
size="xs"
on:click={async () => {
drawerFlowViewer?.openDrawer()
}}
>
View
</Button>
{/if}
</div>
{:else if itemKind == 'app'}
<div class="flex gap-2">
{#if allowEdit}
<Button
startIcon={{ icon: Pen }}
target="_blank"
variant="default"
size="xs"
href="{base}/apps/edit/{scriptPath}{wsParam}"
>
Edit
</Button>
{/if}
{#if allowView}
<Button
variant="default"
size="xs"
target="_blank"
startIcon={{ icon: Code }}
href="{base}/apps/get/{scriptPath}{wsParam}"
>
View
</Button>
{/if}
</div>
{:else}
<div class="flex gap-2">
{#if allowEdit}
<Button
startIcon={{ icon: Pen }}
target="_blank"
variant="default"
size="xs"
href="{base}/scripts/edit/{scriptPath}{wsParam}"
>
Edit
</Button>
{/if}
{#if allowView}
<Button
variant="default"
size="xs"
startIcon={{ icon: Code }}
on:click={async () => {
const { language, content } = await getScriptByPath(
scriptPath ?? '',
effectiveWorkspace
)
code = content
lang = language
drawerViewer?.openDrawer()
}}
>
View
</Button>
{/if}
</div>
{/if}
{/if}
</div>