Files
windmill/frontend/src/lib/components/WorkerRepl.svelte
T
Diego Imbert fa8d1b47db fix: new MultiSelect component (#5979)
* rename all multiselects to Legacy

* move Select

* Separate SelectDropdown

* fix click outside for select with portal

* multiselect v0

* multiselect clear btn

* move filterText logic to SelectDropdown

* ui nits

* console.log

* draggable

* Draggable multiselect

* multiselect search

* nit refacto

* autofocus multiselect input

* Replace in AppMultiSelectV2

* search icon

* app multi select nits

* arginput update multiselect

* fix autofocus scrolling up

* replace High priority tags multiselect

* autoscaling config editor multiselect replace

* fix clear btn not in border

* replace multiselect in cron input

* replace multiselect in savedinputs

* replace EventHandlerItem multiselect

* select dropdown shadow

* more multiselect migration

* hover opacity on drag

* TokensTable UI fixes + replace multiselect

* ai settings replace multiselect

* DefaultTags Multiselect replace

* prevent multiselect from opening on drag

* nit

* app multiselect css + simplify

* console log

* safeSelectItems cleanup

* Remove svelte-multiselect

* clip when wrap not allowed

* hide duplicate app components

* CSS works better  with multiselect in app editor

* allowOverflow

* allowClear

* fix custom createText messed up with search
2025-06-19 14:37:17 +02:00

428 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte'
import { Terminal } from 'xterm'
import 'xterm/css/xterm.css'
import { pollJobResult, runScript } from './jobs/utils'
import { workspaceStore } from '$lib/stores'
import { Badge, Button, Drawer, DrawerContent, Skeleton } from './common'
import DarkModeObserver from './DarkModeObserver.svelte'
import { Eye, Library, Play, RefreshCw, Square } from 'lucide-svelte'
import Editor from './Editor.svelte'
import WorkspaceScriptPicker from './flows/pickers/WorkspaceScriptPicker.svelte'
import ToggleHubWorkspace from './ToggleHubWorkspace.svelte'
import type { SupportedLanguage } from '$lib/common'
import HighlightCode from './HighlightCode.svelte'
import PickHubScript from './flows/pickers/PickHubScript.svelte'
import { getScriptByPath } from '$lib/scripts'
import { FitAddon } from '@xterm/addon-fit'
import { Readline } from 'xterm-readline'
import Tooltip from './Tooltip.svelte'
import { JobService, type QueuedJob } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Select from './select/Select.svelte'
import { emptyString } from '$lib/utils'
let container: HTMLDivElement
let term: Terminal
let input = ''
type Props = {
tag: string
width?: number
}
let scriptPicker: Drawer | undefined = $state()
let editor = $state<Editor | null>(null)
let darkMode = $state(false)
let pick_existing: 'workspace' | 'hub' = $state('workspace')
let jobId = $state('')
let selectedJobId = $state('')
let codeViewer: Drawer | undefined = $state()
let filter = $state('')
let { tag }: Props = $props()
let code: string = $state('')
let working_directory = $state('~')
let homeDirectory: string = '~'
let pendingsJobs: Array<QueuedJob> = $state([])
let loadingPendingJobs = $state(false)
let isCancelingJob = $state(false)
let prompt = $derived(
`$-${working_directory === '/' ? '/' : working_directory.split('/').at(-1)} `
)
let codeObj: { language: SupportedLanguage; content: string } | undefined = $state(undefined)
function getNewWorkingDirectoryPath(currentDir: string, newPath: string): string {
if (newPath.startsWith('/') || newPath.startsWith('~')) {
return newPath
}
return currentDir.replace(/\/+$/, '') + '/' + newPath
}
function ensureTrailingLineBreak(input: any): boolean {
if (typeof input !== 'string') {
return false
}
return input.endsWith('\r\n') || input.endsWith('\n')
}
function isSimpleCdCommand(command: string): boolean {
const trimmed = command.trim()
// Matches:
// - Starts with "cd"
// - Followed by any number of valid args (quoted or not)
// - No use of &, |, ; outside quotes
// - No other commands
const cdRegex = /^cd(\s+("[^"]*"|'[^']*'|[^\s"'&|;]+))*\s*$/
return cdRegex.test(trimmed)
}
async function handleCommand(command: string) {
try {
const trimmedCommand = command.trim()
if (trimmedCommand.length === 0) return
const isOnlyCdCommand = isSimpleCdCommand(trimmedCommand)
let wDirectory = working_directory
if (isOnlyCdCommand) {
const parts = trimmedCommand.split(' ')
if (parts.length > 1) {
const path = parts.slice(1).join(' ')
const newPath = getNewWorkingDirectoryPath(working_directory, path)
wDirectory = newPath
} else {
wDirectory = homeDirectory
}
}
jobId = await runScript({
workspace: $workspaceStore!,
requestBody: {
language: 'bash',
content: `(cd ${wDirectory} && ${isOnlyCdCommand ? 'pwd' : `${trimmedCommand}`}) > result.out`,
tag,
args: {}
}
})
let result: any = await pollJobResult(jobId, $workspaceStore!)
if (isOnlyCdCommand) {
working_directory = (result as string).replace(/(\r\n|\n|\r)/g, '')
result = ''
} else if (!ensureTrailingLineBreak(result)) {
result += '\r\n'
}
rl.write(result)
} catch (e) {
term.writeln(`Error: ${e}`)
}
}
const rl = new Readline()
async function listPendingJobs() {
try {
loadingPendingJobs = true
pendingsJobs = await JobService.listQueue({
workspace: $workspaceStore!,
tag,
running: true
})
} catch (error) {
sendUserToast(error.body || error.message)
} finally {
loadingPendingJobs = false
}
}
async function listPendingJobsAndUpdateSelectedJobid() {
await listPendingJobs()
if (!pendingsJobs.find((pendingJob) => pendingJob.id === selectedJobId)) {
selectedJobId = ''
}
}
async function cancelJob(jobId: string) {
try {
await JobService.cancelQueuedJob({
workspace: $workspaceStore!,
id: jobId,
requestBody: {}
})
} catch (err) {
sendUserToast(err.body || err.message, true)
}
}
onMount(async () => {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
theme: {
background: '#1e1e1e',
foreground: '#ffffff'
},
fontFamily: 'monospace',
convertEol: true,
rightClickSelectsWord: true
})
term.loadAddon(rl)
function readLine() {
rl.read(prompt).then(processLine)
}
async function processLine(text: string) {
await handleCommand(text)
setTimeout(readLine)
}
rl.setCtrlCHandler(async () => {
await cancelJob(jobId)
rl.read(prompt).then(processLine)
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(container)
term.focus()
fitAddon.fit()
const resizeObserver = new ResizeObserver(() => fitAddon.fit())
resizeObserver.observe(container)
//only used to silence unused variable warning from svelte compiler
editor?.show()
readLine()
})
function clearPrompt() {
const buffer = term.buffer.active
const lastLineIndex = buffer.baseY + buffer.cursorY
for (let i = lastLineIndex; i >= 0; i--) {
const line = buffer.getLine(i)
if (!line) break
const text = line.translateToString()
const position = text.indexOf(prompt)
if (position !== -1) {
const x = position + prompt.length + 1
term.write(`\x1b[${x}G`)
const numSpaces = text.length - x
term.write(' '.repeat(numSpaces))
term.write(`\x1b[${x}G`)
break
} else {
term.write('\x1b[2K\r')
}
term.write(`\x1b[1A`)
}
}
async function onScriptPick(e: { detail: { path: string } }) {
codeObj = await getScriptByPath(e.detail.path ?? '')
codeViewer?.openDrawer?.()
}
async function replacePromptWithCommand(command: string) {
clearPrompt()
if (!ensureTrailingLineBreak(command)) {
command += '\r\n'
}
input = command
rl.appendHistory(command)
term.write(command)
await handleCommand(input)
term.write(prompt)
}
listPendingJobs()
</script>
<DarkModeObserver bind:darkMode />
<Drawer bind:this={codeViewer} size="600px">
<DrawerContent title="Code" on:close={codeViewer.closeDrawer}>
{#if codeObj}
<HighlightCode language={codeObj?.language} code={codeObj?.content} />
{:else}
<Skeleton layout={[[40]]} />
{/if}
</DrawerContent>
</Drawer>
<Drawer bind:this={scriptPicker} size="900px">
<DrawerContent title="Code" on:close={scriptPicker.closeDrawer}>
{#if pick_existing == 'hub'}
<PickHubScript bind:filter kind={'script'} on:pick={onScriptPick}>
<ToggleHubWorkspace bind:selected={pick_existing} />
</PickHubScript>
{:else}
<WorkspaceScriptPicker bind:filter kind={'script'} on:pick={onScriptPick}>
<ToggleHubWorkspace bind:selected={pick_existing} />
</WorkspaceScriptPicker>
{/if}
</DrawerContent>
</Drawer>
<div class="h-screen flex flex-col">
<div class="m-1 flex flex-col gap-2">
<div class="flex flex-col gap-1">
{#if pendingsJobs.length === 0}
<Button
loading={loadingPendingJobs}
variant="border"
color="light"
wrapperClasses="self-stretch"
on:click={async () => {
await listPendingJobs()
if (pendingsJobs.length === 0) {
sendUserToast('No pending ssh jobs found')
}
}}
startIcon={{ icon: RefreshCw }}
>
Load pending ssh jobs</Button
>
{:else}
<div class="flex gap-1">
<Select
loading={loadingPendingJobs}
class="grow shrink"
bind:value={selectedJobId}
items={pendingsJobs.map((pendingJob) => ({ value: pendingJob.id }))}
placeholder="Choose a pending job id"
clearable
disablePortal
/>
<Button
variant="border"
color="light"
disabled={emptyString(selectedJobId)}
wrapperClasses="self-stretch"
on:click={listPendingJobsAndUpdateSelectedJobid}
startIcon={{ icon: RefreshCw }}
iconOnly
/>
<Button
color="light"
size="xs"
variant="border"
disabled={emptyString(selectedJobId)}
startIcon={{ icon: Eye }}
on:click={async () => {
const jobId = pendingsJobs.find((pendingsJob) => pendingsJob.id === selectedJobId)?.id
if (jobId) {
const job = await JobService.getJob({ workspace: $workspaceStore!, id: jobId })
codeObj = {
content: job.raw_code ?? '',
language: job.language ?? 'bash'
}
codeViewer?.openDrawer()
} else {
pendingsJobs = pendingsJobs.filter((pendingJob) => pendingJob.id !== selectedJobId)
selectedJobId = ''
}
}}
iconOnly
/>
<Button
loading={isCancelingJob}
disabled={emptyString(selectedJobId)}
color="red"
size="xs"
variant="border"
startIcon={{ icon: Square }}
on:click={async () => {
try {
isCancelingJob = true
await cancelJob(selectedJobId)
sendUserToast('Job cancelled successfully')
pendingsJobs = pendingsJobs.filter((pendingJob) => pendingJob.id !== selectedJobId)
selectedJobId = ''
} catch (error) {
sendUserToast(error.body || error.message, true)
await listPendingJobsAndUpdateSelectedJobid()
} finally {
isCancelingJob = false
}
}}
iconOnly
/>
</div>
{/if}
</div>
<div>
<div class="flex justify-start w-full mb-2">
<div class="flex flex-row">
<Badge
color="gray"
class="relative center-center !bg-gray-300 !text-tertiary dark:!bg-gray-700 dark:!text-gray-300 !h-[40px] rounded-r-none rounded-l-none"
>
Full path
<Tooltip
markdownTooltip="Commands run in the default directory. Run a standalone `cd` to change it. Chained or invalid `cd` commands wont apply."
class="absolute top-0.5"
/>
</Badge>
</div>
<input type="text" disabled bind:value={working_directory} />
</div>
<div bind:this={container}></div>
</div>
</div>
<div class="flex flex-col h-full gap-1 mt-2">
<div class="flex flex-row w-full justify-between">
<div class="flex flex-row">
<Button
btnClasses="!font-medium text-tertiary "
size="xs"
spacingSize="md"
color="light"
startIcon={{ icon: Play }}
title="Run bash script"
on:click={async () => {
await replacePromptWithCommand(code)
}}
>
Run
</Button>
<Button
btnClasses="!font-medium text-tertiary "
size="xs"
spacingSize="md"
color="light"
on:click={scriptPicker.openDrawer}
startIcon={{ icon: Library }}
title="Explore other scripts"
>
Library
</Button>
</div>
</div>
<Editor
bind:this={editor}
useWebsockets={false}
bind:code
scriptLang="bash"
class="w-full h-full"
/>
</div>
</div>
<style>
:global(.xterm-screen) {
padding-left: 10px;
}
</style>