feat: add support for flows in vscode extension (#2585)

* all

* change tagline

* all

* all

* all
This commit is contained in:
Ruben Fiszel
2023-11-07 20:16:01 +01:00
committed by GitHub
parent 73bdaaf756
commit f1a3afe2b0
11 changed files with 496 additions and 525 deletions
+9 -2
View File
@@ -524,7 +524,14 @@ pub async fn add_completed_job<
);
}
if let Err(e) = send_error_to_workspace_handler(rsmq.clone(), &queued_job, canceled_by.is_some(), db, result).await
if let Err(e) = send_error_to_workspace_handler(
rsmq.clone(),
&queued_job,
canceled_by.is_some(),
db,
result,
)
.await
{
tracing::error!(
"Could not run workspace error handler for job {}: {}",
@@ -2623,7 +2630,7 @@ pub async fn push<'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection
)
.fetch_one(&mut tx)
.await
.map_err(|e| Error::InternalErr(format!("Could not insert into queue {job_id}: {e}")))?;
.map_err(|e| Error::InternalErr(format!("Could not insert into queue {job_id} with tag {tag}, schedule_path {schedule_path:?}, script_path: {script_path:?}, email {email}, workspace_id {workspace_id}: {e}")))?;
// TODO: technically the job isn't queued yet, as the transaction can be rolled back. Should be solved when moving these metrics to the queue abstraction.
if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
-22
View File
@@ -1,27 +1,5 @@
import { BROWSER } from 'esm-env'
import { premiumStore, userStore, workspaceStore } from './stores'
import { getUserExt } from './user'
import { WorkspaceService } from './gen'
export function isCloudHosted(): boolean {
return BROWSER && window.location.hostname == 'app.windmill.dev'
}
if (BROWSER) {
workspaceStore.subscribe(async (workspace) => {
if (workspace) {
try {
localStorage.setItem('workspace', String(workspace))
} catch (e) {
console.error('Could not persist workspace to local storage', e)
}
const user = await getUserExt(workspace)
userStore.set(user)
if (isCloudHosted() && user?.is_admin) {
premiumStore.set(await WorkspaceService.getPremiumInfo({ workspace }))
}
} else {
userStore.set(undefined)
}
})
}
+426
View File
@@ -0,0 +1,426 @@
<script lang="ts">
import SchemaForm from '$lib/components/SchemaForm.svelte'
import TestJobLoader from '$lib/components/TestJobLoader.svelte'
import { Button, Kbd } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
import LogPanel from '$lib/components/scriptEditor/LogPanel.svelte'
import {
CompletedJob,
Job,
JobService,
OpenAPI,
Preview,
type OpenFlow,
type FlowModule
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { userStore, workspaceStore } from '$lib/stores'
import { emptySchema, getModifierKey, sendUserToast } from '$lib/utils'
import { faPlay } from '@fortawesome/free-solid-svg-icons'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { onDestroy, onMount, setContext } from 'svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import { page } from '$app/stores'
import { getUserExt } from '$lib/user'
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
import FlowModuleSchemaMap from './flows/map/FlowModuleSchemaMap.svelte'
import FlowEditorPanel from './flows/content/FlowEditorPanel.svelte'
import { deepEqual } from 'fast-equals'
import { writable } from 'svelte/store'
import type { FlowState } from './flows/flowState'
import { initHistory } from '$lib/history'
import type { FlowEditorContext } from './flows/types'
$: token = $page.url.searchParams.get('wm_token') ?? undefined
$: workspace = $page.url.searchParams.get('workspace') ?? undefined
$: themeDarkRaw = $page.url.searchParams.get('activeColorTheme')
$: themeDark = themeDarkRaw == '2' || themeDarkRaw == '4'
$: if (token) {
OpenAPI.WITH_CREDENTIALS = true
OpenAPI.TOKEN = $page.url.searchParams.get('wm_token')!
}
$: if (workspace) {
$workspaceStore = workspace
}
$: if (workspace && token) {
loadUser()
}
async function loadUser() {
const user = await getUserExt(workspace!)
$userStore = user
}
let darkModeToggle: DarkModeToggle
let darkMode: boolean | undefined = undefined
let modeInitialized = false
function initializeMode() {
modeInitialized = true
darkModeToggle.toggle()
}
$: darkModeToggle &&
themeDark != darkMode &&
darkMode != undefined &&
!modeInitialized &&
initializeMode()
let testJobLoader: TestJobLoader
// Test args input
let args: Record<string, any> = {}
let isValid: boolean = true
// Test
let testIsLoading = false
let testJob: Job | undefined
let pastPreviews: CompletedJob[] = []
let validCode = true
type LastEditScript = {
content: string
path: string
language: Preview.language
}
let currentScript: LastEditScript | undefined = undefined
let schema = emptySchema()
const href = window.location.href
const indexQ = href.indexOf('?')
const searchParams = indexQ > -1 ? new URLSearchParams(href.substring(indexQ)) : undefined
if (searchParams?.has('local')) {
connectWs()
}
let lockChanges = false
let timeout: NodeJS.Timeout | undefined = undefined
const el = (event) => {
// sendUserToast(`Received message from parent ${event.data.type}`, true)
if (event.data.type == 'runTest') {
runTest()
} else if (event.data.type == 'replaceScript') {
mode = 'script'
replaceScript(event.data)
} else if (event.data.type == 'replaceFlow') {
mode = 'flow'
lockChanges = true
replaceFlow(event.data)
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
lockChanges = false
}, 500)
} else if (event.data.type == 'error') {
sendUserToast(event.data.error.message, true)
}
}
onMount(() => {
window.addEventListener('message', el, false)
document.addEventListener('keydown', (e) => {
const obj = {
altKey: e.altKey,
code: e.code,
ctrlKey: e.ctrlKey,
isComposing: e.isComposing,
key: e.key,
location: e.location,
metaKey: e.metaKey,
repeat: e.repeat,
shiftKey: e.shiftKey
}
if (obj.ctrlKey && obj.key == 'a') {
e.stopPropagation()
return
}
window.parent?.postMessage({ type: 'keydown', key: JSON.stringify(obj) }, '*')
})
window.parent?.postMessage({ type: 'refresh' }, '*')
})
onDestroy(() => {
window.removeEventListener('message', el)
if (socket && socket.readyState === WebSocket.OPEN) {
socket?.close()
}
})
let socket: WebSocket | undefined = undefined
function connectWs() {
const port = searchParams?.get('port') || '3001'
try {
socket = new WebSocket(`ws://localhost:${port}/ws`)
// Listen for messages
socket.addEventListener('message', (event) => {
replaceData(event.data)
})
function replaceData(msg: string) {
let data: any | undefined = undefined
try {
data = JSON.parse(msg)
} catch {
console.log('Received invalid JSON: ' + msg)
return
}
replaceScript(data)
}
} catch (e) {
sendUserToast('Failed to connect to local server', true)
console.error(e)
}
}
function runTest() {
if (mode == 'script') {
if (!currentScript) {
return
}
//@ts-ignore
testJobLoader.runPreview(
currentScript.path,
currentScript.content,
currentScript.language,
args,
undefined
)
} else {
flowPreviewButtons?.openPreview()
}
}
async function loadPastTests(): Promise<void> {
if (!currentScript) {
return
}
console.log('Loading past tests')
pastPreviews = await JobService.listCompletedJobs({
workspace: $workspaceStore!,
jobKinds: 'preview',
createdBy: $userStore?.username,
scriptPathExact: currentScript.path
})
}
function onKeyDown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key == 'Enter') {
event.preventDefault()
runTest()
}
}
let lastPath: string | undefined = undefined
async function replaceScript(lastEdit: LastEditScript) {
currentScript = lastEdit
if (lastPath !== lastEdit.path) {
schema = emptySchema()
}
try {
await inferArgs(lastEdit.language, lastEdit.content, schema)
schema = schema
lastPath = lastEdit.path
validCode = true
} catch (e) {
console.error(e)
validCode = false
}
}
let mode: 'script' | 'flow' = 'script'
const flowStore = writable({
summary: '',
value: { modules: [] },
extra_perms: {},
schema: emptySchema()
} as OpenFlow)
type LastEditFlow = {
flow: OpenFlow
uriPath: string
}
let lastUriPath: string | undefined = undefined
async function replaceFlow(lastEdit: LastEditFlow) {
lastUriPath = lastEdit.uriPath
// sendUserToast(JSON.stringify(lastEdit.flow), true)
// return
try {
if (!deepEqual(lastEdit.flow, $flowStore)) {
$flowStore = lastEdit.flow
}
} catch (e) {
console.error('issue setting new flowstore', e)
}
}
const flowStateStore = writable({} as FlowState)
const scheduleStore = writable({
args: {},
cron: '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: false
})
const previewArgsStore = writable<Record<string, any>>({})
const scriptEditorDrawer = writable(undefined)
const moving = writable<{ module: FlowModule; modules: FlowModule[] } | undefined>(undefined)
const history = initHistory($flowStore)
const testStepStore = writable<Record<string, any>>({})
const selectedIdStore = writable('settings-metadata')
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
schedule: scheduleStore,
previewArgs: previewArgsStore,
scriptEditorDrawer,
moving,
history,
pathStore: writable(''),
flowStateStore,
flowStore,
testStepStore,
saveDraft: () => {},
initialPath: ''
})
$: updateCode($flowStore)
let lastSent: OpenFlow | undefined = undefined
function updateCode(flow: OpenFlow) {
if (lockChanges) {
return
}
if (!deepEqual(flow, lastSent)) {
lastSent = JSON.parse(JSON.stringify(flow))
window?.parent.postMessage({ type: 'flow', flow, uriPath: lastUriPath }, '*')
}
}
let flowPreviewButtons: FlowPreviewButtons
</script>
<svelte:window on:keydown={onKeyDown} />
<TestJobLoader
on:done={loadPastTests}
bind:this={testJobLoader}
bind:isLoading={testIsLoading}
bind:job={testJob}
/>
<main class="h-screen w-full">
{#if mode == 'script'}
<div class="flex flex-col h-full">
<div class="absolute top-0 left-2">
<DarkModeToggle bind:darkMode bind:this={darkModeToggle} forcedDarkMode={false} />
</div>
<div class="text-center w-full text-lg truncate py-1 text-primary">
{currentScript?.path ?? 'Not editing a script'}
{currentScript?.language ?? ''}
</div>
<div class="absolute top-2 right-2 !text-tertiary text-xs">
{#if $userStore != undefined}
As {$userStore?.username} in {$workspaceStore}
{:else}
<span class="text-red-600">Unable to login</span>
{/if}
</div>
{#if !validCode}
<div class="text-center w-full text-lg truncate py-1 text-red-500">Invalid code</div>
{/if}
<div class="flex justify-center pt-1">
{#if testIsLoading}
<Button on:click={testJobLoader?.cancelJob} btnClasses="w-full" color="red" size="xs">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="20px"
width="20px"
spin="fast"
/>
Cancel
</Button>
{:else}
<Button
disabled={currentScript === undefined}
color="dark"
on:click={() => {
runTest()
}}
btnClasses="w-full"
size="xs"
startIcon={{
icon: faPlay,
classes: 'animate-none'
}}
>
{#if testIsLoading}
Running
{:else}
Test&nbsp;<Kbd small isModifier>{getModifierKey()}</Kbd>
<Kbd small><span class="text-lg font-bold"></span></Kbd>
{/if}
</Button>
{/if}
</div>
<Splitpanes horizontal class="h-full">
<Pane size={33}>
<div class="px-2">
<div class="break-words relative font-sans">
<SchemaForm compact {schema} bind:args bind:isValid />
</div>
</div>
</Pane>
<Pane size={67}>
<LogPanel
lang={currentScript?.language}
previewJob={testJob}
{pastPreviews}
previewIsLoading={testIsLoading}
/>
</Pane>
</Splitpanes>
</div>
{:else}
<!-- <div class="h-full w-full grid grid-cols-2"> -->
<div class="h-full w-full">
<div class="flex flex-col max-h-screen h-full relative">
<div class="absolute top-0 left-2">
<DarkModeToggle bind:darkMode bind:this={darkModeToggle} forcedDarkMode={false} />
{#if $userStore}
As {$userStore?.username} in {$workspaceStore}
{:else}
<span class="text-red-600">Unable to login on {$workspaceStore}</span>
{/if}
</div>
<div class="flex justify-center pt-1 z-50 absolute right-2 top-2 gap-2">
<FlowPreviewButtons bind:this={flowPreviewButtons} />
</div>
<Splitpanes horizontal class="h-full max-h-screen grow">
<Pane size={67}>
{#if $flowStore?.value?.modules}
<FlowModuleSchemaMap
bind:modules={$flowStore.value.modules}
disableAi
disableTutorials
smallErrorHandler={true}
disableStaticInputs
/>
{:else}
<div class="text-red-400 mt-20">Missing flow modules</div>
{/if}
</Pane>
<Pane size={33}>
<FlowEditorPanel noEditor />
</Pane>
</Splitpanes>
</div>
</div>
{/if}
</main>
@@ -40,6 +40,10 @@
let isRunning: boolean = false
let jobProgressReset: () => void
export function test() {
runPreview($previewArgs, undefined)
}
const { selectedId, previewArgs, flowStateStore, flowStore, pathStore, initialPath } =
getContext<FlowEditorContext>('FlowEditorContext')
const dispatch = createEventDispatcher()
@@ -97,14 +97,21 @@
// If editing the arg's name, oldName containing the old argument name must be provided
argError = ''
modalProperty.name = modalProperty.name.trim()
if (modalProperty.name.length === 0) {
argError = 'Arguments need to have a name'
} else if (
Object.keys(schema.properties).includes(modalProperty.name) &&
Object.keys(schema.properties ?? {}).includes(modalProperty.name) &&
(!editing || (editing && oldArgName && oldArgName !== modalProperty.name))
) {
argError = 'There is already an argument with this name'
} else {
if (!schema.properties) {
schema.properties = {}
}
if (!schema.required) {
schema.required = []
}
schema.properties[modalProperty.name] = modalToSchema(modalProperty)
if (modalProperty.required) {
if (!schema.required.includes(modalProperty.name)) {
@@ -126,10 +133,12 @@
schemaModal.closeDrawer()
}
schema = schema
syncOrders()
schemaString = JSON.stringify(schema, null, '\t')
jsonEditor?.setCode(schemaString)
sendUserToast('FOO')
dispatch('change', schema)
}
@@ -13,6 +13,15 @@
let previewOpen = false
let previewMode: 'upTo' | 'whole' = 'whole'
export async function openPreview() {
if (!previewOpen) {
previewOpen = true
} else {
flowPreviewContent?.test()
}
}
let flowPreviewContent: FlowPreviewContent
let jobId: string | undefined = undefined
let job: Job | undefined = undefined
@@ -73,6 +82,7 @@
<Drawer bind:open={previewOpen} alwaysOpen size="75%">
<FlowPreviewContent
bind:this={flowPreviewContent}
open={previewOpen}
bind:previewMode
bind:job
@@ -9,13 +9,21 @@
OpenAPI,
RawAppService,
ScriptService,
UserService
UserService,
WorkspaceService
} from '$lib/gen'
import { classNames } from '$lib/utils'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
import { starStore, superadmin, usageStore, userStore, workspaceStore } from '$lib/stores'
import {
premiumStore,
starStore,
superadmin,
usageStore,
userStore,
workspaceStore
} from '$lib/stores'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { afterNavigate, beforeNavigate, goto } from '$app/navigation'
import UserSettings from '$lib/components/UserSettings.svelte'
@@ -26,6 +34,7 @@
import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { isCloudHosted } from '$lib/cloud'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { getUserExt } from '$lib/user'
OpenAPI.WITH_CREDENTIALS = true
let menuOpen = false
@@ -50,6 +59,25 @@
superadminSettings.openDrawer()
}
$: updateUserStore($workspaceStore)
async function updateUserStore(workspace: string | undefined) {
if (workspace) {
try {
localStorage.setItem('workspace', String(workspace))
} catch (e) {
console.error('Could not persist workspace to local storage', e)
}
const user = await getUserExt(workspace)
userStore.set(user)
if (isCloudHosted() && user?.is_admin) {
premiumStore.set(await WorkspaceService.getPremiumInfo({ workspace }))
}
} else {
userStore.set(undefined)
}
}
beforeNavigate(() => {
menuOpen = false
})
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import Dev from '$lib/components/Dev.svelte'
</script>
<Dev />
+2 -270
View File
@@ -1,273 +1,5 @@
<script lang="ts">
import SchemaForm from '$lib/components/SchemaForm.svelte'
import TestJobLoader from '$lib/components/TestJobLoader.svelte'
import { Button, Kbd } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
import LogPanel from '$lib/components/scriptEditor/LogPanel.svelte'
import { CompletedJob, Job, JobService, OpenAPI, Preview } from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { userStore, workspaceStore } from '$lib/stores'
import { emptySchema, getModifierKey, sendUserToast } from '$lib/utils'
import { faPlay } from '@fortawesome/free-solid-svg-icons'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { onDestroy, onMount } from 'svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import { page } from '$app/stores'
import { getUserExt } from '$lib/user'
let token = $page.url.searchParams.get('wm_token') ?? undefined
let workspace = $page.url.searchParams.get('workspace') ?? undefined
let themeDarkRaw = $page.url.searchParams.get('activeColorTheme')
let themeDark = themeDarkRaw == '2' || themeDarkRaw == '4'
if (token) {
OpenAPI.WITH_CREDENTIALS = true
OpenAPI.TOKEN = $page.url.searchParams.get('wm_token')!
}
if (workspace) {
$workspaceStore = workspace
}
if (workspace && token) {
loadUser()
}
async function loadUser() {
const user = await getUserExt(workspace!)
userStore.set(user)
}
let darkModeToggle: DarkModeToggle
let darkMode: boolean | undefined = undefined
let modeInitialized = false
function initializeMode() {
modeInitialized = true
darkModeToggle.toggle()
}
$: darkModeToggle &&
themeDark != darkMode &&
darkMode != undefined &&
!modeInitialized &&
initializeMode()
let testJobLoader: TestJobLoader
// Test args input
let args: Record<string, any> = {}
let isValid: boolean = true
// Test
let testIsLoading = false
let testJob: Job | undefined
let pastPreviews: CompletedJob[] = []
let validCode = true
type LastEdit = {
content: string
path: string
language: Preview.language
}
let currentScript: LastEdit | undefined = undefined
let schema = emptySchema()
const href = window.location.href
const indexQ = href.indexOf('?')
const searchParams = indexQ > -1 ? new URLSearchParams(href.substring(indexQ)) : undefined
if (searchParams?.has('local')) {
connectWs()
}
const el = (event) => {
if (event.data.type == 'runTest') {
runTest()
} else if (event.data.type == 'replaceScript') {
replaceScript(event.data)
}
}
onMount(() => {
window.addEventListener('message', el, false)
document.addEventListener('keydown', (e) => {
const obj = {
altKey: e.altKey,
code: e.code,
ctrlKey: e.ctrlKey,
isComposing: e.isComposing,
key: e.key,
location: e.location,
metaKey: e.metaKey,
repeat: e.repeat,
shiftKey: e.shiftKey
}
window.parent?.postMessage(JSON.stringify(obj), '*')
})
})
onDestroy(() => {
window.removeEventListener('message', el)
})
function connectWs() {
const port = searchParams?.get('port') || '3001'
try {
const socket = new WebSocket(`ws://localhost:${port}/ws`)
// Listen for messages
socket.addEventListener('message', (event) => {
replaceData(event.data)
})
function replaceData(msg: string) {
let data: any | undefined = undefined
try {
data = JSON.parse(msg)
} catch {
console.log('Received invalid JSON: ' + msg)
return
}
replaceScript(data)
}
} catch (e) {
sendUserToast('Failed to connect to local server', true)
console.error(e)
}
}
function runTest() {
if (!currentScript) {
return
}
//@ts-ignore
testJobLoader.runPreview(
currentScript.path,
currentScript.content,
currentScript.language,
args,
undefined
)
}
async function loadPastTests(): Promise<void> {
if (!currentScript) {
return
}
console.log('Loading past tests')
pastPreviews = await JobService.listCompletedJobs({
workspace: $workspaceStore!,
jobKinds: 'preview',
createdBy: $userStore?.username,
scriptPathExact: currentScript.path
})
}
function onKeyDown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key == 'Enter') {
event.preventDefault()
runTest()
}
}
let lastPath: string | undefined = undefined
async function replaceScript(lastEdit: LastEdit) {
currentScript = lastEdit
if (lastPath !== lastEdit.path) {
schema = emptySchema()
}
try {
await inferArgs(lastEdit.language, lastEdit.content, schema)
schema = schema
lastPath = lastEdit.path
validCode = true
} catch (e) {
console.error(e)
validCode = false
}
}
import Dev from '$lib/components/Dev.svelte'
</script>
<svelte:window on:keydown={onKeyDown} />
<TestJobLoader
on:done={loadPastTests}
bind:this={testJobLoader}
bind:isLoading={testIsLoading}
bind:job={testJob}
/>
<main class="h-screen w-full">
<div class="flex flex-col h-full">
<div class="absolute top-0 left-2">
<DarkModeToggle bind:darkMode bind:this={darkModeToggle} forcedDarkMode={false} />
</div>
<div class="text-center w-full text-lg truncate py-1 text-primary">
{currentScript?.path ?? 'Not editing a script'}
{currentScript?.language ?? ''}
</div>
<div class="absolute top-2 right-2 !text-tertiary text-xs">
{#if $userStore}
As {$userStore?.username} in {$workspaceStore}
{:else}
<span class="text-red-600">Unable to login</span>
{/if}
</div>
{#if !validCode}
<div class="text-center w-full text-lg truncate py-1 text-red-500">Invalid code</div>
{/if}
<div class="flex justify-center pt-1">
{#if testIsLoading}
<Button on:click={testJobLoader?.cancelJob} btnClasses="w-full" color="red" size="xs">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="20px"
width="20px"
spin="fast"
/>
Cancel
</Button>
{:else}
<Button
disabled={currentScript === undefined}
color="dark"
on:click={() => {
runTest()
}}
btnClasses="w-full"
size="xs"
startIcon={{
icon: faPlay,
classes: 'animate-none'
}}
>
{#if testIsLoading}
Running
{:else}
Test&nbsp;<Kbd small isModifier>{getModifierKey()}</Kbd>
<Kbd small><span class="text-lg font-bold"></span></Kbd>
{/if}
</Button>
{/if}
</div>
<Splitpanes horizontal class="h-full">
<Pane size={33}>
<div class="px-2">
<div class="break-words relative font-sans">
<SchemaForm compact {schema} bind:args bind:isValid />
</div>
</div>
</Pane>
<Pane size={67}>
<LogPanel
lang={currentScript?.language}
previewJob={testJob}
{pastPreviews}
previewIsLoading={testIsLoading}
/>
</Pane>
</Splitpanes>
</div>
</main>
<Dev />
@@ -1,228 +0,0 @@
<script lang="ts">
import SchemaForm from '$lib/components/SchemaForm.svelte'
import TestJobLoader from '$lib/components/TestJobLoader.svelte'
import { Button, Kbd } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
import LogPanel from '$lib/components/scriptEditor/LogPanel.svelte'
import { CompletedJob, Job, JobService, Preview } from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { userStore, workspaceStore } from '$lib/stores'
import { emptySchema, getModifierKey, sendUserToast } from '$lib/utils'
import { faPlay } from '@fortawesome/free-solid-svg-icons'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { onDestroy, onMount } from 'svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
let testJobLoader: TestJobLoader
// Test args input
let args: Record<string, any> = {}
let isValid: boolean = true
// Test
let testIsLoading = false
let testJob: Job | undefined
let pastPreviews: CompletedJob[] = []
let validCode = true
type LastEdit = {
content: string
path: string
language: Preview.language
}
let currentScript: LastEdit | undefined = undefined
let schema = emptySchema()
const href = window.location.href
const indexQ = href.indexOf('?')
const searchParams = indexQ > -1 ? new URLSearchParams(href.substring(indexQ)) : undefined
if (searchParams?.has('local')) {
connectWs()
}
const el = (event) => {
if (event.data.type == 'runTest') {
runTest()
} else if (event.data.type == 'replaceScript') {
replaceScript(event.data)
}
}
onMount(() => {
window.addEventListener('message', el, false)
document.addEventListener('keydown', (e) => {
const obj = {
altKey: e.altKey,
code: e.code,
ctrlKey: e.ctrlKey,
isComposing: e.isComposing,
key: e.key,
location: e.location,
metaKey: e.metaKey,
repeat: e.repeat,
shiftKey: e.shiftKey
}
window.parent?.postMessage(JSON.stringify(obj), '*')
})
})
onDestroy(() => {
window.removeEventListener('message', el)
})
function connectWs() {
const port = searchParams?.get('port') || '3001'
try {
const socket = new WebSocket(`ws://localhost:${port}/ws`)
// Listen for messages
socket.addEventListener('message', (event) => {
replaceData(event.data)
})
function replaceData(msg: string) {
let data: any | undefined = undefined
try {
data = JSON.parse(msg)
} catch {
console.log('Received invalid JSON: ' + msg)
return
}
replaceScript(data)
}
} catch (e) {
sendUserToast('Failed to connect to local server', true)
console.error(e)
}
}
function runTest() {
if (!currentScript) {
return
}
//@ts-ignore
testJobLoader.runPreview(
currentScript.path,
currentScript.content,
currentScript.language,
args,
undefined
)
}
async function loadPastTests(): Promise<void> {
if (!currentScript) {
return
}
console.log('Loading past tests')
pastPreviews = await JobService.listCompletedJobs({
workspace: $workspaceStore!,
jobKinds: 'preview',
createdBy: $userStore?.username,
scriptPathExact: currentScript.path
})
}
function onKeyDown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key == 'Enter') {
event.preventDefault()
runTest()
}
}
let lastPath: string | undefined = undefined
async function replaceScript(lastEdit: LastEdit) {
currentScript = lastEdit
if (lastPath !== lastEdit.path) {
schema = emptySchema()
}
try {
await inferArgs(lastEdit.language, lastEdit.content, schema)
schema = schema
lastPath = lastEdit.path
validCode = true
} catch (e) {
console.error(e)
validCode = false
}
}
</script>
<svelte:window on:keydown={onKeyDown} />
<TestJobLoader
on:done={loadPastTests}
bind:this={testJobLoader}
bind:isLoading={testIsLoading}
bind:job={testJob}
/>
<main class="h-screen w-full">
<div class="flex flex-col h-full">
<div class="absolute top-0 right-2">
<DarkModeToggle forcedDarkMode={false} />
</div>
<div class="text-center w-full text-lg truncate py-1 dark:text-white">
{currentScript?.path ?? 'Not editing a script'}
{currentScript?.language ?? ''}
</div>
{#if !validCode}
<div class="text-center w-full text-lg truncate py-1 text-red-500">Invalid code</div>
{/if}
<div class="flex justify-center pt-1">
{#if testIsLoading}
<Button on:click={testJobLoader?.cancelJob} btnClasses="w-full" color="red" size="xs">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="20px"
width="20px"
spin="fast"
/>
Cancel
</Button>
{:else}
<Button
disabled={currentScript === undefined}
color="dark"
on:click={() => {
runTest()
}}
btnClasses="w-full"
size="xs"
startIcon={{
icon: faPlay,
classes: 'animate-none'
}}
>
{#if testIsLoading}
Running
{:else}
Test&nbsp;<Kbd small isModifier>{getModifierKey()}</Kbd>
<Kbd small><span class="text-lg font-bold"></span></Kbd>
{/if}
</Button>
{/if}
</div>
<Splitpanes horizontal class="h-full">
<Pane size={33}>
<div class="px-2">
<div class="break-words relative font-sans">
<SchemaForm compact {schema} bind:args bind:isValid />
</div>
</div>
</Pane>
<Pane size={67}>
<LogPanel
lang={currentScript?.language}
previewJob={testJob}
{pastPreviews}
previewIsLoading={testIsLoading}
/>
</Pane>
</Splitpanes>
</div>
</main>