mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 00:01:55 +00:00
f7a03ee851
* Add popover with path to flow modules * Add path to items in the flow picker * fix virtual item display issue * fix min size * open popover on keyboard selection --------- Co-authored-by: Guilhem <guilhem@mbp-de-windmill.home> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
470 lines
14 KiB
Svelte
470 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { isCloudHosted } from '$lib/cloud'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import FlowScriptPickerQuick from '../pickers/FlowScriptPickerQuick.svelte'
|
|
import WorkspaceScriptPickerQuick from '../pickers/WorkspaceScriptPickerQuick.svelte'
|
|
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
|
|
import { defaultScripts, enterpriseLicense, userStore } from '$lib/stores'
|
|
import type { SupportedLanguage } from '$lib/common'
|
|
import { createEventDispatcher, getContext, onDestroy, onMount } from 'svelte'
|
|
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
|
|
import PickHubScriptQuick from '../pickers/PickHubScriptQuick.svelte'
|
|
import { type Script, type FlowModule } from '$lib/gen'
|
|
import ListFiltersQuick from '$lib/components/home/ListFiltersQuick.svelte'
|
|
import { Folder, User } from 'lucide-svelte'
|
|
import type { FlowCopilotContext, FlowCopilotModule } from '../../copilot/flow'
|
|
import type { FlowEditorContext } from '../../flows/types'
|
|
import { copilotInfo } from '$lib/stores'
|
|
import { nextId } from '../../flows/flowModuleNextId'
|
|
import { twMerge } from 'tailwind-merge'
|
|
import { fade } from 'svelte/transition'
|
|
import { flip } from 'svelte/animate'
|
|
import Scrollable from '$lib/components/Scrollable.svelte'
|
|
import { Button } from '$lib/components/common'
|
|
import { SettingsIcon } from 'lucide-svelte'
|
|
import DefaultScriptsInner from '$lib/components/DefaultScriptsInner.svelte'
|
|
import GenAiQuick from './GenAiQuick.svelte'
|
|
import FlowToplevelNode from '../pickers/FlowToplevelNode.svelte'
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
export let summary: string | undefined = undefined
|
|
export let filter = ''
|
|
export let disableAi = false
|
|
export let preFilter: 'all' | 'workspace' | 'hub' = 'hub'
|
|
export let funcDesc: string
|
|
export let index: number
|
|
export let modules: FlowModule[]
|
|
export let owners: string[] = []
|
|
export let loading = false
|
|
export let small = false
|
|
export let kind: 'trigger' | 'script' | 'preprocessor' | 'failure' | 'approval'
|
|
export let selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure' =
|
|
kind
|
|
export let displayPath = false
|
|
|
|
let lang: FlowCopilotModule['lang'] = undefined
|
|
let selectedCompletion: FlowCopilotModule['selectedCompletion'] = undefined
|
|
|
|
let filteredWorkspaceItems: (Script & { marked?: string })[] = []
|
|
|
|
let hubCompletions: FlowCopilotModule['hubCompletions'] = []
|
|
|
|
const { flowStore, flowStateStore, insertButtonOpen } =
|
|
getContext<FlowEditorContext>('FlowEditorContext')
|
|
const { modulesStore: copilotModulesStore, genFlow } =
|
|
getContext<FlowCopilotContext>('FlowCopilotContext')
|
|
|
|
let selected: { kind: 'owner' | 'integrations'; name: string | undefined } | undefined = undefined
|
|
|
|
let integrations: string[] = []
|
|
|
|
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
|
|
|
|
$: langs = processLangs(undefined, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages))
|
|
.map((l) => [defaultScriptLanguages[l], l])
|
|
.filter(
|
|
(x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x[1])
|
|
) as [string, SupportedLanguage | 'docker'][]
|
|
|
|
function displayLang(
|
|
lang: SupportedLanguage | 'docker',
|
|
kind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
|
|
) {
|
|
if (kind == 'trigger') {
|
|
return ['python3', 'bun', 'deno', 'go'].includes(lang)
|
|
} else if (kind == 'script') {
|
|
return true
|
|
} else if (kind == 'approval') {
|
|
return ['python3', 'bun', 'deno'].includes(lang)
|
|
} else if (kind == 'flow') {
|
|
return false
|
|
} else if (kind == 'preprocessor') {
|
|
return ['python3', 'bun', 'deno'].includes(lang)
|
|
} else if (kind == 'failure') {
|
|
return ['python3', 'bun', 'deno', 'go'].includes(lang)
|
|
}
|
|
}
|
|
|
|
async function onGenerate() {
|
|
if (!selectedCompletion && !$copilotInfo.exists_openai_resource_path) {
|
|
sendUserToast(
|
|
'Windmill AI is not enabled, you can activate it in the workspace settings',
|
|
true
|
|
)
|
|
return
|
|
}
|
|
$copilotModulesStore = [
|
|
{
|
|
id: nextId($flowStateStore, $flowStore),
|
|
type: selectedKind == 'trigger' ? 'trigger' : 'script',
|
|
description: funcDesc,
|
|
code: '',
|
|
source: selectedCompletion ? 'hub' : 'custom',
|
|
hubCompletions,
|
|
selectedCompletion,
|
|
editor: undefined,
|
|
lang
|
|
}
|
|
]
|
|
genFlow?.(index, modules, true)
|
|
dispatch('close')
|
|
}
|
|
|
|
let openScriptSettings = false
|
|
|
|
let selectedByKeyboard = 0
|
|
|
|
$: onSelectedKindChange(selectedKind)
|
|
|
|
function onSelectedKindChange(
|
|
_selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
|
|
) {
|
|
selectedByKeyboard = 0
|
|
}
|
|
|
|
let inlineScripts: [string, SupportedLanguage | 'docker'][] = []
|
|
|
|
const enterpriseLangs = ['bigquery', 'snowflake', 'mssql']
|
|
|
|
function computeInlineScriptChoices(
|
|
funcDesc: string,
|
|
selected: { kind: 'owner' | 'integrations'; name: string | undefined } | undefined,
|
|
preFilter: 'all' | 'workspace' | 'hub',
|
|
selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
|
|
) {
|
|
if (['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind)) {
|
|
if (!selected && preFilter == 'all') {
|
|
inlineScripts = langs.filter((lang) => {
|
|
return (
|
|
(customUi?.languages == undefined || customUi?.languages?.includes(lang?.[1])) &&
|
|
(funcDesc?.length == 0 ||
|
|
lang?.[0]?.toLowerCase()?.includes(funcDesc?.toLowerCase())) &&
|
|
displayLang(lang?.[1], selectedKind)
|
|
)
|
|
})
|
|
return
|
|
}
|
|
}
|
|
inlineScripts = []
|
|
}
|
|
|
|
const allToplevelNodes: [string, string][] = [
|
|
['For Loop', 'forloop'],
|
|
['While Loop', 'whileloop'],
|
|
['Branch to one', 'branchone'],
|
|
['Branch to all', 'branchall']
|
|
]
|
|
let topLevelNodes: [string, string][] = []
|
|
function computeToplevelNodeChoices(funcDesc: string, preFilter: 'all' | 'workspace' | 'hub') {
|
|
if (funcDesc.length > 0 && preFilter == 'all' && kind == 'script') {
|
|
topLevelNodes = allToplevelNodes.filter((node) =>
|
|
node[0].toLowerCase().startsWith(funcDesc.toLowerCase())
|
|
)
|
|
} else {
|
|
topLevelNodes = []
|
|
}
|
|
}
|
|
|
|
$: computeToplevelNodeChoices(funcDesc, preFilter)
|
|
$: computeInlineScriptChoices(funcDesc, selected, preFilter, selectedKind)
|
|
$: onPrefilterChange(preFilter)
|
|
|
|
function onPrefilterChange(preFilter: 'all' | 'workspace' | 'hub') {
|
|
if (preFilter == 'workspace') {
|
|
hubCompletions = []
|
|
} else if (preFilter == 'hub') {
|
|
filteredWorkspaceItems = []
|
|
}
|
|
selectedByKeyboard = 0
|
|
}
|
|
|
|
$: aiLength =
|
|
funcDesc?.length > 0 && !disableAi && selectedKind != 'flow' && preFilter == 'all' ? 2 : 0
|
|
|
|
let scrollable: Scrollable | undefined
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
let length =
|
|
topLevelNodes?.length +
|
|
inlineScripts.length +
|
|
aiLength +
|
|
filteredWorkspaceItems.length +
|
|
hubCompletions.length
|
|
if (e.key === 'ArrowDown') {
|
|
selectedByKeyboard = (selectedByKeyboard + 1) % length
|
|
scrollable?.scrollIntoView(selectedByKeyboard * 32)
|
|
e.preventDefault()
|
|
} else if (e.key === 'ArrowUp') {
|
|
selectedByKeyboard = (selectedByKeyboard - 1 + length) % length
|
|
scrollable?.scrollIntoView(selectedByKeyboard * 32)
|
|
e.preventDefault()
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
$insertButtonOpen = true
|
|
})
|
|
|
|
onDestroy(() => {
|
|
$insertButtonOpen = false
|
|
})
|
|
</script>
|
|
|
|
<svelte:window on:keydown={onKeyDown} />
|
|
<div class="flex flex-row grow min-w-0 divide-x relative {!small ? 'shadow-inset' : ''}">
|
|
{#if selectedKind != 'preprocessor'}
|
|
<Scrollable shiftedShadow scrollableClass="w-32 grow-0 shrink-0 ">
|
|
{#if ['script', 'trigger', 'approval', 'preprocessor', 'failure'].includes(selectedKind)}
|
|
{#if (preFilter === 'all' && owners.length > 0) || preFilter === 'workspace'}
|
|
{#if preFilter !== 'workspace'}
|
|
<div class="pb-0 text-2xs font-light text-secondary ml-2">Workspace Folders</div>
|
|
{/if}
|
|
|
|
{#if owners.length > 0}
|
|
{#each owners as owner (owner)}
|
|
<div
|
|
in:fade={{ duration: 50 }}
|
|
animate:flip={{ duration: 100 }}
|
|
class="w-full px-0.5"
|
|
>
|
|
<button
|
|
class={twMerge(
|
|
'w-full text-left text-2xs text-primary font-normal py-2 px-3 hover:bg-surface-hover transition-all whitespace-nowrap flex flex-row gap-2 items-center rounded-md',
|
|
owner === selected?.name ? 'bg-surface-hover' : ''
|
|
)}
|
|
on:click={() => {
|
|
selected = selected?.name == owner ? undefined : { kind: 'owner', name: owner }
|
|
}}
|
|
>
|
|
{#if owner.startsWith('f/')}
|
|
<Folder class="mr-0.5" size={14} />
|
|
{:else}
|
|
<User class="mr-0.5" size={14} />
|
|
{/if}
|
|
{owner.slice(2)}
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
<div class="pb-1.5" />
|
|
{:else}
|
|
<div class="text-2xs text-tertiary font-light text-center py-3 px-3 items-center">
|
|
No items found.
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if preFilter === 'hub' || preFilter === 'all'}
|
|
{#if preFilter == 'all'}
|
|
<div class="pb-0 text-2xs font-light text-secondary ml-2 pt-0.5">Integrations</div>
|
|
{/if}
|
|
<ListFiltersQuick
|
|
on:selected={() => {
|
|
filteredWorkspaceItems = []
|
|
selectedByKeyboard = 0
|
|
}}
|
|
filters={integrations}
|
|
bind:selectedFilter={selected}
|
|
resourceType
|
|
/>
|
|
{/if}
|
|
{:else if selectedKind === 'flow'}
|
|
{#if owners.length > 0}
|
|
{#each owners as owner (owner)}
|
|
<div in:fade={{ duration: 50 }} animate:flip={{ duration: 100 }}>
|
|
<button
|
|
class={twMerge(
|
|
'w-full text-left text-2xs text-primary font-normal py-2 px-3 hover:bg-surface-hover transition-all whitespace-nowrap flex flex-row gap-2 items-center rounded-md',
|
|
owner === selected?.name ? 'bg-surface-hover' : ''
|
|
)}
|
|
on:click={() => {
|
|
selected = selected?.name == owner ? undefined : { kind: 'owner', name: owner }
|
|
}}
|
|
>
|
|
{#if owner.startsWith('f/')}
|
|
<Folder class="mr-0.5" size={14} />
|
|
{:else}
|
|
<User class="mr-0.5" size={14} />
|
|
{/if}
|
|
{owner.slice(2)}
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
{/if}
|
|
</Scrollable>
|
|
{/if}
|
|
<Scrollable id="flow-editor-flow-atoms" bind:this={scrollable} scrollableClass="grow min-w-0">
|
|
{#if kind == 'script'}
|
|
{#each topLevelNodes as [label, kind], i (label)}
|
|
<FlowToplevelNode
|
|
on:click={() => {
|
|
dispatch('new', { kind })
|
|
}}
|
|
{label}
|
|
selected={selectedByKeyboard === i}
|
|
/>
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if inlineScripts?.length > 0}
|
|
<div class="pb-0 flex flex-row items-center gap-2">
|
|
<div class=" text-2xs font-light text-secondary ml-2"
|
|
>New {selectedKind != 'script' ? selectedKind + ' ' : ''}script</div
|
|
>
|
|
{#if $userStore?.is_admin || $userStore?.is_super_admin}
|
|
{#if !openScriptSettings}
|
|
<Button
|
|
on:click={() => (openScriptSettings = true)}
|
|
startIcon={{ icon: SettingsIcon }}
|
|
color="light"
|
|
size="xs2"
|
|
btnClasses="!text-tertiary"
|
|
variant="contained"
|
|
title="Edit global default scripts"
|
|
/>
|
|
{:else}
|
|
<Button
|
|
on:click={() => (openScriptSettings = false)}
|
|
startIcon={{ icon: SettingsIcon }}
|
|
color="dark"
|
|
size="xs2"
|
|
variant="contained"
|
|
>
|
|
Close
|
|
</Button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{#if openScriptSettings}
|
|
<div class="p-2">
|
|
<DefaultScriptsInner small />
|
|
</div>
|
|
{/if}
|
|
{#each inlineScripts as [label, lang], i (lang)}
|
|
<FlowScriptPickerQuick
|
|
eeRestricted={!$enterpriseLicense && enterpriseLangs.includes(lang)}
|
|
selected={selectedByKeyboard === i + topLevelNodes.length}
|
|
{enterpriseLangs}
|
|
{label}
|
|
lang={lang == 'docker' ? 'bash' : lang}
|
|
on:click={() => {
|
|
if (lang == 'docker') {
|
|
if (isCloudHosted()) {
|
|
sendUserToast(
|
|
'You cannot use Docker scripts on the multi-tenant platform. Use a dedicated instance or self-host windmill instead.',
|
|
true,
|
|
[
|
|
{
|
|
label: 'Learn more',
|
|
callback: () => {
|
|
window.open('https://www.windmill.dev/docs/advanced/docker', '_blank')
|
|
}
|
|
}
|
|
]
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
dispatch('new', {
|
|
kind: selectedKind,
|
|
inlineScript: {
|
|
language: lang == 'docker' ? 'bash' : lang,
|
|
kind: selectedKind,
|
|
subkind:
|
|
lang == 'docker'
|
|
? 'docker'
|
|
: selectedKind == 'preprocessor'
|
|
? 'preprocessor'
|
|
: 'flow',
|
|
summary
|
|
}
|
|
})
|
|
}}
|
|
/>
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if !disableAi && funcDesc?.length > 0 && kind != 'failure' && kind != 'preprocessor' && (selectedKind == 'script' || selectedKind == 'trigger') && preFilter == 'all'}
|
|
<ul class="transition-all">
|
|
<li
|
|
><GenAiQuick
|
|
{funcDesc}
|
|
lang="TypeScript"
|
|
selected={selectedByKeyboard === inlineScripts?.length + topLevelNodes.length}
|
|
on:click={() => {
|
|
lang = 'bun'
|
|
onGenerate()
|
|
}}
|
|
/>
|
|
</li>
|
|
<li>
|
|
<GenAiQuick
|
|
{funcDesc}
|
|
lang="Python"
|
|
selected={selectedByKeyboard === inlineScripts?.length + topLevelNodes.length + 1}
|
|
on:click={() => {
|
|
lang = 'python3'
|
|
onGenerate()
|
|
}}
|
|
/>
|
|
</li>
|
|
</ul>
|
|
{/if}
|
|
|
|
{#if (!selected || selected?.kind === 'owner') && (preFilter === 'workspace' || preFilter === 'all')}
|
|
{#if !selected && (preFilter !== 'workspace' || funcDesc?.length > 0)}
|
|
<div class="pt-2 pb-0 text-2xs font-light text-secondary ml-2">Workspace</div>
|
|
{/if}
|
|
<WorkspaceScriptPickerQuick
|
|
bind:owners
|
|
bind:ownerFilter={selected}
|
|
bind:filteredWithOwner={filteredWorkspaceItems}
|
|
{filter}
|
|
kind={selectedKind}
|
|
selected={selectedByKeyboard - inlineScripts?.length - aiLength - topLevelNodes.length}
|
|
on:pickScript
|
|
on:pickFlow
|
|
{displayPath}
|
|
/>
|
|
{/if}
|
|
|
|
{#if selectedKind != 'preprocessor' && selectedKind != 'flow'}
|
|
{#if (!selected || selected?.kind === 'integrations') && (preFilter === 'hub' || preFilter === 'all')}
|
|
{#if !selected && preFilter !== 'hub'}
|
|
<div class=" pb-0 text-2xs font-light text-secondary ml-2">Hub</div>
|
|
{/if}
|
|
|
|
<PickHubScriptQuick
|
|
bind:items={hubCompletions}
|
|
bind:filter
|
|
bind:apps={integrations}
|
|
appFilter={selected?.name}
|
|
kind={selectedKind}
|
|
selected={selectedByKeyboard -
|
|
inlineScripts?.length -
|
|
aiLength -
|
|
filteredWorkspaceItems?.length -
|
|
topLevelNodes.length}
|
|
on:pickScript
|
|
bind:loading
|
|
{displayPath}
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
</Scrollable>
|
|
</div>
|
|
|
|
<style>
|
|
.shadow-inset::before {
|
|
box-shadow: inset 25px 0px 12px -30px rgba(94, 129, 172, 0.5);
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
content: '';
|
|
pointer-events: none;
|
|
}
|
|
</style>
|