Files
windmill/frontend/src/lib/components/ScriptEditor.svelte
T
HugoCasa 30edcdfe0e fix: workspace preprocessor improvements (#5784)
* add tracing to get of authed client

* internal: Trigger claude when commenting with /aider (#5783)

* add claude instructions files

* call claude too when using aider

* fix

* add draft for linear claude integration

* fix: workspace preprocessor fixes

* tmp ee ref

* fix build

* update ee ref

* fix: hub script preprocessor handling

* fix build

* good ref

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: centdix <40307056+centdix@users.noreply.github.com>
2025-05-21 17:49:21 +02:00

775 lines
21 KiB
Svelte

<script lang="ts">
import { BROWSER } from 'esm-env'
import type { Schema, SupportedLanguage } from '$lib/common'
import { type CompletedJob, type Job, JobService, type Preview } from '$lib/gen'
import { copilotInfo, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { copyToClipboard, emptySchema, sendUserToast } from '$lib/utils'
import Editor from './Editor.svelte'
import { inferArgs } from '$lib/infer'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SchemaForm from './SchemaForm.svelte'
import LogPanel from './scriptEditor/LogPanel.svelte'
import EditorBar, { EDITOR_BAR_WIDTH_THRESHOLD } from './EditorBar.svelte'
import TestJobLoader from './TestJobLoader.svelte'
import JobProgressBar from '$lib/components/jobs/JobProgressBar.svelte'
import { createEventDispatcher, onDestroy, onMount } 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 { WebsocketProvider } from 'y-websocket'
import Modal from './common/modal/Modal.svelte'
import DiffEditor from './DiffEditor.svelte'
import {
Clipboard,
CornerDownLeft,
ExternalLink,
Github,
Play,
PlayIcon,
WandSparkles
} from 'lucide-svelte'
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 { slide } from 'svelte/transition'
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
import CaptureButton from './triggers/CaptureButton.svelte'
import AIChat from './copilot/chat/AIChat.svelte'
import { setContext } from 'svelte'
import HideButton from './apps/editor/settingsPanel/HideButton.svelte'
import { base } from '$lib/base'
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './copilot/chat/core'
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
// Exported
export let schema: Schema | any = emptySchema()
export let code: string
export let path: string | undefined
export let lang: Preview['language']
export let kind: string | undefined = undefined
export let template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' =
'script'
export let tag: string | undefined
export let initialArgs: Record<string, any> = {}
export let fixedOverflowWidgets = true
export let noSyncFromGithub = false
export let editor: Editor | undefined = undefined
export let diffEditor: DiffEditor | undefined = undefined
export let collabMode = false
export let edit = true
export let noHistory = false
export let saveToWorkspace = false
export let watchChanges = false
export let customUi: ScriptEditorWhitelabelCustomUi | undefined = undefined
export let args: Record<string, any> = initialArgs
export let selectedTab: 'main' | 'preprocessor' = 'main'
export let hasPreprocessor = false
export let captureTable: CaptureTable | undefined = undefined
export let showCaptures: boolean = true
export let stablePathForCaptures: string = ''
export let lastSavedCode: string | undefined = undefined
export let lastDeployedCode: string | undefined = undefined
let showHistoryDrawer = false
let jobProgressReset: () => void
let diffMode = false
let websocketAlive = {
pyright: false,
deno: false,
go: false,
ruff: false,
shellcheck: false
}
const dispatch = createEventDispatcher()
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
$: watchChanges &&
(code != undefined || schema != undefined) &&
dispatchIfMounted('change', { code, schema })
let width = 1200
let testJobLoader: TestJobLoader
let isValid: boolean = true
let scriptProgress = undefined
// Test
let testIsLoading = false
let testJob: Job | undefined
let pastPreviews: CompletedJob[] = []
let validCode = true
let wsProvider: WebsocketProvider | undefined = undefined
let yContent: Y.Text | undefined = undefined
let peers: { name: string }[] = []
let showCollabPopup = false
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 == 'l') {
event.preventDefault()
toggleAiPanel()
} else if ((event.ctrlKey || event.metaKey) && event.key == 'u') {
event.preventDefault()
toggleTestPanel()
}
}
export function setArgs(nargs: Record<string, any>) {
args = nargs
}
export async function runTest() {
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset()
//@ts-ignore
let job = await testJobLoader.runPreview(
path,
code,
lang,
selectedTab === 'preprocessor' || kind === 'preprocessor'
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args }
: args,
tag
)
setFocusToLogs()
return job
}
async function loadPastTests(): Promise<void> {
pastPreviews = await JobService.listCompletedJobs({
workspace: $workspaceStore!,
jobKinds: 'preview',
createdBy: $userStore?.username,
scriptPathExact: path
})
}
export async function inferSchema(code: string, nlang?: SupportedLanguage, resetArgs = false) {
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?.no_main_func : result?.has_preprocessor) ??
false
if (!hasPreprocessor && selectedTab === 'preprocessor') {
selectedTab = 'main'
}
}
validCode = true
if (resetArgs) {
args = {}
}
schema = nschema
} catch (e) {
validCode = false
}
}
onMount(() => {
inferSchema(code)
loadPastTests()
})
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
}
const ydoc = new Y.Doc()
if (wsProvider) {
wsProvider.destroy()
}
let yContentInit = ydoc.getText('content')
const wsProtocol = BROWSER && window.location.protocol == 'https:' ? 'wss' : 'ws'
wsProvider = new WebsocketProvider(
`${wsProtocol}://${window.location.host}/ws_mp/`,
$workspaceStore + '/' + (path ?? 'no-room-name'),
ydoc,
{ connect: false }
)
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 = []
console.log('collab mode disabled')
wsProvider?.disconnect()
wsProvider.destroy()
wsProvider = undefined
}
onDestroy(() => {
disableCollaboration()
})
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` + (edit ? '' : `&path=${path}`)
}
$: showTabs = hasPreprocessor
$: !hasPreprocessor && (selectedTab = 'main')
$: selectedTab && inferSchema(code)
let argsRender = 0
export async function updateArgs(newArgs: Record<string, any>) {
if (Object.keys(newArgs).length > 0) {
args = { ...newArgs }
argsRender++
}
}
let setFocusToLogs = () => {}
setContext('disableTooltips', customUi?.disableTooltips === true)
let aiPanelSize =
!$copilotInfo.enabled ||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '') ||
localStorage.getItem('aiPanelOpen') === 'false'
? 0
: 30
let codePanelSize = 40 + (30 - aiPanelSize)
let storedAiPanelSize = aiPanelSize > 0 ? aiPanelSize : 30
let testPanelSize = 30
let storedTestPanelSize = testPanelSize
function toggleAiPanel() {
if (!$copilotInfo.enabled) return
if (aiPanelSize > 0) {
storedAiPanelSize = aiPanelSize
codePanelSize += aiPanelSize
aiPanelSize = 0
localStorage.setItem('aiPanelOpen', 'false')
} else {
codePanelSize -= storedAiPanelSize
aiPanelSize = storedAiPanelSize
localStorage.setItem('aiPanelOpen', 'true')
}
}
function addSelectedLinesToAiChat(
e: CustomEvent<{ lines: string; startLine: number; endLine: number }>
) {
if (aiChat) {
aiChat.addSelectedLinesToContext(e.detail.lines, e.detail.startLine, e.detail.endLine)
aiChat.focusTextArea()
}
}
$: !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '') && aiPanelSize > 0 && toggleAiPanel()
function toggleTestPanel() {
if (testPanelSize > 0) {
storedTestPanelSize = testPanelSize
codePanelSize += testPanelSize
testPanelSize = 0
} else {
codePanelSize -= storedTestPanelSize
testPanelSize = storedTestPanelSize
}
}
let aiChat: AIChat | undefined = undefined
function getStringError(job: Job | undefined) {
if (
job != undefined &&
job.type === 'CompletedJob' &&
typeof job.result == 'object' &&
job.result != null &&
`error` in job.result &&
job.result.error
) {
return JSON.stringify(job.result.error, null, 2)
}
return undefined
}
function showDiffMode() {
diffMode = true
diffEditor?.setOriginal(lastDeployedCode ?? '')
diffEditor?.setModified(editor?.getCode() ?? '')
diffEditor?.show()
editor?.hide()
}
function hideDiffMode() {
diffMode = false
diffEditor?.hide()
editor?.show()
}
$: error = getStringError(testJob)
</script>
<TestJobLoader
on:done={loadPastTests}
bind:scriptProgress
bind:this={testJobLoader}
bind:isLoading={testIsLoading}
bind:job={testJob}
/>
<svelte:window on:keydown={onKeyDown} />
<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: Clipboard }}
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">
<EditorBar
scriptPath={edit ? path : undefined}
on:toggleCollabMode={() => {
if (wsProvider?.shouldConnect) {
disableCollaboration()
} else {
setCollaborationMode()
}
}}
on:showDiffMode={showDiffMode}
on:hideDiffMode={hideDiffMode}
customUi={{ ...customUi?.editorBar, aiGen: false }}
collabLive={wsProvider?.shouldConnect}
{collabMode}
{validCode}
iconOnly={width < EDITOR_BAR_WIDTH_THRESHOLD}
on:collabPopup={() => (showCollabPopup = true)}
{editor}
{lang}
on:createScriptFromInlineScript
{websocketAlive}
collabUsers={peers}
kind={asKind(kind)}
{template}
{diffEditor}
{args}
{noHistory}
{saveToWorkspace}
lastDeployedCode={lastDeployedCode && lastDeployedCode !== code
? lastDeployedCode
: undefined}
{diffMode}
bind:showHistoryDrawer
>
<slot name="editor-bar-right" slot="right" />
</EditorBar>
{#if !noSyncFromGithub && customUi?.editorBar?.useVsCode != false}
<div class="py-1">
<Button
target="_blank"
href="https://www.windmill.dev/docs/cli_local_dev/vscode-extension"
color="light"
size="xs"
btnClasses="hidden lg:flex"
startIcon={{
icon: Github
}}
>
Use VScode
</Button>
</div>
{/if}
</div>
</div>
<SplitPanesWrapper>
<Splitpanes class="!overflow-visible">
<Pane bind:size={codePanelSize} minSize={10} class="!overflow-visible">
<div class="h-full !overflow-visible bg-gray-50 dark:bg-[#272D38] relative">
<div class="absolute top-2 right-4 z-10 flex flex-row gap-2">
{#if aiPanelSize === 0}
{#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')}
<HideButton
hidden={true}
direction="right"
panelName="AI"
shortcut="L"
size="md"
usePopoverOverride={!$copilotInfo.enabled}
customHiddenIcon={WandSparkles}
btnClasses="!text-violet-800 dark:!text-violet-400 border border-gray-200 dark:border-gray-600 bg-surface"
on:click={() => {
toggleAiPanel()
}}
>
<svelte:fragment slot="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>
</svelte:fragment>
</HideButton>
{/if}
{#if testPanelSize === 0}
<HideButton
hidden={true}
direction="right"
size="md"
panelName="Test"
shortcut="U"
customHiddenIcon={PlayIcon}
on:click={() => {
toggleTestPanel()
}}
btnClasses="bg-marine-400 hover:bg-marine-200 !text-primary-inverse hover:!text-primary-inverse hover:dark:!text-primary-inverse dark:bg-marine-50 dark:hover:bg-marine-50/70"
color="marine"
/>
{/if}
{/if}
</div>
{#key lang}
<Editor
lineNumbersMinChars={4}
folding
{path}
bind:code
bind:websocketAlive
bind:this={editor}
{yContent}
awareness={wsProvider?.awareness}
on:change={(e) => {
inferSchema(e.detail)
}}
on:saveDraft
on:toggleAiPanel={toggleAiPanel}
on:addSelectedLinesToAiChat={addSelectedLinesToAiChat}
on:toggleTestPanel={toggleTestPanel}
isAiPanelOpen={aiPanelSize > 0}
cmdEnterAction={async () => {
await inferSchema(code)
runTest()
}}
formatAction={async () => {
await inferSchema(code)
try {
localStorage.setItem(path ?? 'last_save', code)
} catch (e) {
console.error('Could not save last_save to local storage', e)
}
dispatch('format')
}}
class="flex flex-1 h-full !overflow-visible"
scriptLang={lang}
automaticLayout={true}
{fixedOverflowWidgets}
{args}
/>
<DiffEditor
class="h-full"
bind:this={diffEditor}
automaticLayout
defaultLang={scriptLangToEditorLang(lang)}
{fixedOverflowWidgets}
showButtons={diffMode}
on:hideDiffMode={hideDiffMode}
on:seeHistory={() => {
showHistoryDrawer = true
}}
/>
{/key}
</div>
</Pane>
{#if lang && $copilotInfo.enabled}
<Pane bind:size={aiPanelSize} minSize={0}>
<AIChat
bind:this={aiChat}
{code}
{lang}
{error}
{args}
{path}
on:applyCode={(e) => {
hideDiffMode()
editor?.reviewAndApplyCode(e.detail.code)
}}
on:showDiffMode={showDiffMode}
{lastSavedCode}
{lastDeployedCode}
{diffMode}
>
<svelte:fragment slot="header-left">
<HideButton
hidden={false}
direction="right"
panelName="AI"
shortcut="L"
size="md"
on:click={() => {
toggleAiPanel()
}}
/>
</svelte:fragment>
<svelte:fragment slot="header-right">
{#if testPanelSize === 0}
<div class="bg-gray-200 h-6 w-[1px] rounded-full dark:bg-gray-600"></div>
<HideButton
hidden={true}
direction="right"
panelName="Test"
shortcut="U"
size="md"
customHiddenIcon={PlayIcon}
on:click={() => {
toggleTestPanel()
}}
btnClasses="bg-marine-400 hover:bg-marine-200 !text-primary-inverse hover:!text-primary-inverse hover:dark:!text-primary-inverse dark:bg-marine-50 dark:hover:bg-marine-50/70"
color="marine"
/>
{/if}
</svelte:fragment>
</AIChat>
</Pane>
{/if}
<Pane bind:size={testPanelSize} minSize={0}>
<div class="flex flex-col h-full">
{#if showTabs}
<div transition:slide={{ duration: 200 }}>
<Tabs bind:selected={selectedTab}>
<Tab value="main">Main</Tab>
{#if hasPreprocessor}
<div transition:slide={{ duration: 200, axis: 'x' }}>
<Tab value="preprocessor">Preprocessor</Tab>
</div>
{/if}
</Tabs>
</div>
{/if}
<div class="flex justify-center pt-1 relative">
<div class="absolute top-2 left-2">
<HideButton
hidden={false}
direction="right"
panelName="Test"
shortcut="U"
size="md"
on:click={() => {
toggleTestPanel()
}}
/>
</div>
{#if testIsLoading}
<Button on:click={testJobLoader?.cancelJob} btnClasses="w-full" color="red" size="xs">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="16px"
width="20px"
spin="fast"
/>
Cancel
</Button>
{:else if customUi?.previewPanel?.disableTriggerButton !== true}
<div class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch">
<Button
color="dark"
on:click={() => {
runTest()
}}
btnClasses="w-full rounded-r-none"
size="xs"
startIcon={{
icon: Play,
classes: 'animate-none'
}}
shortCut={{ Icon: CornerDownLeft, hide: testIsLoading }}
>
{#if testIsLoading}
Running
{:else}
Test
{/if}
</Button>
<CaptureButton on:openTriggers />
</div>
{:else}
<div class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch">
<Button
color="dark"
on:click={() => {
runTest()
}}
btnClasses="w-full"
size="xs"
startIcon={{
icon: Play,
classes: 'animate-none'
}}
shortCut={{ Icon: CornerDownLeft, hide: testIsLoading }}
>
{#if testIsLoading}
Running
{:else}
Test
{/if}
</Button>
</div>
{/if}
</div>
<Splitpanes horizontal class="!max-h-[calc(100%-43px)]">
<Pane size={33}>
<div class="px-4">
<div class="break-words relative font-sans">
{#key argsRender}
<SchemaForm
helperScript={{
type: 'inline',
code,
//@ts-ignore
lang
}}
compact
{schema}
bind:args
bind:isValid
noVariablePicker={customUi?.previewPanel?.disableVariablePicker === true}
showSchemaExplorer
/>
{/key}
</div>
</div>
</Pane>
<Pane size={67} class="relative">
<LogPanel
bind:setFocusToLogs
on:fix={() => {
aiChat?.fix()
}}
fixChatMode
{lang}
previewJob={testJob}
{pastPreviews}
previewIsLoading={testIsLoading}
{editor}
{diffEditor}
{args}
{showCaptures}
customUi={customUi?.previewPanel}
>
{#if scriptProgress}
<!-- Put to the slot in logpanel -->
<JobProgressBar
job={testJob}
bind:scriptProgress
bind:reset={jobProgressReset}
compact={true}
/>
{/if}
<svelte:fragment slot="capturesTab">
<div class="h-full p-2">
<CaptureTable
bind:this={captureTable}
{hasPreprocessor}
canHavePreprocessor={lang === 'bun' || lang === 'deno' || lang === 'python3'}
isFlow={false}
path={stablePathForCaptures}
canEdit={true}
on:applyArgs
on:updateSchema
on:addPreprocessor
/>
</div>
</svelte:fragment>
</LogPanel>
</Pane>
</Splitpanes>
</div>
</Pane>
</Splitpanes>
</SplitPanesWrapper>