feat: add multiplayer support for webeditor (#1562)
This commit is contained in:
73
.github/workflows/build_ws.yml
vendored
Normal file
73
.github/workflows/build_ws.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
ECR_REGISTRY: 976079455550.dkr.ecr.us-east-1.amazonaws.com
|
||||
IMAGE_NAME: ${{ github.repository }}-multiplayer
|
||||
|
||||
name: Publish websocket multiplayer server
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish_multiplayer:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: depot/setup-action@v1
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/DockerfileMultiplayer
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.licenses=AGPLv3
|
||||
|
||||
publish_privately:
|
||||
needs: [publish_multiplayer]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to ECR
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.ECR_REGISTRY }}
|
||||
username: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
password: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
|
||||
- name: Push image to ECR
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag ${{ env.ECR_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
2
.github/workflows/pypi_on_release.yml
vendored
2
.github/workflows/pypi_on_release.yml
vendored
@@ -94,7 +94,7 @@ jobs:
|
||||
password: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
|
||||
- name: Push image to GHCR
|
||||
- name: Push image to ECR
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag ${{ env.ECR_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{$BASE_URL} {
|
||||
bind {$ADDRESS}
|
||||
reverse_proxy /ws/* http://lsp:3001
|
||||
# reverse_proxy /ws_mp/* http://multiplayer:3002
|
||||
reverse_proxy /* http://windmill_server:8000
|
||||
}
|
||||
@@ -18,6 +18,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
windmill_server:
|
||||
# Use ghcr.io/windmill-labs/windmill-ee:main for the ee
|
||||
image: ghcr.io/windmill-labs/windmill:main
|
||||
deploy:
|
||||
replicas: 1
|
||||
@@ -39,6 +40,7 @@ services:
|
||||
# - ./oauth.json/:/usr/src/app/oauth.json
|
||||
|
||||
windmill_worker:
|
||||
# Use ghcr.io/windmill-labs/windmill-ee:main for the ee
|
||||
image: ghcr.io/windmill-labs/windmill:main
|
||||
deploy:
|
||||
replicas: 3
|
||||
@@ -51,13 +53,11 @@ services:
|
||||
- NUM_WORKERS=1
|
||||
- DISABLE_SERVER=true
|
||||
- KEEP_JOB_DIR=false
|
||||
- DENO_PATH=/usr/bin/deno
|
||||
- PYTHON_PATH=/usr/local/bin/python3
|
||||
- METRICS_ADDR=false
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# to mount the worker folder to debug,, KEEP_JOB_DIR=true and mount /tmp/windmill
|
||||
# to mount the worker folder to debug, KEEP_JOB_DIR=true and mount /tmp/windmill
|
||||
volumes:
|
||||
- worker_dependency_cache:/tmp/windmill/cache
|
||||
# mount the docker socket to allow to run docker containers from within the workers
|
||||
@@ -69,6 +69,13 @@ services:
|
||||
expose:
|
||||
- 3001
|
||||
|
||||
# Uncomment to enable multiplayer on the enterprise edition
|
||||
# multiplayer:
|
||||
# image: ghcr.io/windmill-labs/windmill-multiplayer:latest
|
||||
# restart: unless-stopped
|
||||
# expose:
|
||||
# - 3002
|
||||
|
||||
caddy:
|
||||
image: caddy:2.5.2-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
8
docker/DockerfileMultiplayer
Normal file
8
docker/DockerfileMultiplayer
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM node:slim
|
||||
|
||||
EXPOSE 3002
|
||||
ENV PORT 3002
|
||||
|
||||
RUN npm install -g y-websocket
|
||||
|
||||
CMD ["npx", "y-websocket"]
|
||||
782
frontend/package-lock.json
generated
782
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -95,7 +95,10 @@
|
||||
"svelte-select": "^5.6.1",
|
||||
"svelte-timezone-picker": "^2.0.3",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
"vscode-ws-jsonrpc": "3.0.0"
|
||||
"vscode-ws-jsonrpc": "3.0.0",
|
||||
"y-monaco": "^0.1.4",
|
||||
"y-websocket": "^1.5.0",
|
||||
"yjs": "^13.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sveltejs/kit": "^1.18.0",
|
||||
|
||||
@@ -58,40 +58,45 @@
|
||||
let seeEditable: boolean = enum_ != undefined || pattern != undefined
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: validateInput(pattern, value)
|
||||
|
||||
let error: string = ''
|
||||
|
||||
let el: HTMLTextAreaElement | undefined = undefined
|
||||
|
||||
export let editor: SimpleEditor | undefined = undefined
|
||||
|
||||
let inputCat = computeInputCat(type, format, itemsType?.type, enum_, contentEncoding)
|
||||
|
||||
$: inputCat = computeInputCat(type, format, itemsType?.type, enum_, contentEncoding)
|
||||
let rawValue: string | undefined = undefined
|
||||
|
||||
$: {
|
||||
if (rawValue) {
|
||||
try {
|
||||
value = JSON.parse(rawValue)
|
||||
error = ''
|
||||
} catch (err) {
|
||||
error = err.toString()
|
||||
function computeDefaultValue(nvalue?: any, inputCat?: string, defaultValue?: any) {
|
||||
if (value == undefined || value == null) {
|
||||
value = defaultValue
|
||||
if (defaultValue === undefined || defaultValue === null) {
|
||||
if (inputCat === 'string') {
|
||||
value = ''
|
||||
} else if (inputCat == 'enum') {
|
||||
value = enum_?.[0]
|
||||
} else if (inputCat == 'boolean') {
|
||||
value = false
|
||||
} else if (inputCat == 'list') {
|
||||
value = []
|
||||
}
|
||||
} else if (inputCat === 'object') {
|
||||
evalValueToRaw()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
error = ''
|
||||
if (inputCat === 'object') {
|
||||
evalValueToRaw()
|
||||
validateInput(pattern, value)
|
||||
}
|
||||
computeDefaultValue()
|
||||
|
||||
$: computeDefaultValue(value, inputCat, defaultValue)
|
||||
|
||||
function evalValueToRaw() {
|
||||
rawValue = inputCat === 'object' ? JSON.stringify(value, null, 2) : undefined
|
||||
}
|
||||
|
||||
export function evalValueToRaw() {
|
||||
if (value) {
|
||||
rawValue = JSON.stringify(value, null, 2)
|
||||
}
|
||||
}
|
||||
evalValueToRaw()
|
||||
|
||||
function fileChanged(e: any, cb: (v: string | undefined) => void) {
|
||||
let t = e.target
|
||||
@@ -144,29 +149,14 @@
|
||||
}
|
||||
e.stopPropagation()
|
||||
}
|
||||
$: {
|
||||
if (value == undefined || value == null) {
|
||||
value = defaultValue
|
||||
if (defaultValue === undefined || defaultValue === null) {
|
||||
if (inputCat === 'string') {
|
||||
value = ''
|
||||
} else if (inputCat == 'enum') {
|
||||
value = enum_?.[0]
|
||||
} else if (inputCat == 'boolean') {
|
||||
value = false
|
||||
} else if (inputCat == 'list') {
|
||||
value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: inputCat = computeInputCat(type, format, itemsType?.type, enum_, contentEncoding)
|
||||
let redraw = 0
|
||||
|
||||
let itemsLimit = 50
|
||||
|
||||
let customValue = false
|
||||
|
||||
$: validateInput(pattern, value)
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { MonacoLanguageClient, initServices } from 'monaco-languageclient'
|
||||
import { toSocket, WebSocketMessageReader, WebSocketMessageWriter } from 'vscode-ws-jsonrpc'
|
||||
import { CloseAction, ErrorAction, RequestType } from 'vscode-languageclient'
|
||||
import { MonacoBinding } from 'y-monaco'
|
||||
|
||||
languages.typescript.typescriptDefaults.setModeConfiguration({
|
||||
completionItems: false,
|
||||
@@ -59,6 +60,7 @@
|
||||
import { buildWorkerDefinition } from './build_workers'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { UserService } from '$lib/gen'
|
||||
import type { Text } from 'yjs'
|
||||
|
||||
let divEl: HTMLDivElement | null = null
|
||||
let editor: meditor.IStandaloneCodeEditor
|
||||
@@ -79,6 +81,8 @@
|
||||
export let shouldBindKey: boolean = true
|
||||
export let fixedOverflowWidgets = true
|
||||
export let path: string = randomHash()
|
||||
export let yContent: Text | undefined = undefined
|
||||
export let awareness: any | undefined = undefined
|
||||
|
||||
if (path == '' || path == undefined || path.startsWith('/')) {
|
||||
path = randomHash()
|
||||
@@ -570,8 +574,16 @@
|
||||
}
|
||||
|
||||
let widgets: HTMLElement | undefined = document.getElementById('monaco-widgets-root') ?? undefined
|
||||
let model: meditor.ITextModel
|
||||
|
||||
let monacoBinding: MonacoBinding | undefined = undefined
|
||||
// @ts-ignore
|
||||
$: if (yContent && awareness && model && editor) {
|
||||
monacoBinding && monacoBinding.destroy()
|
||||
monacoBinding = new MonacoBinding(yContent, model, new Set([editor]), awareness)
|
||||
}
|
||||
|
||||
async function loadMonaco() {
|
||||
let model: meditor.ITextModel
|
||||
try {
|
||||
model = meditor.createModel(code, lang, mUri.parse(uri))
|
||||
} catch (err) {
|
||||
@@ -633,6 +645,7 @@
|
||||
reloadWebsocket()
|
||||
|
||||
return () => {
|
||||
console.log('disposing editor')
|
||||
try {
|
||||
closeWebsockets()
|
||||
model?.dispose()
|
||||
@@ -676,8 +689,27 @@
|
||||
|
||||
<div bind:this={divEl} class="{$$props.class} editor" />
|
||||
|
||||
<style lang="postcss">
|
||||
<style global lang="postcss">
|
||||
.editor {
|
||||
@apply p-0 border rounded-md border-gray-50;
|
||||
}
|
||||
.yRemoteSelection {
|
||||
background-color: rgb(250, 129, 0, 0.5);
|
||||
}
|
||||
.yRemoteSelectionHead {
|
||||
position: absolute;
|
||||
border-left: orange solid 2px;
|
||||
border-top: orange solid 2px;
|
||||
border-bottom: orange solid 2px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.yRemoteSelectionHead::after {
|
||||
position: absolute;
|
||||
content: ' ';
|
||||
border: 3px solid orange;
|
||||
border-radius: 4px;
|
||||
left: -4px;
|
||||
top: -5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,10 +27,12 @@
|
||||
import PickHubScript from './flows/pickers/PickHubScript.svelte'
|
||||
import ToggleHubWorkspace from './ToggleHubWorkspace.svelte'
|
||||
import Skeleton from './common/skeleton/Skeleton.svelte'
|
||||
import Popover from './Popover.svelte'
|
||||
import { SCRIPT_EDITOR_SHOW_EXPLORE_OTHER_SCRIPTS } from '$lib/consts'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { getScriptByPath } from '$lib/scripts'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { Link, Users } from 'lucide-svelte'
|
||||
|
||||
export let lang: 'python3' | 'deno' | 'go' | 'bash'
|
||||
export let editor: Editor | undefined
|
||||
@@ -45,6 +47,9 @@
|
||||
export let iconOnly: boolean = false
|
||||
export let validCode: boolean = true
|
||||
export let kind: 'script' | 'trigger' | 'approval' = 'script'
|
||||
export let collabMode = false
|
||||
export let collabLive = false
|
||||
export let collabUsers: { name: string }[] = []
|
||||
|
||||
let contextualVariablePicker: ItemPicker
|
||||
let variablePicker: ItemPicker
|
||||
@@ -88,6 +93,8 @@
|
||||
}
|
||||
|
||||
let version = __pkg__.version
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={scriptPicker} size="900px">
|
||||
@@ -252,120 +259,117 @@
|
||||
{validCode ? 'Valid' : 'Invalid'}
|
||||
</Badge>
|
||||
<div class="flex items-center divide-x">
|
||||
<Popover
|
||||
notClickable
|
||||
placement="bottom"
|
||||
disappearTimeout={0}
|
||||
class="pr-1"
|
||||
disablePopup={!iconOnly}
|
||||
<Button
|
||||
title="Add context variable"
|
||||
color="light"
|
||||
btnClasses="!font-medium !h-full"
|
||||
on:click={contextualVariablePicker.openDrawer}
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
startIcon={{ icon: faDollarSign }}
|
||||
{iconOnly}
|
||||
>
|
||||
<Button
|
||||
color="light"
|
||||
btnClasses="!font-medium !h-full"
|
||||
on:click={contextualVariablePicker.openDrawer}
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
startIcon={{ icon: faDollarSign }}
|
||||
{iconOnly}
|
||||
>
|
||||
+Context Var
|
||||
</Button>
|
||||
<svelte:fragment slot="text">Add context variable</svelte:fragment>
|
||||
</Popover>
|
||||
<Popover
|
||||
notClickable
|
||||
placement="bottom"
|
||||
disappearTimeout={0}
|
||||
class="px-1"
|
||||
disablePopup={!iconOnly}
|
||||
+Context Var
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
title="Add variable"
|
||||
color="light"
|
||||
btnClasses="!font-medium !h-full"
|
||||
on:click={variablePicker.openDrawer}
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
startIcon={{ icon: faDollarSign }}
|
||||
{iconOnly}
|
||||
>
|
||||
<Button
|
||||
color="light"
|
||||
btnClasses="!font-medium !h-full"
|
||||
on:click={variablePicker.openDrawer}
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
startIcon={{ icon: faDollarSign }}
|
||||
{iconOnly}
|
||||
>
|
||||
+Variable
|
||||
</Button>
|
||||
<svelte:fragment slot="text">Add variable</svelte:fragment>
|
||||
</Popover>
|
||||
<Popover
|
||||
notClickable
|
||||
placement="bottom"
|
||||
disappearTimeout={0}
|
||||
class="px-1"
|
||||
disablePopup={!iconOnly}
|
||||
+Variable
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
title="Add resource"
|
||||
btnClasses="!font-medium !h-full"
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
color="light"
|
||||
on:click={resourcePicker.openDrawer}
|
||||
{iconOnly}
|
||||
startIcon={{ icon: faCube }}
|
||||
>
|
||||
<Button
|
||||
btnClasses="!font-medium !h-full"
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
color="light"
|
||||
on:click={resourcePicker.openDrawer}
|
||||
{iconOnly}
|
||||
startIcon={{ icon: faCube }}
|
||||
>
|
||||
+Resource
|
||||
</Button>
|
||||
<svelte:fragment slot="text">Add resource</svelte:fragment>
|
||||
</Popover>
|
||||
<Popover
|
||||
notClickable
|
||||
placement="bottom"
|
||||
disappearTimeout={0}
|
||||
class="px-1"
|
||||
disablePopup={!iconOnly}
|
||||
+Resource
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
title="Reset Content"
|
||||
btnClasses="!font-medium !h-full"
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
color="light"
|
||||
on:click={editor?.clearContent}
|
||||
{iconOnly}
|
||||
startIcon={{ icon: faRotateLeft }}
|
||||
>
|
||||
<Button
|
||||
btnClasses="!font-medium !h-full"
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
color="light"
|
||||
on:click={editor?.clearContent}
|
||||
{iconOnly}
|
||||
startIcon={{ icon: faRotateLeft }}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<svelte:fragment slot="text">Reset</svelte:fragment>
|
||||
</Popover>
|
||||
<Popover
|
||||
notClickable
|
||||
placement="bottom"
|
||||
disappearTimeout={0}
|
||||
class="px-1"
|
||||
disablePopup={!iconOnly}
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
btnClasses="!font-medium !h-full"
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
color="light"
|
||||
on:click={editor?.reloadWebsocket}
|
||||
startIcon={{ icon: faRotate }}
|
||||
title="Reload assistants"
|
||||
>
|
||||
<Button
|
||||
btnClasses="!font-medium !h-full"
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
color="light"
|
||||
on:click={editor?.reloadWebsocket}
|
||||
startIcon={{ icon: faRotate }}
|
||||
>
|
||||
{#if !iconOnly}
|
||||
Assistant
|
||||
{#if !iconOnly}
|
||||
Assistants
|
||||
{/if}
|
||||
<span class="ml-1 -my-1">
|
||||
{#if lang == 'deno'}
|
||||
(<span class={websocketAlive.deno ? 'green' : 'text-red-700'}>Deno</span>)
|
||||
{:else if lang == 'go'}
|
||||
(<span class={websocketAlive.go ? 'green' : 'text-red-700'}>Go</span>)
|
||||
{:else if lang == 'python3'}
|
||||
(<span class={websocketAlive.pyright ? 'green' : 'text-red-700'}>Pyright</span>
|
||||
<span class={websocketAlive.black ? 'green' : 'text-red-700'}>Black</span>
|
||||
<span class={websocketAlive.ruff ? 'green' : 'text-red-700'}>Ruff</span>)
|
||||
{:else if lang == 'bash'}
|
||||
(<span class={websocketAlive.shellcheck ? 'green' : 'text-red-700'}>Shellcheck</span>)
|
||||
{/if}
|
||||
<span class="ml-1 -my-1">
|
||||
{#if lang == 'deno'}
|
||||
(<span class={websocketAlive.deno ? 'green' : 'text-red-700'}>Deno</span>)
|
||||
{:else if lang == 'go'}
|
||||
(<span class={websocketAlive.go ? 'green' : 'text-red-700'}>Go</span>)
|
||||
{:else if lang == 'python3'}
|
||||
(<span class={websocketAlive.pyright ? 'green' : 'text-red-700'}>Pyright</span>
|
||||
<span class={websocketAlive.black ? 'green' : 'text-red-700'}>Black</span>
|
||||
<span class={websocketAlive.ruff ? 'green' : 'text-red-700'}>Ruff</span>)
|
||||
{:else if lang == 'bash'}
|
||||
(<span class={websocketAlive.shellcheck ? 'green' : 'text-red-700'}>Shellcheck</span>)
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
<svelte:fragment slot="text">Reload assistant</svelte:fragment>
|
||||
</Popover>
|
||||
</span>
|
||||
</Button>
|
||||
{#if collabMode}
|
||||
<div class="flex items-center px-1">
|
||||
<Toggle
|
||||
options={{ right: iconOnly ? '' : 'Multiplayer' }}
|
||||
size="xs"
|
||||
checked={collabLive}
|
||||
on:change={() => dispatch('toggleCollabMode')}
|
||||
/>
|
||||
{#if iconOnly}
|
||||
<Users class="ml-1" size={12} />
|
||||
{/if}
|
||||
{#if collabLive}
|
||||
<button
|
||||
title="Show invite link"
|
||||
class="p-1 rounded hover:bg-gray-400 mx-1 border"
|
||||
on:click={() => dispatch('collabPopup')}><Link size={12} /></button
|
||||
>
|
||||
<div class="isolate flex -space-x-2 overflow-hidden pl-2">
|
||||
{#each collabUsers as user}
|
||||
<span
|
||||
class="inline-flex h-6 w-6 items-center justify-center rounded-full ring-2 ring-white bg-gray-600"
|
||||
title={user.name}
|
||||
>
|
||||
<span class="text-sm font-medium leading-none text-white"
|
||||
>{user.name.substring(0, 2).toLocaleUpperCase()}</span
|
||||
>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- <Popover
|
||||
notClickable
|
||||
placement="bottom"
|
||||
@@ -392,28 +396,21 @@
|
||||
</Popover> -->
|
||||
</div>
|
||||
</div>
|
||||
<Popover
|
||||
notClickable
|
||||
placement="bottom"
|
||||
disappearTimeout={0}
|
||||
class="px-1"
|
||||
disablePopup={!iconOnly}
|
||||
>
|
||||
{#if SCRIPT_EDITOR_SHOW_EXPLORE_OTHER_SCRIPTS}
|
||||
<Button
|
||||
btnClasses="!font-medium"
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
color="light"
|
||||
on:click={scriptPicker.openDrawer}
|
||||
{iconOnly}
|
||||
startIcon={{ icon: faEye }}
|
||||
>
|
||||
Explore other scripts
|
||||
</Button>
|
||||
{/if}
|
||||
<svelte:fragment slot="text">Script</svelte:fragment>
|
||||
</Popover>
|
||||
|
||||
{#if SCRIPT_EDITOR_SHOW_EXPLORE_OTHER_SCRIPTS}
|
||||
<Button
|
||||
btnClasses="!font-medium"
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
color="light"
|
||||
on:click={scriptPicker.openDrawer}
|
||||
{iconOnly}
|
||||
startIcon={{ icon: faEye }}
|
||||
title="Explore other scripts"
|
||||
>
|
||||
Explore other scripts
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
export let showMeta: boolean = false
|
||||
|
||||
let metadataOpen =
|
||||
showMeta || (initialPath == '' && $page.url.searchParams.get('state') == undefined)
|
||||
showMeta ||
|
||||
(initialPath == '' &&
|
||||
$page.url.searchParams.get('state') == undefined &&
|
||||
$page.url.searchParams.get('collab') == undefined)
|
||||
let advancedOpen = false
|
||||
|
||||
let editor: Editor | undefined = undefined
|
||||
@@ -100,7 +103,14 @@
|
||||
let loadingSave = false
|
||||
let loadingDraft = false
|
||||
|
||||
$: setQueryWithoutLoad($page.url, [{ key: 'state', value: encodeState(script) }])
|
||||
$: {
|
||||
;['collab', 'path'].forEach((x) => {
|
||||
if ($page.url.searchParams.get(x)) {
|
||||
$page.url.searchParams.delete(x)
|
||||
}
|
||||
})
|
||||
setQueryWithoutLoad($page.url, [{ key: 'state', value: encodeState(script) }])
|
||||
}
|
||||
|
||||
if (script.content == '') {
|
||||
initContent(script.language, script.kind, template)
|
||||
@@ -111,6 +121,7 @@
|
||||
kind: Script.kind | undefined,
|
||||
template: 'pgsql' | 'mysql' | 'script' | 'docker'
|
||||
) {
|
||||
scriptEditor?.disableCollaboration()
|
||||
script.content = initialCode(language, kind, template)
|
||||
scriptEditor?.inferSchema(script.content, language)
|
||||
if (script.content != editor?.getCode()) {
|
||||
@@ -528,6 +539,8 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<ScriptEditor
|
||||
collabMode
|
||||
edit={initialPath != ''}
|
||||
on:format={() => {
|
||||
saveDraft()
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { BROWSER } from 'esm-env'
|
||||
|
||||
import type { Schema } from '$lib/common'
|
||||
import { CompletedJob, Job, JobService } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { emptySchema, getModifierKey } from '$lib/utils'
|
||||
import { faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { copyToClipboard, emptySchema, getModifierKey, sendUserToast } from '$lib/utils'
|
||||
import { faClipboard, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import Editor from './Editor.svelte'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import type { Preview } from '$lib/gen/models/Preview'
|
||||
@@ -13,11 +15,15 @@
|
||||
import { faGithub } from '@fortawesome/free-brands-svg-icons'
|
||||
import EditorBar, { EDITOR_BAR_WIDTH_THRESHOLD } from './EditorBar.svelte'
|
||||
import TestJobLoader from './TestJobLoader.svelte'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
|
||||
import { Button, Kbd } 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 { Icon } from 'svelte-awesome'
|
||||
|
||||
// Exported
|
||||
export let schema: Schema | any = emptySchema()
|
||||
@@ -30,6 +36,8 @@
|
||||
export let fixedOverflowWidgets = true
|
||||
export let noSyncFromGithub = false
|
||||
export let editor: Editor | undefined = undefined
|
||||
export let collabMode = false
|
||||
export let edit = true
|
||||
|
||||
let websocketAlive = {
|
||||
pyright: false,
|
||||
@@ -54,6 +62,21 @@
|
||||
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()
|
||||
@@ -107,11 +130,89 @@
|
||||
loadPastTests()
|
||||
})
|
||||
|
||||
export function setCollaborationMode() {
|
||||
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'
|
||||
|
||||
console.log(window.location.host)
|
||||
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()
|
||||
})
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function asKind(str: string | undefined) {
|
||||
return str as 'script' | 'approval' | 'trigger' | undefined
|
||||
}
|
||||
|
||||
function collabUrl() {
|
||||
let url = new URL(window.location.toString())
|
||||
url.search = ''
|
||||
return `${url}?collab=1` + (edit ? '' : `&path=${path}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<TestJobLoader
|
||||
@@ -123,14 +224,34 @@
|
||||
|
||||
<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 on:click={() => copyToClipboard(collabUrl())} class="text-gray-700 ml-2">
|
||||
<Icon data={faClipboard} />
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
<div class="border-b-2 shadow-sm px-1 pr-4" bind:clientWidth={width}>
|
||||
<div class="flex justify-between space-x-2">
|
||||
<EditorBar
|
||||
on:toggleCollabMode={() => {
|
||||
if (wsProvider?.shouldConnect) {
|
||||
disableCollaboration()
|
||||
} else {
|
||||
setCollaborationMode()
|
||||
}
|
||||
}}
|
||||
collabLive={wsProvider?.shouldConnect}
|
||||
{collabMode}
|
||||
{validCode}
|
||||
iconOnly={width < EDITOR_BAR_WIDTH_THRESHOLD}
|
||||
on:collabPopup={() => (showCollabPopup = true)}
|
||||
{editor}
|
||||
{lang}
|
||||
{websocketAlive}
|
||||
collabUsers={peers}
|
||||
kind={asKind(kind)}
|
||||
/>
|
||||
{#if !noSyncFromGithub}
|
||||
@@ -161,6 +282,8 @@
|
||||
bind:code
|
||||
bind:websocketAlive
|
||||
bind:this={editor}
|
||||
{yContent}
|
||||
awareness={wsProvider?.awareness}
|
||||
on:change={(e) => {
|
||||
inferSchema(e.detail)
|
||||
}}
|
||||
@@ -202,7 +325,9 @@
|
||||
{:else}
|
||||
<Button
|
||||
color="dark"
|
||||
on:click={runTest}
|
||||
on:click={() => {
|
||||
runTest()
|
||||
}}
|
||||
btnClasses="w-full"
|
||||
size="xs"
|
||||
startIcon={{
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
case 'Escape':
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
open = false
|
||||
dispatch('canceled')
|
||||
break
|
||||
}
|
||||
@@ -37,7 +38,9 @@
|
||||
<svelte:window on:keydown={onKeyDown} />
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
on:click={() => (open = false)}
|
||||
transition:fadeFast|local
|
||||
class={'absolute top-0 bottom-0 left-0 right-0 z-50'}
|
||||
role="dialog"
|
||||
@@ -52,6 +55,7 @@
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
on:click|stopPropagation
|
||||
class={twMerge(
|
||||
'relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
|
||||
c,
|
||||
@@ -72,8 +76,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
||||
<Button on:click={() => dispatch('canceled')} color="light" size="sm">
|
||||
<span class="gap-2">Cancel <Badge color="dark-gray">Escape</Badge></span>
|
||||
<Button
|
||||
on:click={() => {
|
||||
dispatch('canceled')
|
||||
open = false
|
||||
}}
|
||||
color="light"
|
||||
size="sm"
|
||||
>
|
||||
<span class="inline-flex gap-2">Cancel <Badge color="dark-gray">Escape</Badge></span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,16 +15,18 @@
|
||||
const hubPath = $page.url.searchParams.get('hub')
|
||||
const showMeta = /true|1/i.test($page.url.searchParams.get('show_meta') ?? '0')
|
||||
|
||||
const path = $page.url.searchParams.get('path')
|
||||
|
||||
const initialState = $page.url.searchParams.get('state')
|
||||
|
||||
let scriptBuilder: ScriptBuilder | undefined = undefined
|
||||
|
||||
let script: NewScript =
|
||||
initialState != undefined
|
||||
!path && initialState != undefined
|
||||
? decodeState(initialState)
|
||||
: {
|
||||
hash: '',
|
||||
path: '',
|
||||
path: path ?? '',
|
||||
summary: '',
|
||||
content: '',
|
||||
schema: schema,
|
||||
|
||||
@@ -16,11 +16,15 @@ const config = {
|
||||
changeOrigin: true,
|
||||
cookieDomainRewrite: 'localhost'
|
||||
},
|
||||
// Proxying websockets or socket.io: ws://localhost:5173/socket.io -> ws://localhost:5174/socket.io
|
||||
'^/ws/.*': {
|
||||
target: process.env.REMOTE_LSP ?? 'https://app.windmill.dev',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
},
|
||||
'^/ws_mp/.*': {
|
||||
target: process.env.REMOTE_MP ?? 'https://app.windmill.dev',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user