[gui2] Language Server binary protocol, and loading of visualization data (#7873)

- Depends on #7773.

- Implements binary WebSocket protocol (data protocol)
- Performs some editor initialization (the bare minimum so that visualizations work)
- Adds event handlers to receive visualization data updates

# Important Notes
None
This commit is contained in:
somebody1234 2023-10-08 06:57:47 +10:00 committed by GitHub
parent a90af9f844
commit 44f2f425c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 4108 additions and 625 deletions

1
app/gui2/.gitignore vendored
View File

@ -13,6 +13,7 @@ coverage
# Editor directories and files
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
.idea
*.suo

3
app/gui2/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"prettier.prettierPath": "../../node_modules/prettier/index.cjs"
}

1
app/gui2/env.d.ts vendored
View File

@ -13,5 +13,4 @@ declare module 'builtins' {
export const VisualizationContainer: typeof import('@/components/VisualizationContainer.vue').default
export const useVisualizationConfig: typeof import('@/providers/visualizationConfig').useVisualizationConfig
export const defineKeybinds: typeof import('@/util/shortcuts').defineKeybinds
export const d3: typeof import('d3')
}

View File

@ -32,6 +32,7 @@ const conf = [
rules: {
camelcase: [1, { ignoreImports: true }],
'no-inner-declarations': 0,
'vue/attribute-hyphenation': [2, 'never'],
'vue/v-on-event-hyphenation': [2, 'never'],
'@typescript-eslint/no-unused-vars': [
1,

View File

@ -3,9 +3,3 @@ module 'tailwindcss/nesting' {
declare const plugin: PluginCreator<unknown>
export default plugin
}
// This is an augmentation to the built-in `ImportMeta` interface.
// This file MUST NOT contain any top-level imports.
interface ImportMeta {
vitest: typeof import('vitest') | undefined
}

View File

@ -34,7 +34,7 @@
"fast-diff": "^1.3.0",
"hash-sum": "^2.0.0",
"isomorphic-ws": "^5.0.0",
"lib0": "^0.2.83",
"lib0": "^0.2.85",
"magic-string": "^0.30.3",
"murmurhash": "^2.0.1",
"pinia": "^2.1.6",
@ -43,7 +43,6 @@
"sha3": "^2.1.4",
"sucrase": "^3.34.0",
"vue": "^3.3.4",
"vue-codemirror": "^6.1.1",
"ws": "^8.13.0",
"y-codemirror.next": "^0.3.2",
"y-protocols": "^1.0.5",

View File

@ -3,6 +3,13 @@ import { defineKeybinds } from 'builtins'
export const name = 'Scatterplot'
export const inputType = 'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector'
const DEFAULT_LIMIT = 1024
export const defaultPreprocessor = [
'Standard.Visualization.Scatter_Plot',
'process_to_json_text',
'Nothing',
DEFAULT_LIMIT.toString(),
]
const bindings = defineKeybinds('scatterplot-visualization', {
zoomIn: ['Mod+Z'],
@ -68,7 +75,7 @@ enum ScaleType {
interface AxisConfiguration {
label: string
scale: ScaleType
scale?: ScaleType
}
interface AxesConfiguration {
@ -84,8 +91,10 @@ interface Color {
</script>
<script setup lang="ts">
import { d3 } from 'builtins'
import { computed, onMounted, ref, watch, watchEffect, watchPostEffect } from 'vue'
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
import * as d3 from 'd3'
import FindIcon from './icons/find.svg'
import ShowAllIcon from './icons/show_all.svg'
@ -106,12 +115,9 @@ const POINT_LABEL_PADDING_X_PX = 7
const POINT_LABEL_PADDING_Y_PX = 2
const ANIMATION_DURATION_MS = 400
const VISIBLE_POINTS = 'visible'
const DEFAULT_LIMIT = 1024
const ACCENT_COLOR: Color = { red: 78, green: 165, blue: 253 }
const SIZE_SCALE_MULTIPLER = 100
const FILL_COLOR = `rgba(${ACCENT_COLOR.red * 255},${ACCENT_COLOR.green * 255},${
ACCENT_COLOR.blue * 255
},0.8)`
const FILL_COLOR = `rgba(${ACCENT_COLOR.red},${ACCENT_COLOR.green},${ACCENT_COLOR.blue},0.8)`
const ZOOM_EXTENT = [0.5, 20] satisfies d3.BrushSelection
const RIGHT_BUTTON = 2
@ -201,18 +207,18 @@ const margin = computed(() => {
return { top: 10, right: 10, bottom: 35, left: 55 }
}
})
const width = ref(Math.max(config.value.width ?? 0, config.value.nodeSize.x))
watchPostEffect(() => {
width.value = config.value.fullscreen
const width = computed(() =>
config.value.fullscreen
? containerNode.value?.parentElement?.clientWidth ?? 0
: Math.max(config.value.width ?? 0, config.value.nodeSize.x)
})
const height = ref(config.value.height ?? (config.value.nodeSize.x * 3) / 4)
watchPostEffect(() => {
height.value = config.value.fullscreen
: Math.max(config.value.width ?? 0, config.value.nodeSize.x),
)
const height = computed(() =>
config.value.fullscreen
? containerNode.value?.parentElement?.clientHeight ?? 0
: config.value.height ?? (config.value.nodeSize.x * 3) / 4
})
: config.value.height ?? (config.value.nodeSize.x * 3) / 4,
)
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
const xTicks = computed(() => boxWidth.value / 40)
@ -229,7 +235,7 @@ const yLabelLeft = computed(
)
const yLabelTop = computed(() => -margin.value.left + 15)
function updatePreprocessor() {
watchEffect(() => {
emit(
'update:preprocessor',
'Standard.Visualization.Scatter_Plot',
@ -237,9 +243,7 @@ function updatePreprocessor() {
bounds.value == null ? 'Nothing' : '[' + bounds.value.join(',') + ']',
limit.value.toString(),
)
}
onMounted(updatePreprocessor)
})
watchEffect(() => (focus.value = data.value.focus))
@ -409,7 +413,6 @@ function zoomToSelected() {
const yMin = yScale_.invert(yMinRaw)
const yMax = yScale_.invert(yMaxRaw)
bounds.value = [xMin, yMin, xMax, yMax]
updatePreprocessor()
xDomain.value = [xMin, xMax]
yDomain.value = [yMin, yMax]
}
@ -435,7 +438,7 @@ function matchShape(d: Point) {
* @param axis Axis information as received in the visualization update.
* @returns D3 scale. */
function axisD3Scale(axis: AxisConfiguration | undefined) {
return axis != null ? SCALE_TO_D3_SCALE[axis.scale]() : d3.scaleLinear()
return axis?.scale != null ? SCALE_TO_D3_SCALE[axis.scale]() : d3.scaleLinear()
}
watchEffect(() => {
@ -522,7 +525,6 @@ function showAll() {
extremesAndDeltas.value.yMin - extremesAndDeltas.value.paddingY,
extremesAndDeltas.value.yMax + extremesAndDeltas.value.paddingY,
]
updatePreprocessor()
endBrushing()
}
@ -539,7 +541,7 @@ useEvent(document, 'scroll', endBrushing)
</script>
<template>
<VisualizationContainer :below-toolbar="true">
<VisualizationContainer :belowToolbar="true">
<template #toolbar>
<button class="image-button active">
<img :src="ShowAllIcon" alt="Fit all" @pointerdown="showAll" />

View File

@ -1,6 +1,12 @@
<script lang="ts">
export const name = '<name here>'
export const inputType = '<allowed input type(s) here>'
// Optional:
export const defaultPreprocessor = [
'<module path here>',
'<method name here>',
'<optional args here>',
]
interface Data {
dataType: 'here'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,198 @@
import { ObservableV2 } from 'lib0/observable'
import * as random from 'lib0/random'
import {
Builder,
ByteBuffer,
ChecksumBytesCommand,
ChecksumBytesReply,
EnsoUUID,
Error,
FileContentsReply,
FileSegment,
InboundMessage,
InboundPayload,
InitSessionCommand,
None,
OutboundMessage,
OutboundPayload,
ReadBytesCommand,
ReadBytesReply,
ReadFileCommand,
Success,
VisualizationUpdate,
WriteBytesCommand,
WriteBytesReply,
WriteFileCommand,
type Table,
} from './binaryProtocol'
import type { WebsocketClient } from './websocket'
import type { Uuid } from './yjsModel'
export function uuidFromBits(leastSigBits: bigint, mostSigBits: bigint): string {
const bits = (mostSigBits << 64n) | leastSigBits
const string = bits.toString(16).padStart(32, '0')
return string.replace(/(........)(....)(....)(....)(............)/, '$1-$2-$3-$4-$5')
}
export function uuidToBits(uuid: string): [leastSigBits: bigint, mostSigBits: bigint] {
const bits = BigInt('0x' + uuid.replace(/-/g, ''))
return [bits & 0xffff_ffff_ffff_ffffn, bits >> 64n]
}
const PAYLOAD_CONSTRUCTOR = {
[OutboundPayload.NONE]: None,
[OutboundPayload.ERROR]: Error,
[OutboundPayload.SUCCESS]: Success,
[OutboundPayload.VISUALIZATION_UPDATE]: VisualizationUpdate,
[OutboundPayload.FILE_CONTENTS_REPLY]: FileContentsReply,
[OutboundPayload.WRITE_BYTES_REPLY]: WriteBytesReply,
[OutboundPayload.READ_BYTES_REPLY]: ReadBytesReply,
[OutboundPayload.CHECKSUM_BYTES_REPLY]: ChecksumBytesReply,
} satisfies Record<OutboundPayload, new () => Table>
export type DataServerEvents = {
[K in keyof typeof PAYLOAD_CONSTRUCTOR as K | `${K}:${string}`]: (
arg: InstanceType<(typeof PAYLOAD_CONSTRUCTOR)[K]>,
) => void
}
export class DataServer extends ObservableV2<DataServerEvents> {
initialized = false
ready: Promise<void>
clientId!: string
resolveCallbacks = new Map<string, (data: any) => void>()
/** `websocket.binaryType` should be `ArrayBuffer`. */
constructor(public websocket: WebsocketClient) {
super()
if (websocket.connected) {
this.ready = Promise.resolve()
} else {
this.ready = new Promise((resolve, reject) => {
websocket.on('connect', () => resolve())
websocket.on('disconnect', reject)
})
}
websocket.on('message', (rawPayload) => {
if (!(rawPayload instanceof ArrayBuffer)) {
console.warn('Data Server: Data type was invalid:', rawPayload)
// Ignore all non-binary messages. If the messages are `Blob`s instead, this is a
// misconfiguration and should also be ignored.
return
}
const binaryMessage = OutboundMessage.getRootAsOutboundMessage(new ByteBuffer(rawPayload))
const payloadType = binaryMessage.payloadType()
const payload = binaryMessage.payload(new PAYLOAD_CONSTRUCTOR[payloadType]())
if (payload != null) {
this.emit(`${payloadType}`, [payload])
const id = binaryMessage.correlationId()
if (id != null) {
const uuid = uuidFromBits(id.leastSigBits(), id.mostSigBits())
this.emit(`${payloadType}:${uuid}`, [payload])
const callback = this.resolveCallbacks.get(uuid)
callback?.(payload)
} else if (payload instanceof VisualizationUpdate) {
const id = payload.visualizationContext()?.visualizationId()
if (id != null) {
const uuid = uuidFromBits(id.leastSigBits(), id.mostSigBits())
this.emit(`${payloadType}:${uuid}`, [payload])
}
}
}
})
}
async initialize(clientId: Uuid) {
if (!this.initialized) {
this.clientId = clientId
await this.ready
await this.initSession()
}
}
protected send<T = void>(
builder: Builder,
payloadType: InboundPayload,
payloadOffset: number,
): Promise<T> {
const messageUuid = random.uuidv4()
const messageId = this.createUUID(builder, messageUuid)
const rootTable = InboundMessage.createInboundMessage(
builder,
messageId,
0 /* correlation id */,
payloadType,
payloadOffset,
)
const promise = new Promise<T>((resolve) => {
this.resolveCallbacks.set(messageUuid, resolve)
})
this.websocket.send(builder.finish(rootTable).toArrayBuffer())
return promise
}
protected createUUID(builder: Builder, uuid: string) {
const [leastSigBits, mostSigBits] = uuidToBits(uuid)
const identifier = EnsoUUID.createEnsoUUID(builder, leastSigBits, mostSigBits)
return identifier
}
initSession(): Promise<Success> {
const builder = new Builder()
const identifierOffset = this.createUUID(builder, this.clientId)
const commandOffset = InitSessionCommand.createInitSessionCommand(builder, identifierOffset)
return this.send<Success>(builder, InboundPayload.INIT_SESSION_CMD, commandOffset)
}
async writeFile(path: string, contents: string | ArrayBuffer | Uint8Array) {
const builder = new Builder()
const contentsOffset = builder.createString(contents)
const pathOffset = builder.createString(path)
const command = WriteFileCommand.createWriteFileCommand(builder, pathOffset, contentsOffset)
return await this.send(builder, InboundPayload.WRITE_FILE_CMD, command)
}
async readFile(path: string) {
const builder = new Builder()
const pathOffset = builder.createString(path)
const command = ReadFileCommand.createReadFileCommand(builder, pathOffset)
return await this.send(builder, InboundPayload.READ_FILE_CMD, command)
}
async writeBytes(
path: string,
index: bigint,
overwriteExisting: boolean,
contents: string | ArrayBuffer | Uint8Array,
): Promise<WriteBytesReply> {
const builder = new Builder()
const bytesOffset = builder.createString(contents)
const pathOffset = builder.createString(path)
const command = WriteBytesCommand.createWriteBytesCommand(
builder,
pathOffset,
index,
overwriteExisting,
bytesOffset,
)
return await this.send<WriteBytesReply>(builder, InboundPayload.WRITE_BYTES_CMD, command)
}
async readBytes(path: string, index: bigint, length: bigint): Promise<ReadBytesReply> {
const builder = new Builder()
const pathOffset = builder.createString(path)
const segmentOffset = FileSegment.createFileSegment(builder, pathOffset, index, length)
const command = ReadBytesCommand.createReadBytesCommand(builder, segmentOffset)
return await this.send<ReadBytesReply>(builder, InboundPayload.READ_BYTES_CMD, command)
}
async checksumBytes(path: string, index: bigint, length: bigint): Promise<ChecksumBytesReply> {
const builder = new Builder()
const pathOffset = builder.createString(path)
const segmentOffset = FileSegment.createFileSegment(builder, pathOffset, index, length)
const command = ChecksumBytesCommand.createChecksumBytesCommand(builder, segmentOffset)
return await this.send<ChecksumBytesReply>(builder, InboundPayload.WRITE_BYTES_CMD, command)
}
// TODO: check whether any of these may send an "error" message instead
}

25
app/gui2/shared/events.ts Normal file
View File

@ -0,0 +1,25 @@
import { ObservableV2 } from 'lib0/observable'
type EventHandler = (...arg0: any[]) => void
type EventMap = {
[key: string]: EventHandler
}
export class EventEmitter extends ObservableV2<EventMap> {
emit(name: string, ...args: unknown[]): void {
super.emit(name, args)
}
addListener(name: string, f: EventHandler) {
this.on(name, f)
}
removeListener(name: string, f: EventHandler) {
this.off(name, f)
}
removeAllListeners() {
this.destroy()
}
}

View File

@ -20,15 +20,15 @@ import type {
import type { Uuid } from './yjsModel'
const DEBUG_LOG_RPC = false
const RPC_TIMEOUT_MS = 10000
const RPC_TIMEOUT_MS = 15000
class RpcError extends Error {
export class LsRpcError extends Error {
cause: Error
request: string
params: object
constructor(inner: Error, request: string, params: object) {
super(`Language server request failed.`)
this.cause = inner
constructor(cause: Error, request: string, params: object) {
super(`Language server request '${request}' failed.`)
this.cause = cause
this.request = request
this.params = params
}
@ -47,6 +47,9 @@ export class LanguageServer extends ObservableV2<Notifications> {
client.onNotification((notification) => {
this.emit(notification.method as keyof Notifications, [notification.params])
})
client.onError((error) => {
console.error(`Unexpected LS connection error:`, error)
})
}
// The "magic bag of holding" generic that is only present in the return type is UNSOUND.
@ -62,7 +65,7 @@ export class LanguageServer extends ObservableV2<Notifications> {
return await this.client.request({ method, params }, RPC_TIMEOUT_MS)
} catch (e) {
if (e instanceof Error) {
throw new RpcError(e, method, params)
throw new LsRpcError(e, method, params)
}
throw e
} finally {
@ -271,12 +274,12 @@ export class LanguageServer extends ObservableV2<Notifications> {
detachVisualization(
visualizationId: Uuid,
expressionId: ExpressionId,
executionContextId: ContextId,
contextId: ContextId,
): Promise<void> {
return this.request('executionContext/detachVisualization', {
visualizationId,
expressionId,
executionContextId,
contextId,
})
}

View File

@ -258,7 +258,7 @@ interface VisualizationContext {}
export interface VisualizationConfiguration {
/** An execution context of the visualization. */
executionContextId: Uuid
executionContextId: ContextId
/** A qualified name of the module to be used to evaluate the arguments for the visualization
* expression. */
visualizationModule: string
@ -311,7 +311,7 @@ export type StackItem = ExplicitCall | LocalCall
export interface ExplicitCall {
type: 'ExplicitCall'
methodPointer: MethodPointer
thisArgumentExpression?: string
thisArgumentExpression?: string | undefined
positionalArgumentsExpressions: string[]
}

View File

@ -0,0 +1,188 @@
/* eslint-env browser */
/* The MIT License (MIT)
*
* Copyright (c) 2019 Kevin Jahns <kevin.jahns@protonmail.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/**
* Tiny websocket connection handler.
*
* Implements exponential backoff reconnects, ping/pong, and a nice event system using [lib0/observable].
*
* @module websocket
*/
import * as math from 'lib0/math'
import { ObservableV2 } from 'lib0/observable'
import * as time from 'lib0/time'
const reconnectTimeoutBase = 1200
const maxReconnectTimeout = 2500
// @todo - this should depend on awareness.outdatedTime
const messageReconnectTimeout = 30000
const setupWS = (wsclient: WebsocketClient) => {
if (wsclient.shouldConnect && wsclient.ws === null) {
// @ts-ignore I don't know why `lib` is misconfigured.
const websocket = new WebSocket(wsclient.url)
const binaryType = wsclient.binaryType
let pingTimeout: any = null
if (binaryType) {
websocket.binaryType = binaryType
}
wsclient.ws = websocket
wsclient.connecting = true
wsclient.connected = false
websocket.onmessage = (event: { data: string | ArrayBuffer | Blob }) => {
wsclient.lastMessageReceived = time.getUnixTime()
const data = event.data
const message = typeof data === 'string' ? JSON.parse(data) : data
if (wsclient.sendPings && message && message.type === 'pong') {
clearTimeout(pingTimeout)
pingTimeout = setTimeout(sendPing, messageReconnectTimeout / 2)
}
wsclient.emit('message', [message, wsclient])
}
const onclose = (error: unknown) => {
if (wsclient.ws !== null) {
wsclient.ws = null
wsclient.connecting = false
if (wsclient.connected) {
wsclient.connected = false
wsclient.emit('disconnect', [{ type: 'disconnect', error }, wsclient])
} else {
wsclient.unsuccessfulReconnects++
}
// Start with no reconnect timeout and increase timeout by
// log10(wsUnsuccessfulReconnects).
// The idea is to increase reconnect timeout slowly and have no reconnect
// timeout at the beginning (log(1) = 0)
setTimeout(
setupWS,
math.min(
math.log10(wsclient.unsuccessfulReconnects + 1) * reconnectTimeoutBase,
maxReconnectTimeout,
),
wsclient,
)
}
clearTimeout(pingTimeout)
}
const sendPing = () => {
if (wsclient.sendPings && wsclient.ws === websocket) {
wsclient.send({
type: 'ping',
})
}
}
websocket.onclose = () => onclose(null)
websocket.onerror = (error: unknown) => onclose(error)
websocket.onopen = () => {
wsclient.lastMessageReceived = time.getUnixTime()
wsclient.connecting = false
wsclient.connected = true
wsclient.unsuccessfulReconnects = 0
wsclient.emit('connect', [{ type: 'connect' }, wsclient])
// set ping
pingTimeout = setTimeout(sendPing, messageReconnectTimeout / 2)
}
}
}
type WebsocketEvents = {
connect: (payload: { type: 'connect' }, self: WebsocketClient) => void
disconnect: (payload: { type: 'disconnect'; error: unknown }, self: WebsocketClient) => void
message: (payload: {} | ArrayBuffer | Blob, self: WebsocketClient) => void
}
export class WebsocketClient extends ObservableV2<WebsocketEvents> {
ws: any
binaryType
sendPings
connected
connecting
unsuccessfulReconnects
lastMessageReceived
shouldConnect
protected _checkInterval
constructor(
public url: string,
{
binaryType,
sendPings,
}: { binaryType?: 'arraybuffer' | 'blob' | null; sendPings?: boolean } = {},
) {
super()
this.ws = null
this.binaryType = binaryType || null
this.sendPings = sendPings ?? true
this.connected = false
this.connecting = false
this.unsuccessfulReconnects = 0
this.lastMessageReceived = 0
/** Whether to connect to other peers or not */
this.shouldConnect = true
this._checkInterval = this.sendPings
? setInterval(() => {
if (
this.connected &&
messageReconnectTimeout < time.getUnixTime() - this.lastMessageReceived
) {
// no message received in a long time - not even your own awareness
// updates (which are updated every 15 seconds)
this.ws.close()
}
}, messageReconnectTimeout / 2)
: 0
setupWS(this)
}
send(message: {} | ArrayBuffer | Blob) {
if (this.ws) {
const encoded =
message instanceof ArrayBuffer || message instanceof Blob
? message
: JSON.stringify(message)
this.ws.send(encoded)
}
}
destroy() {
clearInterval(this._checkInterval)
this.disconnect()
super.destroy()
}
disconnect() {
this.shouldConnect = false
if (this.ws !== null) {
this.ws.close()
}
}
connect() {
this.shouldConnect = true
if (!this.connected && this.ws === null) {
setupWS(this)
}
}
}

View File

@ -1,6 +1,7 @@
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import * as random from 'lib0/random.js'
import * as object from 'lib0/object'
import * as random from 'lib0/random'
import * as Y from 'yjs'
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
@ -8,10 +9,38 @@ declare const brandExprId: unique symbol
export type ExprId = Uuid & { [brandExprId]: never }
export const NULL_EXPR_ID: ExprId = '00000000-0000-0000-0000-000000000000' as ExprId
export type VisualizationModule =
| { kind: 'Builtin' }
| { kind: 'CurrentProject' }
| { kind: 'Library'; name: string }
export interface VisualizationIdentifier {
module: VisualizationModule
name: string
}
export interface VisualizationMetadata extends VisualizationIdentifier {
visible: boolean
}
export function visMetadataEquals(
a: VisualizationMetadata | null | undefined,
b: VisualizationMetadata | null | undefined,
) {
return (
(a == null && b == null) ||
(a != null && b != null && a.visible === b.visible && visIdentifierEquals(a, b))
)
}
export function visIdentifierEquals(a: VisualizationIdentifier, b: VisualizationIdentifier) {
return a.name === b.name && object.equalFlat(a.module, b.module)
}
export interface NodeMetadata {
x: number
y: number
vis?: unknown
vis: VisualizationMetadata | null
}
export class DistributedProject {
@ -85,6 +114,7 @@ export class ModuleDoc {
export class DistributedModule {
doc: ModuleDoc
undoManager: Y.UndoManager
static async load(ydoc: Y.Doc): Promise<DistributedModule> {
ydoc.load()
@ -94,6 +124,7 @@ export class DistributedModule {
constructor(ydoc: Y.Doc) {
this.doc = new ModuleDoc(ydoc)
this.undoManager = new Y.UndoManager([this.doc.contents, this.doc.idMap, this.doc.metadata])
}
insertNewNode(offset: number, content: string, meta: NodeMetadata): ExprId {
@ -145,12 +176,14 @@ export class DistributedModule {
}
transact<T>(fn: () => T): T {
return this.doc.ydoc.transact(fn)
return this.doc.ydoc.transact(fn, 'local')
}
updateNodeMetadata(id: ExprId, meta: Partial<NodeMetadata>): void {
const existing = this.doc.metadata.get(id) ?? { x: 0, y: 0 }
this.doc.metadata.set(id, { ...existing, ...meta })
const existing = this.doc.metadata.get(id) ?? { x: 0, y: 0, vis: null }
this.transact(() => {
this.doc.metadata.set(id, { ...existing, ...meta })
})
}
getIdMap(): IdMap {

View File

@ -1099,22 +1099,10 @@
fill="currentColor" />
</g>
<g id="docs" fill="none">
<svg width="16" height="16" viewBox="0.5 0.5 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.54419" y="1.45581" width="12" height="2" fill="black" fill-opacity="0.6" />
<rect x="2.54419" y="5.45581" width="8" height="2" fill="black" fill-opacity="0.6" />
<rect x="4.54419" y="9.45581" width="8" height="2" fill="black" fill-opacity="0.6" />
<rect x="2.54419" y="13.4558" width="12" height="2" fill="black" fill-opacity="0.6" />
</svg>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.04419 0.95581H14.0442V2.95581H2.04419V0.95581ZM2.04419 4.95581H10.0442V6.95581H2.04419V4.95581ZM12.0442 8.95581H4.04419V10.9558H12.0442V8.95581ZM2.04419 12.9558H14.0442V14.9558H2.04419V12.9558Z" fill="black" fill-opacity="0.6"/>
</g>
<g id="eye" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.99988 2.5C11.4999 2.5 13.9999 5.25 15.9999 8L10.4999 8C10.4999 6.61929 9.38059 5.5 7.99988 5.5C6.61917 5.5 5.49988 6.61929 5.49988 8L-0.000123032 8C1.99988 5.25 4.49988 2.5 7.99988 2.5Z"
fill="black" fill-opacity="0.6" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.99988 13.5C4.49988 13.5 1.99988 10.75 -0.000120628 8L5.49988 8C5.49988 9.38071 6.61917 10.5 7.99988 10.5C9.38059 10.5 10.4999 9.38071 10.4999 8L15.9999 8C13.9999 10.75 11.4999 13.5 7.99988 13.5Z"
fill="black" fill-opacity="0.6" />
</svg>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 8C14 5.25 11.5 2.5 8 2.5C4.5 2.5 2 5.25 0 8C2 10.75 4.5 13.5 8 13.5C11.5 13.5 14 10.75 16 8ZM5.5 8C5.5 9.38071 6.61929 10.5 8 10.5C9.38071 10.5 10.5 9.38071 10.5 8C10.5 6.61929 9.38071 5.5 8 5.5C6.61929 5.5 5.5 6.61929 5.5 8Z" fill="black" fill-opacity="0.6"/>
</g>
<g id="no_auto_replay" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -1,5 +1,17 @@
import { defineKeybinds } from '@/util/shortcuts'
export const codeEditorBindings = defineKeybinds('code-editor', {
toggle: ['`'],
})
export const graphBindings = defineKeybinds('graph-editor', {
undo: ['Mod+Z'],
redo: ['Mod+Y', 'Mod+Shift+Z'],
dragScene: ['PointerAux', 'Mod+PointerMain'],
openComponentBrowser: ['Enter'],
newNode: ['N'],
})
export const nodeBindings = defineKeybinds('node-selection', {
deleteSelected: ['Delete'],
selectAll: ['Mod+A'],
@ -9,4 +21,5 @@ export const nodeBindings = defineKeybinds('node-selection', {
remove: ['Shift+Alt+PointerMain'],
toggle: ['Shift+PointerMain'],
invert: ['Mod+Shift+Alt+PointerMain'],
toggleVisualization: ['Space'],
})

View File

@ -19,19 +19,19 @@ const emit = defineEmits<{
<ToggleIcon
icon="no_auto_replay"
class="icon-container button no-auto-evaluate-button"
:model-value="props.isAutoEvaluationDisabled"
:modelValue="props.isAutoEvaluationDisabled"
@update:modelValue="emit('update:isAutoEvaluationDisabled', $event)"
/>
<ToggleIcon
icon="docs"
class="icon-container button docs-button"
:model-value="props.isDocsVisible"
:modelValue="props.isDocsVisible"
@update:modelValue="emit('update:isDocsVisible', $event)"
/>
<ToggleIcon
icon="eye"
class="icon-container button visualization-button"
:model-value="props.isVisualizationVisible"
:modelValue="props.isVisualizationVisible"
@update:modelValue="emit('update:isVisualizationVisible', $event)"
/>
</div>

View File

@ -1,84 +1,148 @@
<script setup lang="ts">
import { useProjectStore } from '@/stores/project'
import { useWindowEvent } from '@/util/events'
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { basicSetup } from 'codemirror'
import { ref, watchPostEffect } from 'vue'
// y-codemirror.next does not provide type information. See https://github.com/yjs/y-codemirror.next/issues/27
// @ts-ignore
import { yCollab } from 'y-codemirror.next'
import { usePointer } from '@/util/events'
import { useLocalStorage } from '@vueuse/core'
import { computed, onMounted, ref, watchEffect } from 'vue'
// Use dynamic imports to aid code splitting. The codemirror dependency is quite large.
const { minimalSetup, EditorState, EditorView, yCollab } = await import('@/util/codemirror')
const projectStore = useProjectStore()
// == Keyboard shortcut to toggle the CodeEditor ==
const shown = ref(false)
const rootElement = ref<HTMLElement>()
useWindowEvent('keydown', (e) => {
const graphEditorInFocus = document.activeElement === document.body
const codeEditorInFocus = rootElement.value?.contains(document.activeElement)
const validFocus = graphEditorInFocus || codeEditorInFocus
const targetKeyPressed = e.key == `\``
if (validFocus && targetKeyPressed) {
e.preventDefault()
shown.value = !shown.value
}
})
// == CodeMirror editor setup ==
const codeMirrorEl = ref(null)
const editorView = ref<EditorView>()
watchPostEffect((onCleanup) => {
const yText = projectStore.module?.doc.contents
if (!yText || !codeMirrorEl.value) return
const undoManager = projectStore.undoManager
const editorView = new EditorView()
watchEffect(() => {
const module = projectStore.module
if (!module) return
const yText = module.doc.contents
const undoManager = module.undoManager
const awareness = projectStore.awareness
const view = new EditorView({
parent: codeMirrorEl.value,
state: EditorState.create({
editorView.setState(
EditorState.create({
doc: yText.toString(),
extensions: [basicSetup, yCollab(yText, awareness, { undoManager })],
extensions: [minimalSetup, yCollab(yText, awareness, { undoManager })],
}),
})
onCleanup(() => view.destroy())
editorView.value = view
)
})
onMounted(() => {
editorView.focus()
rootElement.value?.prepend(editorView.dom)
})
const editorSize = useLocalStorage<{ width: number | null; height: number | null }>(
'code-editor-size',
{ width: null, height: null },
)
let initSize = { width: 0, height: 0 }
const resize = usePointer((pos, _, type) => {
if (rootElement.value == null) return
if (type == 'start') initSize = rootElement.value.getBoundingClientRect()
editorSize.value.width = initSize.width + pos.relative.x
editorSize.value.height = initSize.height - pos.relative.y
})
function resetSize() {
editorSize.value.width = null
editorSize.value.height = null
}
const editorStyle = computed(() => {
return {
width: editorSize.value.width ? `${editorSize.value.width}px` : '50%',
height: editorSize.value.height ? `${editorSize.value.height}px` : '30%',
}
})
</script>
<template>
<div
v-show="shown"
ref="rootElement"
class="CodeEditor"
:style="editorStyle"
@keydown.enter.stop
@wheel.stop.passive
@pointerdown.stop
>
<div ref="codeMirrorEl" class="codemirror-container"></div>
<div class="resize-handle" v-on="resize.events" @dblclick="resetSize">
<svg viewBox="0 0 16 16">
<circle cx="2" cy="2" r="1.5" />
<circle cx="8" cy="2" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="14" cy="2" r="1.5" />
<circle cx="14" cy="8" r="1.5" />
<circle cx="14" cy="14" r="1.5" />
</svg>
</div>
</div>
</template>
<style>
.CodeEditor {
position: absolute;
bottom: 0;
left: 0;
bottom: 5px;
left: 5px;
width: 50%;
height: 30%;
max-width: calc(100% - 10px);
max-height: calc(100% - 10px);
backdrop-filter: blur(16px);
&.v-enter-active,
&.v-leave-active {
transition:
transform 0.2s ease,
opacity 0.2s ease;
}
&.v-enter-from,
&.v-leave-to {
transform: scale(95%);
opacity: 0;
}
}
.codemirror-container {
.resize-handle {
position: absolute;
top: -3px;
right: -3px;
width: 20px;
height: 20px;
padding: 5px;
/* cursor: nesw-resize; */
svg {
fill: black;
width: 100%;
height: 100%;
opacity: 0.1;
transition: opacity 0.1s ease-in-out;
}
&:hover svg {
opacity: 0.9;
}
}
.CodeEditor :is(.cm-editor) {
color: white;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.3);
background-color: rgba(255, 255, 255, 0.35);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 5px;
opacity: 1;
color: black;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.4);
font-size: 12px;
outline: 1px solid transparent;
transition: outline 0.1s ease-in-out;
}
.cm-editor {
width: 100%;
height: 100%;
}
.cm-gutters {
display: none !important;
.CodeEditor :is(.cm-focused) {
outline: 1px solid rgba(0, 0, 0, 0.5);
}
</style>

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
import { Filtering } from '@/components/ComponentBrowser/filtering'
import { default as SvgIcon } from '@/components/SvgIcon.vue'
import { default as ToggleIcon } from '@/components/ToggleIcon.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import ToggleIcon from '@/components/ToggleIcon.vue'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { useApproach } from '@/util/animation'
import { useResizeObserver } from '@/util/events'

View File

@ -1,17 +1,5 @@
<script lang="ts">
import { defineKeybinds } from '@/util/shortcuts'
const graphBindings = defineKeybinds('graph-editor', {
undo: ['Mod+Z'],
redo: ['Mod+Y', 'Mod+Shift+Z'],
dragScene: ['PointerAux', 'Mod+PointerMain'],
openComponentBrowser: ['Enter'],
newNode: ['N'],
})
</script>
<script setup lang="ts">
import { nodeBindings } from '@/bindings/nodeSelection'
import { codeEditorBindings, graphBindings, nodeBindings } from '@/bindings'
import CodeEditor from '@/components/CodeEditor.vue'
import ComponentBrowser from '@/components/ComponentBrowser.vue'
import GraphEdge from '@/components/GraphEdge.vue'
@ -19,16 +7,18 @@ import GraphNode from '@/components/GraphNode.vue'
import SelectionBrush from '@/components/SelectionBrush.vue'
import TopBar from '@/components/TopBar.vue'
import { useGraphStore } from '@/stores/graph'
import { ExecutionContext, useProjectStore } from '@/stores/project'
import { useProjectStore } from '@/stores/project'
import type { Rect } from '@/stores/rect'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { colorFromString } from '@/util/colors'
import { keyboardBusy, usePointer, useWindowEvent } from '@/util/events'
import { keyboardBusy, keyboardBusyExceptIn, usePointer, useWindowEvent } from '@/util/events'
import { useNavigator } from '@/util/navigator'
import { Vec2 } from '@/util/vec2'
import * as set from 'lib0/set'
import type { ContentRange, ExprId } from 'shared/yjsModel'
import { computed, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue'
import { computed, onMounted, reactive, ref, shallowRef, watch } from 'vue'
</script>
<script setup lang="ts">
const EXECUTION_MODES = ['design', 'live']
const SELECTION_BRUSH_MARGIN_PX = 6
@ -37,7 +27,6 @@ const viewportNode = ref<HTMLElement>()
const navigator = useNavigator(viewportNode)
const graphStore = useGraphStore()
const projectStore = useProjectStore()
const executionCtx = shallowRef<ExecutionContext>()
const componentBrowserVisible = ref(false)
const componentBrowserPosition = ref(Vec2.Zero())
const suggestionDb = useSuggestionDbStore()
@ -47,16 +36,6 @@ const exprRects = reactive(new Map<ExprId, Rect>())
const selectedNodes = ref(new Set<ExprId>())
const latestSelectedNode = ref<ExprId>()
onMounted(async () => {
const executionCtxPromise = projectStore.createExecutionContextForMain()
onUnmounted(async () => {
executionCtx.value = undefined
const ctx = await executionCtxPromise
if (ctx != null) ctx.destroy()
})
executionCtx.value = (await executionCtxPromise) ?? undefined
})
function updateNodeRect(id: ExprId, rect: Rect) {
nodeRects.set(id, rect)
}
@ -66,10 +45,7 @@ function updateExprRect(id: ExprId, rect: Rect) {
}
useWindowEvent('keydown', (event) => {
if (keyboardBusy()) {
return
}
graphBindingsHandler(event) || nodeSelectionHandler(event)
graphBindingsHandler(event) || nodeSelectionHandler(event) || codeEditorHandler(event)
})
onMounted(() => viewportNode.value?.focus())
@ -84,13 +60,15 @@ function updateNodeContent(id: ExprId, updates: [ContentRange, string][]) {
function moveNode(id: ExprId, delta: Vec2) {
const scaledDelta = delta.scale(1 / navigator.scale)
for (const id_ of selectedNodes.value.has(id) ? selectedNodes.value : [id]) {
const node = graphStore.nodes.get(id_)
if (node == null) {
continue
graphStore.transact(() => {
for (const id_ of selectedNodes.value.has(id) ? selectedNodes.value : [id]) {
const node = graphStore.nodes.get(id_)
if (node == null) {
continue
}
graphStore.setNodePosition(id_, node.position.add(scaledDelta))
}
graphStore.setNodePosition(id_, node.position.add(scaledDelta))
}
})
}
const selectionAnchor = shallowRef<Vec2>()
@ -168,15 +146,13 @@ function updateLatestSelectedNode(id: ExprId) {
const graphBindingsHandler = graphBindings.handler({
undo() {
projectStore.undoManager.undo()
projectStore.module?.undoManager.undo()
},
redo() {
projectStore.undoManager.redo()
projectStore.module?.undoManager.redo()
},
openComponentBrowser() {
if (keyboardBusy()) {
return false
}
if (keyboardBusy()) return false
if (navigator.sceneMousePos != null && !componentBrowserVisible.value) {
componentBrowserPosition.value = navigator.sceneMousePos
componentBrowserVisible.value = true
@ -189,13 +165,25 @@ const graphBindingsHandler = graphBindings.handler({
},
})
const codeEditorArea = ref<HTMLElement>()
const showCodeEditor = ref(false)
const codeEditorHandler = codeEditorBindings.handler({
toggle() {
if (keyboardBusyExceptIn(codeEditorArea.value)) return false
showCodeEditor.value = !showCodeEditor.value
},
})
const nodeSelectionHandler = nodeBindings.handler({
deleteSelected() {
for (const node of selectedNodes.value) {
graphStore.deleteNode(node)
}
graphStore.transact(() => {
for (const node of selectedNodes.value) {
graphStore.deleteNode(node)
}
})
},
selectAll() {
if (keyboardBusy()) return
for (const id of graphStore.nodes.keys()) {
selectedNodes.value.add(id)
}
@ -203,6 +191,19 @@ const nodeSelectionHandler = nodeBindings.handler({
deselectAll() {
clearSelection()
selectedNodes.value.clear()
graphStore.stopCapturingUndo()
},
toggleVisualization() {
if (keyboardBusy()) return false
graphStore.transact(() => {
const allHidden = set
.toArray(selectedNodes.value)
.every((id) => graphStore.nodes.get(id)?.vis?.visible !== true)
for (const nodeId of selectedNodes.value) {
graphStore.setNodeVisualizationVisible(nodeId, allHidden)
}
})
},
})
@ -291,9 +292,9 @@ const groupColors = computed(() => {
v-for="(edge, index) in graphStore.edges"
:key="index"
:edge="edge"
:node-rects="nodeRects"
:expr-rects="exprRects"
:expr-nodes="graphStore.exprNodes"
:nodeRects="nodeRects"
:exprRects="exprRects"
:exprNodes="graphStore.exprNodes"
/>
</svg>
<div :style="{ transform: navigator.transform }" class="htmlLayer">
@ -302,7 +303,8 @@ const groupColors = computed(() => {
:key="id"
:node="node"
:selected="selectedNodes.has(id)"
:is-latest-selected="id === latestSelectedNode"
:isLatestSelected="id === latestSelectedNode"
:fullscreenVis="false"
@update:selected="setSelected(id, $event), $event && updateLatestSelectedNode(id)"
@replaceSelection="
selectedNodes.clear(), selectedNodes.add(id), updateLatestSelectedNode(id)
@ -311,6 +313,8 @@ const groupColors = computed(() => {
@delete="graphStore.deleteNode(id)"
@updateExprRect="updateExprRect"
@updateContent="updateNodeContent(id, $event)"
@setVisualizationId="graphStore.setNodeVisualizationId(id, $event)"
@setVisualizationVisible="graphStore.setNodeVisualizationVisible(id, $event)"
@movePosition="moveNode(id, $event)"
/>
</div>
@ -330,7 +334,13 @@ const groupColors = computed(() => {
@forward="console.log('breadcrumbs \'forward\' button clicked.')"
@execute="console.log('\'execute\' button clicked.')"
/>
<CodeEditor ref="codeEditor" />
<div ref="codeEditorArea">
<Suspense>
<Transition>
<CodeEditor v-if="showCodeEditor" />
</Transition>
</Suspense>
</div>
<SelectionBrush
v-if="scaledMousePos"
:position="scaledMousePos"

View File

@ -1,20 +1,27 @@
<script setup lang="ts">
import { computed, onUpdated, reactive, ref, shallowRef, watch, watchEffect } from 'vue'
import { nodeBindings } from '@/bindings/nodeSelection'
import { nodeBindings } from '@/bindings'
import CircularMenu from '@/components/CircularMenu.vue'
import NodeSpan from '@/components/NodeSpan.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
import {
provideVisualizationConfig,
type VisualizationConfig,
} from '@/providers/visualizationConfig'
import type { Node } from '@/stores/graph'
import { Rect } from '@/stores/rect'
import { useVisualizationStore, type Visualization } from '@/stores/visualization'
import { keyboardBusy, useDocumentEvent, usePointer, useResizeObserver } from '@/util/events'
import {
DEFAULT_VISUALIZATION_CONFIGURATION,
DEFAULT_VISUALIZATION_IDENTIFIER,
useVisualizationStore,
type Visualization,
} from '@/stores/visualization'
import { usePointer, useResizeObserver } from '@/util/events'
import type { Opt } from '@/util/opt'
import type { Vec2 } from '@/util/vec2'
import type { ContentRange, ExprId } from 'shared/yjsModel'
import type { ContentRange, ExprId, VisualizationIdentifier } from 'shared/yjsModel'
import { computed, onUpdated, reactive, ref, shallowRef, watch, watchEffect } from 'vue'
import { useProjectStore } from '../stores/project'
const MAXIMUM_CLICK_LENGTH_MS = 300
@ -22,6 +29,7 @@ const props = defineProps<{
node: Node
selected: boolean
isLatestSelected: boolean
fullscreenVis: boolean
}>()
const emit = defineEmits<{
@ -29,6 +37,8 @@ const emit = defineEmits<{
updateExprRect: [id: ExprId, rect: Rect]
updateContent: [updates: [range: ContentRange, content: string][]]
movePosition: [delta: Vec2]
setVisualizationId: [id: Opt<VisualizationIdentifier>]
setVisualizationVisible: [visible: boolean]
delete: []
replaceSelection: []
'update:selected': [selected: boolean]
@ -40,6 +50,29 @@ const rootNode = ref<HTMLElement>()
const nodeSize = useResizeObserver(rootNode)
const editableRootNode = ref<HTMLElement>()
type PreprocessorDef = {
visualizationModule: string
expression: string
positionalArgumentsExpressions: string[]
}
const visPreprocessor = ref<PreprocessorDef>(DEFAULT_VISUALIZATION_CONFIGURATION)
const isAutoEvaluationDisabled = ref(false)
const isDocsVisible = ref(false)
const isVisualizationVisible = computed(() => props.node.vis?.visible ?? false)
const visualization = shallowRef<Visualization>()
const projectStore = useProjectStore()
const visualizationData = projectStore.useVisualizationData(() => {
if (!isVisualizationVisible.value || !visPreprocessor.value) return
return {
...visPreprocessor.value,
expressionId: props.node.rootSpan.id,
}
})
watchEffect(() => {
const size = nodeSize.value
if (!size.isZero()) {
@ -259,66 +292,59 @@ onUpdated(() => {
}
})
const isAutoEvaluationDisabled = ref(false)
const isDocsVisible = ref(false)
const isVisualizationVisible = ref(false)
function updatePreprocessor(
visualizationModule: string,
expression: string,
...positionalArgumentsExpressions: string[]
) {
visPreprocessor.value = { visualizationModule, expression, positionalArgumentsExpressions }
}
const visualizationType = ref('Scatterplot')
const visualization = shallowRef<Visualization>()
const queuedVisualizationData = computed<{}>(() =>
visualizationStore.sampleData(visualizationType.value),
)
const visualizationData = ref<{}>({})
function switchToDefaultPreprocessor() {
visPreprocessor.value = DEFAULT_VISUALIZATION_CONFIGURATION
}
const visualizationConfig = ref<VisualizationConfig>({
fullscreen: false,
types: visualizationStore.types,
width: null,
height: 150, // FIXME:
height: 150,
hide() {
isVisualizationVisible.value = false
},
updateType(type) {
visualizationType.value = type
emit('setVisualizationVisible', false)
},
updateType: (id) => emit('setVisualizationId', id),
isCircularMenuVisible: props.isLatestSelected,
get nodeSize() {
return nodeSize.value
},
get currentType() {
return props.node.vis ?? DEFAULT_VISUALIZATION_IDENTIFIER
},
})
provideVisualizationConfig(visualizationConfig)
useDocumentEvent('keydown', (event) => {
if (keyboardBusy()) {
watchEffect(async () => {
if (props.node.vis == null) {
return
}
if (event.key === ' ') {
if (event.shiftKey) {
if (isVisualizationVisible.value) {
visualizationConfig.value.fullscreen = !visualizationConfig.value.fullscreen
} else {
isVisualizationVisible.value = true
visualizationConfig.value.fullscreen = true
}
visualization.value = undefined
const module = await visualizationStore.get(props.node.vis)
if (module) {
if (module.defaultPreprocessor != null) {
updatePreprocessor(...module.defaultPreprocessor)
} else {
isVisualizationVisible.value = !isVisualizationVisible.value
switchToDefaultPreprocessor()
}
visualization.value = module.default
}
})
watchEffect(async (onCleanup) => {
if (isVisualizationVisible.value) {
let shouldSwitchVisualization = true
onCleanup(() => {
shouldSwitchVisualization = false
})
const component = await visualizationStore.get(visualizationType.value)
if (shouldSwitchVisualization) {
visualization.value = component
visualizationData.value = queuedVisualizationData.value
}
const effectiveVisualization = computed(() => {
if (!visualization.value || visualizationData.value == null) {
return LoadingVisualization
}
return visualization.value
})
watch(
@ -328,14 +354,6 @@ watch(
},
)
function updatePreprocessor(module: string, method: string, ...args: string[]) {
console.log(
`preprocessor changed. node id: ${
props.node.rootSpan.id
} module: ${module}, method: ${method}, args: [${args.join(', ')}]`,
)
}
const mouseHandler = nodeBindings.handler({
replace() {
emit('replaceSelection')
@ -384,7 +402,11 @@ const dragPointer = usePointer((pos, event, type) => {
ref="rootNode"
class="GraphNode"
:style="{ transform }"
:class="{ dragging: dragPointer.dragging, selected }"
:class="{
dragging: dragPointer.dragging,
selected,
visualizationVisible: isVisualizationVisible,
}"
>
<div class="selection" v-on="dragPointer.events"></div>
<div class="binding" @pointerdown.stop>
@ -394,14 +416,14 @@ const dragPointer = usePointer((pos, event, type) => {
v-if="isLatestSelected"
v-model:is-auto-evaluation-disabled="isAutoEvaluationDisabled"
v-model:is-docs-visible="isDocsVisible"
v-model:is-visualization-visible="isVisualizationVisible"
:isVisualizationVisible="isVisualizationVisible"
@update:isVisualizationVisible="emit('setVisualizationVisible', $event)"
/>
<component
:is="visualization"
v-if="isVisualizationVisible && visualization"
:is="effectiveVisualization"
v-if="isVisualizationVisible && effectiveVisualization != null"
:data="visualizationData"
@update:preprocessor="updatePreprocessor"
@update:type="visualizationType = $event"
/>
<div class="node" v-on="dragPointer.events">
<SvgIcon class="icon grab-handle" name="number_input"></SvgIcon>
@ -412,6 +434,7 @@ const dragPointer = usePointer((pos, event, type) => {
spellcheck="false"
@beforeinput="editContent"
@pointerdown.stop
@blur="projectStore.stopCapturingUndo()"
>
<NodeSpan
:content="node.content"
@ -426,10 +449,16 @@ const dragPointer = usePointer((pos, event, type) => {
<style scoped>
.GraphNode {
--node-height: 32px;
--node-border-radius: calc(var(--node-height) * 0.5);
--node-color-primary: #357ab9;
position: absolute;
border-radius: var(--radius-full);
transition: box-shadow 0.2s ease-in-out;
::selection {
background-color: rgba(255, 255, 255, 20%);
}
}
.node {
@ -437,9 +466,10 @@ const dragPointer = usePointer((pos, event, type) => {
top: 0;
left: 0;
caret-shape: bar;
height: var(--node-height);
background: var(--node-color-primary);
background-clip: padding-box;
border-radius: var(--radius-full);
border-radius: var(--node-border-radius);
display: flex;
flex-direction: row;
align-items: center;
@ -447,17 +477,15 @@ const dragPointer = usePointer((pos, event, type) => {
padding: 4px 8px;
z-index: 2;
}
.GraphNode .selection {
position: absolute;
inset: calc(0px - var(--selected-node-border-width));
border-radius: var(--radius-full);
&:before {
content: '';
opacity: 0;
position: absolute;
border-radius: var(--radius-full);
border-radius: var(--node-border-radius);
display: block;
inset: var(--selected-node-border-width);
box-shadow: 0 0 0 0 var(--node-color-primary);
@ -468,7 +496,7 @@ const dragPointer = usePointer((pos, event, type) => {
}
}
.GraphNode.selected .selection:before,
.GraphNode:is(:hover, .selected) .selection:before,
.GraphNode .selection:hover:before {
box-shadow: 0 0 0 var(--selected-node-border-width) var(--node-color-primary);
}
@ -483,7 +511,6 @@ const dragPointer = usePointer((pos, event, type) => {
.GraphNode.selected .selection:hover:before {
opacity: 0.3;
}
.binding {
user-select: none;
margin-right: 10px;
@ -492,6 +519,13 @@ const dragPointer = usePointer((pos, event, type) => {
right: 100%;
top: 50%;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.GraphNode .selection:hover + .binding,
.GraphNode.selected .binding {
opacity: 1;
}
.editable {
@ -511,13 +545,7 @@ const dragPointer = usePointer((pos, event, type) => {
margin-right: 10px;
}
.visualization {
position: absolute;
top: 100%;
width: 100%;
margin-top: 4px;
padding: 4px;
background: #222;
border-radius: 16px;
.CircularMenu {
z-index: 1;
}
</style>

View File

@ -10,7 +10,7 @@ const emit = defineEmits<{ execute: []; 'update:mode': [mode: string] }>()
<span class="title" v-text="props.title"></span>
<ExecutionModeSelector
:modes="props.modes"
:model-value="props.mode"
:modelValue="props.mode"
@execute="emit('execute')"
@update:modelValue="emit('update:mode', $event)"
/>

View File

@ -127,8 +127,9 @@ const resizeBottomRight = usePointer((pos, _, type) => {
<VisualizationSelector
v-if="isSelectorVisible"
:types="config.types"
:modelValue="config.currentType"
@hide="isSelectorVisible = false"
@update:type="
@update:modelValue="
(type) => {
isSelectorVisible = false
config.updateType(type)
@ -148,12 +149,11 @@ const resizeBottomRight = usePointer((pos, _, type) => {
<style scoped>
.VisualizationContainer {
color: var(--color-text);
background: var(--color-visualization-bg);
position: absolute;
min-width: 100%;
width: min-content;
color: var(--color-text);
z-index: -1;
border-radius: var(--radius-default);
}
@ -189,11 +189,6 @@ const resizeBottomRight = usePointer((pos, _, type) => {
transition-property: padding-left;
}
.VisualizationContainer.fullscreen .toolbars,
.VisualizationContainer:not(.circular-menu-visible) .toolbars {
padding-left: 4px;
}
.content {
overflow: auto;
}

View File

@ -1,29 +1,46 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useDocumentEvent } from '../util/events'
import { visIdentifierEquals, type VisualizationIdentifier } from 'shared/yjsModel'
import { onMounted, ref } from 'vue'
const props = defineProps<{ types: string[] }>()
const emit = defineEmits<{ hide: []; 'update:type': [type: string] }>()
const props = defineProps<{
types: VisualizationIdentifier[]
modelValue: VisualizationIdentifier
}>()
const emit = defineEmits<{ hide: []; 'update:modelValue': [type: VisualizationIdentifier] }>()
const rootNode = ref<HTMLElement>()
useDocumentEvent('pointerdown', (event) => {
if (event.target instanceof Node && rootNode.value?.contains(event.target)) {
return
function visIdLabel(id: VisualizationIdentifier) {
switch (id.module.kind) {
case 'Builtin':
return id.name
case 'Library':
return `${id.name} (from library ${id.module.name})`
case 'CurrentProject':
return `${id.name} (from project)`
}
emit('hide')
}
function visIdKey(id: VisualizationIdentifier) {
const kindKey = id.module.kind === 'Library' ? `Library::${id.module.name}` : id.module.kind
return `${kindKey}::${id.name}`
}
onMounted(() => {
setTimeout(() => rootNode.value?.focus(), 0)
})
</script>
<template>
<div ref="rootNode" class="VisualizationSelector">
<div ref="rootNode" :tabindex="-1" class="VisualizationSelector" @blur="emit('hide')">
<div class="background"></div>
<ul>
<li
v-for="type_ in props.types"
:key="type_"
@click="emit('update:type', type_)"
v-text="type_"
:key="visIdKey(type_)"
:class="{ selected: visIdentifierEquals(props.modelValue, type_) }"
@pointerdown.stop="emit('update:modelValue', type_)"
v-text="visIdLabel(type_)"
></li>
</ul>
</div>
@ -67,6 +84,10 @@ li {
border-radius: 12px;
white-space: nowrap;
&.selected {
background: var(--color-menu-entry-selected-bg);
}
&:hover {
background: var(--color-menu-entry-hover-bg);
}

View File

@ -1,6 +1,10 @@
<script lang="ts">
export const name = 'Heatmap'
export const inputType = 'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector'
export const defaultPreprocessor = [
'Standard.Visualization.Table.Visualization',
'prepare_visualization',
] as const
type Data = HeatmapData | HeatmapArrayData | HeatmapJSONData | HeatmapUpdate
@ -36,16 +40,14 @@ interface Bucket {
</script>
<script setup lang="ts">
import { computed, ref, watchPostEffect } from 'vue'
import * as d3 from 'd3'
import { computed, onMounted, ref, watchPostEffect } from 'vue'
import VisualizationContainer from '@/components/VisualizationContainer.vue'
import { useVisualizationConfig } from '@/providers/visualizationConfig.ts'
const props = defineProps<{ data: Data }>()
const emit = defineEmits<{
'update:preprocessor': [module: string, method: string, ...args: string[]]
}>()
const config = useVisualizationConfig()
@ -101,10 +103,6 @@ watchPostEffect(() => {
const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right))
const boxHeight = computed(() => Math.max(0, height.value - MARGIN.top - MARGIN.bottom))
onMounted(() => {
emit('update:preprocessor', 'Standard.Visualization.Table.Visualization', 'prepare_visualization')
})
const buckets = computed(() => {
const newData = data.value
let groups: number[] = []
@ -213,7 +211,7 @@ watchPostEffect(() => {
</script>
<template>
<VisualizationContainer :below-toolbar="true">
<VisualizationContainer :belowToolbar="true">
<div ref="containerNode" class="HeatmapVisualization">
<svg :width="width" :height="height">
<g :transform="`translate(${MARGIN.left},${MARGIN.top})`">

View File

@ -1,10 +1,15 @@
<script lang="ts">
import { defineKeybinds } from '@/util/shortcuts'
export const name = 'Histogram'
export const inputType =
'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector | Standard.Image.Data.Histogram.Histogram'
export const defaultPreprocessor = [
'Standard.Visualization.Histogram',
'process_to_json_text',
] as const
const bindings = defineKeybinds('scatterplot-visualization', {
const bindings = defineKeybinds('histogram-visualization', {
zoomIn: ['Mod+Z'],
showAll: ['Mod+A'],
})
@ -88,8 +93,8 @@ interface AxisConfiguration {
}
interface AxesConfiguration {
x: AxisConfiguration
y: AxisConfiguration
x: AxisConfiguration | undefined
y: AxisConfiguration | undefined
}
interface Bin {
@ -100,8 +105,9 @@ interface Bin {
</script>
<script setup lang="ts">
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
import * as d3 from 'd3'
import { computed, onMounted, ref, watch, watchEffect, watchPostEffect } from 'vue'
import SvgIcon from '@/components/SvgIcon.vue'
import VisualizationContainer from '@/components/VisualizationContainer.vue'
@ -130,9 +136,6 @@ const MID_BUTTON_CLICKED = 4
const SCROLL_WHEEL = 0
const props = defineProps<{ data: Data }>()
const emit = defineEmits<{
'update:preprocessor': [module: string, method: string, ...args: string[]]
}>()
const config = useVisualizationConfig()
@ -155,7 +158,7 @@ const points = ref<number[]>([])
const rawBins = ref<number[]>()
const binCount = ref(DEFAULT_NUMBER_OF_BINS)
const axis = ref(DEFAULT_AXES_CONFIGURATION)
const rawFocus = ref<Focus>()
const focus = ref<Focus>()
const brushExtent = ref<d3.BrushSelection>()
const zoomLevel = ref(1)
const shouldAnimate = ref(false)
@ -224,7 +227,7 @@ watchEffect(() => {
axis.value = rawData.axis
}
if (rawData.focus != null) {
rawFocus.value = rawData.focus
focus.value = rawData.focus
}
if (rawData.bins != null) {
binCount.value = Math.max(1, rawData.bins)
@ -263,9 +266,9 @@ watchPostEffect(() => {
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
const xLabelTop = computed(() => boxHeight.value + margin.value.bottom - AXIS_LABEL_HEIGHT / 2)
const xLabelLeft = computed(() => boxWidth.value / 2 + getTextWidth(axis.value.x.label) / 2)
const xLabelLeft = computed(() => boxWidth.value / 2 + getTextWidth(axis.value.x?.label) / 2)
const yLabelTop = computed(() => -margin.value.left + AXIS_LABEL_HEIGHT)
const yLabelLeft = computed(() => -boxHeight.value / 2 + getTextWidth(axis.value.y.label) / 2)
const yLabelLeft = computed(() => -boxHeight.value / 2 + getTextWidth(axis.value.y?.label) / 2)
let startX = 0
let startY = 0
@ -416,7 +419,7 @@ function zoomToSelected() {
if (brushExtent.value == null) {
return
}
rawFocus.value = undefined
focus.value = undefined
const xScale_ = xScale.value
const startRaw = brushExtent.value[0]
const endRaw = brushExtent.value[1]
@ -468,7 +471,7 @@ const xExtents = computed<[min: number, max: number]>(() => {
})
watchEffect(() => {
const focus_ = rawFocus.value
const focus_ = focus.value
if (focus_?.x != null && focus_.zoom != null) {
let paddingX = extremesAndDeltas.value.dx / (2 * focus_.zoom)
xDomain.value = [focus_.x - paddingX, focus_.x + paddingX]
@ -496,14 +499,6 @@ function updateColorLegend(colorScale: d3.ScaleSequential<string>) {
.attr('stop-color', (d) => d.color)
}
// =============
// === Setup ===
// =============
onMounted(() => {
emit('update:preprocessor', 'Standard.Visualization.Histogram', 'process_to_json_text')
})
// ==============
// === Update ===
// ==============
@ -566,7 +561,7 @@ watchPostEffect(() => {
// ======================
function showAll() {
rawFocus.value = undefined
focus.value = undefined
zoomLevel.value = 1
xDomain.value = originalXScale.value.domain()
shouldAnimate.value = true
@ -581,7 +576,7 @@ useEvent(document, 'scroll', endBrushing)
</script>
<template>
<VisualizationContainer :below-toolbar="true">
<VisualizationContainer :belowToolbar="true">
<template #toolbar>
<button class="image-button active">
<SvgIcon name="show_all" alt="Fit all" @click="showAll" />
@ -616,20 +611,20 @@ useEvent(document, 'scroll', endBrushing)
<g ref="xAxisNode" class="axis-x" :transform="`translate(0, ${boxHeight})`"></g>
<g ref="yAxisNode" class="axis-y"></g>
<text
v-if="axis.x.label"
v-if="axis.x?.label"
class="label label-x"
text-anchor="end"
:x="xLabelLeft"
:y="xLabelTop"
v-text="axis.x.label"
v-text="axis.x?.label"
></text>
<text
v-if="axis.y.label"
v-if="axis.y?.label"
class="label label-y"
text-anchor="end"
:x="yLabelLeft"
:y="yLabelTop"
v-text="axis.y.label"
v-text="axis.y?.label"
></text>
<g ref="plotNode" clip-path="url(#histogram-clip-path)"></g>
<g ref="zoomNode" class="zoom">

View File

@ -23,7 +23,7 @@ const src = computed(
</script>
<template>
<VisualizationContainer :below-node="true">
<VisualizationContainer :belowNode="true">
<div class="ImageVisualization">
<img :src="src" />
</div>

View File

@ -4,24 +4,13 @@ export const inputType = 'Any'
</script>
<script setup lang="ts">
import { onMounted } from 'vue'
import VisualizationContainer from '@/components/VisualizationContainer.vue'
type Data = Record<string, unknown>
const props = defineProps<{ data: Data }>()
const emit = defineEmits<{
'update:preprocessor': [module: string, method: string]
}>()
onMounted(() => {
emit('update:preprocessor', 'Standard.Visualization.Preprocessor', 'error_preprocessor')
})
const props = defineProps<{ data: unknown }>()
</script>
<template>
<VisualizationContainer :below-toolbar="true">
<VisualizationContainer :belowToolbar="true">
<div class="JSONVisualization" v-text="props.data"></div>
</VisualizationContainer>
</template>

View File

@ -0,0 +1,43 @@
<script lang="ts">
export const name = 'Loading'
export const inputType = 'Any'
</script>
<script setup lang="ts">
import VisualizationContainer from '@/components/VisualizationContainer.vue'
const _props = defineProps<{ data: unknown }>()
</script>
<template>
<VisualizationContainer>
<div class="LoadingVisualization"></div>
</VisualizationContainer>
</template>
<style scoped>
.LoadingVisualization {
display: flex;
height: 100%;
padding-top: 30px;
place-content: center;
place-items: center;
overflow: clip;
}
.LoadingVisualization::before {
content: '';
display: block;
width: 30px;
height: 30px;
border-radius: 50%;
border: 4px solid;
border-color: rgba(0, 0, 0, 30%) #0000;
animation: s1 0.8s infinite;
}
@keyframes s1 {
to {
transform: rotate(0.5turn);
}
}
</style>

View File

@ -1,6 +1,10 @@
<script lang="ts">
export const name = 'SQL Query'
export const inputType = 'Standard.Database.Data.Table.Table | Standard.Database.Data.Column.Column'
export const defaultPreprocessor = [
'Standard.Visualization.SQL.Visualization',
'prepare_visualization',
] as const
/**
* A visualization that pretty-prints generated SQL code and displays type hints related to
@ -32,7 +36,7 @@ declare const sqlFormatter: typeof import('sql-formatter')
</script>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed } from 'vue'
// @ts-expect-error
// eslint-disable-next-line no-redeclare
@ -42,9 +46,6 @@ import VisualizationContainer from '@/components/VisualizationContainer.vue'
import { DEFAULT_THEME, type RGBA, type Theme } from './builtins.ts'
const props = defineProps<{ data: Data }>()
const emit = defineEmits<{
'update:preprocessor': [module: string, method: string, ...args: string[]]
}>()
const theme: Theme = DEFAULT_THEME
@ -72,10 +73,6 @@ const TEXT_TYPE = 'Builtins.Main.Text'
/** Specifies opacity of interpolation background color. */
const INTERPOLATION_BACKGROUND_OPACITY = 0.2
onMounted(() => {
emit('update:preprocessor', 'Standard.Visualization.SQL.Visualization', 'prepare_visualization')
})
// === Handling Colors ===
/** Render a 4-element array representing a color into a CSS-compatible rgba string. */
@ -130,7 +127,7 @@ function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA)
</script>
<template>
<VisualizationContainer :below-toolbar="true">
<VisualizationContainer :belowToolbar="true">
<div class="sql-visualization scrollable">
<pre v-if="data.error" class="sql" v-text="data.error"></pre>
<!-- eslint-disable-next-line vue/no-v-html This is SAFE, beause it is not user input. -->
@ -142,32 +139,32 @@ function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA)
<style scoped>
@import url('https://fonts.cdnfonts.com/css/dejavu-sans-mono');
.sql-visualization {
.SQLVisualization {
padding: 4px;
}
</style>
<style>
.sql-visualization .sql {
.SQLVisualization .sql {
font-family: 'DejaVu Sans Mono', monospace;
font-size: 12px;
margin-left: 7px;
margin-top: 5px;
}
.sql-visualization .interpolation {
.SQLVisualization .interpolation {
border-radius: 6px;
padding: 1px 2px 1px 2px;
display: inline;
}
.sql-visualization .mismatch-parent {
.SQLVisualization .mismatch-parent {
position: relative;
display: inline-flex;
justify-content: center;
}
.sql-visualization .mismatch-mouse-area {
.SQLVisualization .mismatch-mouse-area {
display: inline;
position: absolute;
width: 150%;
@ -176,15 +173,15 @@ function renderRegularInterpolation(value: string, fgColor: RGBA, bgColor: RGBA)
z-index: 0;
}
.sql-visualization .mismatch {
.SQLVisualization .mismatch {
z-index: 1;
}
.sql-visualization .modulepath {
.SQLVisualization .modulepath {
color: rgba(150, 150, 150, 0.9);
}
.sql-visualization .tooltip {
.SQLVisualization .tooltip {
font-family: DejaVuSansMonoBook, sans-serif;
font-size: 12px;
opacity: 0;

View File

@ -2,6 +2,11 @@
export const name = 'Table'
export const inputType =
'Standard.Table.Data.Table.Table | Standard.Table.Data.Column.Column | Standard.Table.Data.Row.Row |Standard.Base.Data.Vector.Vector | Standard.Base.Data.Array.Array | Standard.Base.Data.Map.Map | Any'
export const defaultPreprocessor = [
'Standard.Visualization.Table.Visualization',
'prepare_visualization',
'1000',
] as const
type Data = Error | Matrix | ObjectMatrix | LegacyMatrix | LegacyObjectMatrix | UnknownTable
@ -408,8 +413,8 @@ watch(
</script>
<template>
<VisualizationContainer :below-toolbar="true">
<div ref="rootNode" class="TableVisualization" @wheel.stop>
<VisualizationContainer :belowToolbar="true" :overflow="true">
<div ref="rootNode" class="TableVisualization" @wheel.stop @pointerdown.stop>
<div class="table-visualization-status-bar">
<button :disabled="isFirstPage" @click="goToFirstPage">«</button>
<button :disabled="isFirstPage" @click="goToPreviousPage">&lsaquo;</button>

View File

@ -1,27 +1,22 @@
<script lang="ts">
export const name = 'Warnings'
export const inputType = 'Any'
export const defaultPreprocessor = [
'Standard.Visualization.Warnings',
'process_to_json_text',
] as const
</script>
<script setup lang="ts">
import { onMounted } from 'vue'
import VisualizationContainer from '@/components/VisualizationContainer.vue'
type Data = string[]
const props = defineProps<{ data: Data }>()
const emit = defineEmits<{
'update:preprocessor': [module: string, method: string, ...args: string[]]
}>()
onMounted(() => {
emit('update:preprocessor', 'Standard.Visualization.Warnings', 'process_to_json_text')
})
</script>
<template>
<VisualizationContainer :below-toolbar="true">
<VisualizationContainer :belowToolbar="true">
<div class="WarningsVisualization">
<ul>
<li v-if="props.data.length === 0">There are no warnings.</li>

16
app/gui2/src/createApp.ts Normal file
View File

@ -0,0 +1,16 @@
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import AppRoot from './App.vue'
import './assets/main.css'
import type { StringConfig } from './main'
export function mountProjectApp(rootProps: {
config: StringConfig | null
accessToken: string | null
metadata?: object | undefined
}) {
const app = createApp(AppRoot, rootProps)
app.use(createPinia())
app.mount('#app')
return app
}

View File

@ -2,16 +2,9 @@ import 'enso-dashboard/src/tailwind.css'
const INITIAL_URL_KEY = `Enso-initial-url`
import './assets/main.css'
import { basicSetup } from 'codemirror'
import * as dashboard from 'enso-authentication'
import { isMac } from 'lib0/environment'
import { decodeQueryParams } from 'lib0/url'
import { createPinia } from 'pinia'
import { createApp, type App } from 'vue'
import VueCodemirror from 'vue-codemirror'
import AppRoot from './App.vue'
const params = decodeQueryParams(location.href)
@ -24,25 +17,29 @@ const config = {
initialProjectName: params.project ?? null,
}
let app: App | null = null
let unmount: null | (() => void) = null
let runRequested = false
interface StringConfig {
export interface StringConfig {
[key: string]: StringConfig | string
}
const vueAppEntry = import('./createApp')
async function runApp(config: StringConfig | null, accessToken: string | null, metadata?: object) {
if (app != null) stopApp()
const rootProps = { config, accessToken, metadata }
app = createApp(AppRoot, rootProps)
app.use(createPinia())
app.use(VueCodemirror, { extensions: [basicSetup] })
app.mount('#app')
runRequested = true
const { mountProjectApp } = await vueAppEntry
if (runRequested) {
unmount?.()
const app = mountProjectApp({ config, accessToken, metadata })
unmount = () => app.unmount()
}
}
function stopApp() {
if (app == null) return
app.unmount()
app = null
runRequested = false
unmount?.()
unmount = null
}
const appRunner = { runApp, stopApp }

View File

@ -1,17 +1,19 @@
import type { Vec2 } from '@/util/vec2'
import type { VisualizationIdentifier } from 'shared/yjsModel'
import { inject, provide, type InjectionKey, type Ref } from 'vue'
export interface VisualizationConfig {
/** Possible visualization types that can be switched to. */
background?: string
readonly types: string[]
readonly types: VisualizationIdentifier[]
readonly currentType: VisualizationIdentifier
readonly isCircularMenuVisible: boolean
readonly nodeSize: Vec2
width: number | null
height: number | null
fullscreen: boolean
hide: () => void
updateType: (type: string) => void
updateType: (type: VisualizationIdentifier) => void
}
const provideKey = Symbol('visualizationConfig') as InjectionKey<Ref<VisualizationConfig>>

View File

@ -1,6 +1,6 @@
import { assert, assertNever } from '@/util/assert'
import { useObserveYjs } from '@/util/crdt'
import { parseEnso } from '@/util/ffi'
import { parseEnso, type Ast } from '@/util/ffi'
import type { Opt } from '@/util/opt'
import { Vec2 } from '@/util/vec2'
import * as map from 'lib0/map'
@ -8,14 +8,18 @@ import * as set from 'lib0/set'
import { defineStore } from 'pinia'
import {
rangeIntersects,
visMetadataEquals,
type ContentRange,
type ExprId,
type IdMap,
type NodeMetadata,
type VisualizationIdentifier,
type VisualizationMetadata,
} from 'shared/yjsModel'
import { computed, reactive, ref, watch, watchEffect } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import * as Y from 'yjs'
import { useProjectStore } from './project'
import { DEFAULT_VISUALIZATION_IDENTIFIER } from './visualization'
export const useGraphStore = defineStore('graph', () => {
const proj = useProjectStore()
@ -73,10 +77,8 @@ export const useGraphStore = defineStore('graph', () => {
if (value != null) updateState()
})
const _parsed = ref([] as Statement[])
const _parsedEnso = ref<any>()
watchEffect(() => {})
const _parsed = ref<Statement[]>([])
const _parsedEnso = ref<Ast.Tree>()
function updateState(affectedRanges?: ContentRange[]) {
const module = proj.module
@ -107,8 +109,8 @@ export const useGraphStore = defineStore('graph', () => {
while (affectedRanges[0]?.[1]! < exprRange[0]) {
affectedRanges.shift()
}
if (affectedRanges.length === 0) break
const nodeAffected = rangeIntersects(exprRange, affectedRanges[0]!)
if (affectedRanges[0] == null) break
const nodeAffected = rangeIntersects(exprRange, affectedRanges[0])
if (!nodeAffected) continue
}
@ -132,13 +134,8 @@ export const useGraphStore = defineStore('graph', () => {
const data = meta.get(id)
const node = nodes.get(id as ExprId)
if (data != null && node != null) {
const pos = new Vec2(data.x, -data.y)
if (!node.position.equals(pos)) {
node.position = pos
}
assignUpdatedMetadata(node, data)
}
} else {
console.log(op)
}
}
})
@ -152,12 +149,16 @@ export const useGraphStore = defineStore('graph', () => {
content,
binding: stmt.binding ?? '',
rootSpan: stmt.expression,
position: meta == null ? Vec2.Zero() : new Vec2(meta.x, -meta.y),
position: Vec2.Zero(),
vis: undefined,
docRange: [
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset, -1),
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset + stmt.expression.length),
],
}
if (meta) {
assignUpdatedMetadata(node, meta)
}
identDefinitions.set(node.binding, nodeId)
addSpanUsages(nodeId, node)
nodes.set(nodeId, node)
@ -177,12 +178,22 @@ export const useGraphStore = defineStore('graph', () => {
} else {
node.rootSpan = stmt.expression
}
if (meta != null && !node.position.equals(new Vec2(meta.x, -meta.y))) {
node.position = new Vec2(meta.x, -meta.y)
if (meta != null) {
assignUpdatedMetadata(node, meta)
}
addSpanUsages(nodeId, node)
}
function assignUpdatedMetadata(node: Node, meta: NodeMetadata) {
const newPosition = new Vec2(meta.x, -meta.y)
if (!node.position.equals(newPosition)) {
node.position = newPosition
}
if (!visMetadataEquals(node.vis, meta.vis)) {
node.vis = meta.vis
}
}
function addSpanUsages(id: ExprId, node: Node) {
for (const [span, offset] of walkSpansBfs(node.rootSpan)) {
exprNodes.set(span.id, id)
@ -251,6 +262,7 @@ export const useGraphStore = defineStore('graph', () => {
const meta: NodeMetadata = {
x: position.x,
y: -position.y,
vis: null,
}
const ident = generateUniqueIdent()
const content = `${ident} = ${expression}`
@ -275,16 +287,50 @@ export const useGraphStore = defineStore('graph', () => {
return proj.module?.transact(fn)
}
function replaceNodeSubexpression(id: ExprId, range: ContentRange, content: string) {
const node = nodes.get(id)
function stopCapturingUndo() {
proj.stopCapturingUndo()
}
function replaceNodeSubexpression(nodeId: ExprId, range: ContentRange, content: string) {
const node = nodes.get(nodeId)
if (node == null) return
proj.module?.replaceExpressionContent(node.rootSpan.id, content, range)
}
function setNodePosition(id: ExprId, position: Vec2) {
const node = nodes.get(id)
function setNodePosition(nodeId: ExprId, position: Vec2) {
const node = nodes.get(nodeId)
if (node == null) return
proj.module?.updateNodeMetadata(id, { x: position.x, y: -position.y })
proj.module?.updateNodeMetadata(nodeId, { x: position.x, y: -position.y })
}
function normalizeVisMetadata(
id: Opt<VisualizationIdentifier>,
visible?: boolean,
): VisualizationMetadata | null {
const vis: VisualizationMetadata = {
...(id ?? DEFAULT_VISUALIZATION_IDENTIFIER),
visible: visible ?? false,
}
if (
visMetadataEquals(vis, {
...DEFAULT_VISUALIZATION_IDENTIFIER,
visible: false,
})
)
return null
return vis
}
function setNodeVisualizationId(nodeId: ExprId, vis: Opt<VisualizationIdentifier>) {
const node = nodes.get(nodeId)
if (node == null) return
proj.module?.updateNodeMetadata(nodeId, { vis: normalizeVisMetadata(vis, node.vis?.visible) })
}
function setNodeVisualizationVisible(nodeId: ExprId, visible: boolean) {
const node = nodes.get(nodeId)
if (node == null) return
proj.module?.updateNodeMetadata(nodeId, { vis: normalizeVisMetadata(node.vis, visible) })
}
return {
@ -302,6 +348,9 @@ export const useGraphStore = defineStore('graph', () => {
setExpressionContent,
replaceNodeSubexpression,
setNodePosition,
setNodeVisualizationId,
setNodeVisualizationVisible,
stopCapturingUndo,
}
})
@ -315,6 +364,7 @@ export interface Node {
rootSpan: Span
position: Vec2
docRange: [Y.RelativePosition, Y.RelativePosition]
vis: Opt<VisualizationMetadata>
}
export const enum SpanKind {

View File

@ -1,14 +1,36 @@
import { useGuiConfig, type GuiConfig } from '@/providers/guiConfig'
import { attachProvider } from '@/util/crdt'
import type { Opt } from '@/util/opt'
import { AsyncQueue, rpcWithRetries as lsRpcWithRetries } from '@/util/net'
import { isSome, type Opt } from '@/util/opt'
import { Client, RequestManager, WebSocketTransport } from '@open-rpc/client-js'
import { computedAsync } from '@vueuse/core'
import * as array from 'lib0/array'
import * as object from 'lib0/object'
import * as random from 'lib0/random'
import { defineStore } from 'pinia'
import { OutboundPayload, VisualizationUpdate } from 'shared/binaryProtocol'
import { DataServer } from 'shared/dataServer'
import { LanguageServer } from 'shared/languageServer'
import type { ContentRoot, ContextId, MethodPointer } from 'shared/languageServerTypes'
import { DistributedProject, type Uuid } from 'shared/yjsModel'
import { computed, markRaw, ref, watchEffect } from 'vue'
import type {
ContentRoot,
ContextId,
ExplicitCall,
ExpressionId,
StackItem,
VisualizationConfiguration,
} from 'shared/languageServerTypes'
import { WebsocketClient } from 'shared/websocket'
import { DistributedProject, type ExprId, type Uuid } from 'shared/yjsModel'
import {
computed,
markRaw,
ref,
shallowRef,
watch,
watchEffect,
type ShallowRef,
type WatchSource,
} from 'vue'
import { Awareness } from 'y-protocols/awareness'
import * as Y from 'yjs'
@ -31,19 +53,71 @@ function resolveLsUrl(config: GuiConfig): LsUrls {
throw new Error('Incomplete engine configuration')
}
async function initializeLsRpcConnection(urls: LsUrls): Promise<{
async function initializeLsRpcConnection(
clientId: Uuid,
url: string,
): Promise<{
connection: LanguageServer
contentRoots: ContentRoot[]
}> {
const transport = new WebSocketTransport(urls.rpcUrl)
const transport = new WebSocketTransport(url)
const requestManager = new RequestManager([transport])
const client = new Client(requestManager)
const clientId = random.uuidv4() as Uuid
const connection = new LanguageServer(client)
const contentRoots = (await connection.initProtocolConnection(clientId)).contentRoots
const initialization = await lsRpcWithRetries(() => connection.initProtocolConnection(clientId), {
onBeforeRetry: (error, _, delay) => {
console.warn(
`Failed to initialize language server connection, retrying after ${delay}ms...\n`,
error,
)
},
})
const contentRoots = initialization.contentRoots
return { connection, contentRoots }
}
async function initializeDataConnection(clientId: Uuid, url: string) {
const client = new WebsocketClient(url, { binaryType: 'arraybuffer', sendPings: false })
const connection = new DataServer(client)
await connection.initialize(clientId)
return connection
}
export type NodeVisualizationConfiguration = Omit<
VisualizationConfiguration,
'executionContextId'
> & {
expressionId: ExprId
}
interface ExecutionContextState {
lsRpc: LanguageServer
created: boolean
visualizations: Map<Uuid, NodeVisualizationConfiguration>
stack: StackItem[]
}
function visualizationConfigEqual(
a: NodeVisualizationConfiguration,
b: NodeVisualizationConfiguration,
): boolean {
return (
a === b ||
(a.visualizationModule === b.visualizationModule &&
(a.positionalArgumentsExpressions === b.positionalArgumentsExpressions ||
(Array.isArray(a.positionalArgumentsExpressions) &&
Array.isArray(b.positionalArgumentsExpressions) &&
array.equalFlat(a.positionalArgumentsExpressions, b.positionalArgumentsExpressions))) &&
(a.expression === b.expression ||
(typeof a.expression === 'object' &&
typeof b.expression === 'object' &&
object.equalFlat(a.expression, b.expression))))
)
}
type EntryPoint = Omit<ExplicitCall, 'type'>
/**
* Execution Context
*
@ -54,31 +128,184 @@ async function initializeLsRpcConnection(urls: LsUrls): Promise<{
* run only when the previous call is done.
*/
export class ExecutionContext {
state: Promise<{ lsRpc: LanguageServer; id: ContextId }>
id: ContextId = random.uuidv4() as ContextId
queue: AsyncQueue<ExecutionContextState>
taskRunning = false
visSyncScheduled = false
visualizationConfigs: Map<Uuid, NodeVisualizationConfiguration> = new Map()
abortCtl = new AbortController()
constructor(
lsRpc: Promise<LanguageServer>,
call: {
methodPointer: MethodPointer
thisArgumentExpression?: string
positionalArgumentsExpressions?: string[]
},
) {
this.state = lsRpc.then(async (lsRpc) => {
const { contextId } = await lsRpc.createExecutionContext()
await lsRpc.pushExecutionContextItem(contextId, {
type: 'ExplicitCall',
positionalArgumentsExpressions: call.positionalArgumentsExpressions ?? [],
...call,
})
return { lsRpc, id: contextId }
constructor(lsRpc: Promise<LanguageServer>, entryPoint: EntryPoint) {
this.queue = new AsyncQueue(
lsRpc.then((lsRpc) => ({
lsRpc,
created: false,
visualizations: new Map(),
stack: [],
})),
)
this.create()
this.pushItem({ type: 'ExplicitCall', ...entryPoint })
}
private withBackoff<T>(f: () => Promise<T>, message: string): Promise<T> {
return lsRpcWithRetries(f, {
onBeforeRetry: (error, _, delay) => {
if (this.abortCtl.signal.aborted) return false
console.warn(
`${message}: ${error.payload.cause.message}. Retrying after ${delay}ms...\n`,
error,
)
},
})
}
private syncVisualizations() {
if (this.visSyncScheduled) return
this.visSyncScheduled = true
this.queue.pushTask(async (state) => {
this.visSyncScheduled = false
if (!state.created) return state
const promises: Promise<void>[] = []
const attach = (id: Uuid, config: NodeVisualizationConfiguration) => {
return this.withBackoff(
() =>
state.lsRpc.attachVisualization(id, config.expressionId, {
executionContextId: this.id,
expression: config.expression,
visualizationModule: config.visualizationModule,
...(config.positionalArgumentsExpressions
? { positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}),
}),
'Failed to attach visualization',
).then(() => {
state.visualizations.set(id, config)
})
}
const modify = (id: Uuid, config: NodeVisualizationConfiguration) => {
return this.withBackoff(
() =>
state.lsRpc.modifyVisualization(id, {
executionContextId: this.id,
expression: config.expression,
visualizationModule: config.visualizationModule,
...(config.positionalArgumentsExpressions
? { positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}),
}),
'Failed to modify visualization',
).then(() => {
state.visualizations.set(id, config)
})
}
const detach = (id: Uuid, config: NodeVisualizationConfiguration) => {
return this.withBackoff(
() => state.lsRpc.detachVisualization(id, config.expressionId, this.id),
'Failed to detach visualization',
).then(() => {
state.visualizations.delete(id)
})
}
// Attach new and update existing visualizations.
for (const [id, config] of this.visualizationConfigs) {
const previousConfig = state.visualizations.get(id)
if (previousConfig == null) {
promises.push(attach(id, config))
} else if (!visualizationConfigEqual(previousConfig, config)) {
if (previousConfig.expressionId === config.expressionId) {
promises.push(modify(id, config))
} else {
promises.push(detach(id, previousConfig).then(() => attach(id, config)))
}
}
}
// Detach removed visualizations.
for (const [id, config] of state.visualizations) {
if (this.visualizationConfigs.get(id) == undefined) {
promises.push(detach(id, config))
}
}
const settled = await Promise.allSettled(promises)
// Emit errors for failed requests.
const errors = settled
.map((result) => (result.status === 'rejected' ? result.reason : null))
.filter(isSome)
if (errors.length > 0) {
console.error('Failed to synchronize visualizations:', errors)
}
// State object was updated in-place in each successful promise.
return state
})
}
private pushItem(item: StackItem) {
this.queue.pushTask(async (state) => {
if (!state.created) return state
await this.withBackoff(
() => state.lsRpc.pushExecutionContextItem(this.id, item),
'Failed to push item to execution context stack',
)
state.stack.push(item)
return state
})
}
push(expressionId: ExpressionId) {
this.pushItem({ type: 'LocalCall', expressionId })
}
pop() {
this.queue.pushTask(async (state) => {
if (!state.created) return state
if (state.stack.length === 0) {
throw new Error('Cannot pop from empty execution context stack')
}
await this.withBackoff(
() => state.lsRpc.popExecutionContextItem(this.id),
'Failed to pop item from execution context stack',
)
state.stack.pop()
return state
})
}
async setVisualization(id: Uuid, configuration: Opt<NodeVisualizationConfiguration>) {
if (configuration == null) {
this.visualizationConfigs.delete(id)
} else {
this.visualizationConfigs.set(id, configuration)
}
this.syncVisualizations()
}
private create() {
this.queue.pushTask(async (state) => {
if (state.created) return state
return this.withBackoff(async () => {
const result = await state.lsRpc.createExecutionContext(this.id)
if (result.contextId !== this.id) {
throw new Error('Unexpected Context ID returned by the language server.')
}
return { ...state, created: true }
}, 'Failed to create execution context')
})
}
destroy() {
this.state = this.state.then(({ lsRpc, id }) => {
lsRpc.destroyExecutionContext(id)
return { lsRpc, id }
this.abortCtl.abort()
this.queue.clear()
this.queue.pushTask(async (state) => {
if (!state.created) return state
await state.lsRpc.destroyExecutionContext(this.id)
return { ...state, created: false }
})
}
}
@ -98,12 +325,12 @@ export const useProjectStore = defineStore('project', () => {
const projectName = config.value.startup?.project
if (projectName == null) throw new Error('Missing project name.')
const clientId = random.uuidv4() as Uuid
const lsUrls = resolveLsUrl(config.value)
const initializedConnection = initializeLsRpcConnection(lsUrls)
const initializedConnection = initializeLsRpcConnection(clientId, lsUrls.rpcUrl)
const lsRpcConnection = initializedConnection.then(({ connection }) => connection)
const contentRoots = initializedConnection.then(({ contentRoots }) => contentRoots)
const undoManager = new Y.UndoManager([], { doc })
const dataConnection = initializeDataConnection(clientId, lsUrls.dataUrl)
const name = computed(() => config.value.startup?.project)
const namespace = computed(() => config.value.engine?.namespace)
@ -142,23 +369,14 @@ export const useProjectStore = defineStore('project', () => {
if (guid == null) return null
const moduleName = projectModel.findModuleByDocId(guid)
if (moduleName == null) return null
return await projectModel.openModule(moduleName)
const mod = await projectModel.openModule(moduleName)
mod?.undoManager.addTrackedOrigin('local')
return mod
})
watchEffect((onCleanup) => {
const mod = module.value
if (mod == null) return
const scope: typeof undoManager.scope = [mod.doc.contents, mod.doc.idMap]
undoManager.scope.push(...scope)
onCleanup(() => {
undoManager.scope = undoManager.scope.filter((s) => !scope.includes(s))
})
})
async function createExecutionContextForMain(): Promise<Opt<ExecutionContext>> {
function createExecutionContextForMain(): ExecutionContext {
if (name.value == null) {
console.error('Cannot create execution context. Unknown project name.')
return
throw new Error('Cannot create execution context. Unknown project name.')
}
if (namespace.value == null) {
console.warn(
@ -167,28 +385,65 @@ export const useProjectStore = defineStore('project', () => {
}
const projectName = `${namespace.value ?? 'local'}.${name.value}`
const mainModule = `${projectName}.Main`
const projectRoot = (await contentRoots).find((root) => root.type === 'Project')
if (projectRoot == null) {
console.error(
'Cannot create execution context. Protocol connection initialization did not return a project root.',
)
return
}
const entryPoint = { module: mainModule, definedOnType: mainModule, name: 'main' }
return new ExecutionContext(lsRpcConnection, {
methodPointer: { module: mainModule, definedOnType: mainModule, name: 'main' },
methodPointer: entryPoint,
positionalArgumentsExpressions: [],
})
}
const executionContext = createExecutionContextForMain()
const dataConnectionRef = computedAsync<DataServer | undefined>(() => dataConnection)
function useVisualizationData(
configuration: WatchSource<Opt<NodeVisualizationConfiguration>>,
): ShallowRef<{} | undefined> {
const id = random.uuidv4() as Uuid
const visualizationData = shallowRef<{}>()
watch(configuration, async (config, _, onCleanup) => {
executionContext.setVisualization(id, config)
onCleanup(() => {
executionContext.setVisualization(id, null)
})
})
watchEffect((onCleanup) => {
const connection = dataConnectionRef.value
const dataEvent = `${OutboundPayload.VISUALIZATION_UPDATE}:${id}`
if (connection == null) return
connection.on(dataEvent, onVisualizationUpdate)
onCleanup(() => {
connection.off(dataEvent, onVisualizationUpdate)
})
})
function onVisualizationUpdate(vizUpdate: VisualizationUpdate) {
const json = vizUpdate.dataString()
const newData = json != null ? JSON.parse(json) : undefined
visualizationData.value = newData
}
return visualizationData
}
function stopCapturingUndo() {
module.value?.undoManager.stopCapturing()
}
return {
setObservedFileName(name: string) {
observedFileName.value = name
},
name: projectName,
createExecutionContextForMain,
executionContext,
module,
contentRoots,
undoManager,
awareness,
lsRpcConnection: markRaw(lsRpcConnection),
dataConnection: markRaw(dataConnection),
useVisualizationData,
stopCapturingUndo,
}
})

View File

@ -1,3 +1,4 @@
import { AsyncQueue, rpcWithRetries } from '@/util/net'
import { type QualifiedName } from '@/util/qualifiedName'
import { defineStore } from 'pinia'
import { LanguageServer } from 'shared/languageServer'
@ -18,17 +19,22 @@ export interface Group {
class Synchronizer {
entries: SuggestionDb
groups: Ref<Group[]>
lastUpdate: Promise<{ currentVersion: number }>
queue: AsyncQueue<{ currentVersion: number }>
constructor(entries: SuggestionDb, groups: Ref<Group[]>) {
this.entries = entries
this.groups = groups
const projectStore = useProjectStore()
this.lastUpdate = projectStore.lsRpcConnection.then(async (lsRpc) => {
await lsRpc.acquireCapability('search/receivesSuggestionsDatabaseUpdates', {})
const initState = projectStore.lsRpcConnection.then(async (lsRpc) => {
await rpcWithRetries(() =>
lsRpc.acquireCapability('search/receivesSuggestionsDatabaseUpdates', {}),
)
this.setupUpdateHandler(lsRpc)
return Synchronizer.loadDatabase(entries, lsRpc, groups.value)
})
this.queue = new AsyncQueue(initState)
}
static async loadDatabase(
@ -51,7 +57,7 @@ class Synchronizer {
private setupUpdateHandler(lsRpc: LanguageServer) {
lsRpc.on('search/suggestionsDatabaseUpdates', (param) => {
this.lastUpdate = this.lastUpdate.then(async ({ currentVersion }) => {
this.queue.pushTask(async ({ currentVersion }) => {
if (param.currentVersion <= currentVersion) {
console.log(
`Skipping suggestion database update ${param.currentVersion}, because it's already applied`,

View File

@ -1,9 +1,3 @@
import * as vue from 'vue'
import { type DefineComponent } from 'vue'
import * as vueUseCore from '@vueuse/core'
import { defineStore } from 'pinia'
import VisualizationContainer from '@/components/VisualizationContainer.vue'
import { useVisualizationConfig } from '@/providers/visualizationConfig'
import { defineKeybinds } from '@/util/shortcuts'
@ -21,33 +15,60 @@ import type {
RegisterBuiltinModulesRequest,
} from '@/workers/visualizationCompiler'
import Compiler from '@/workers/visualizationCompiler?worker'
import * as d3 from 'd3'
import { defineStore } from 'pinia'
import type { VisualizationConfiguration } from 'shared/languageServerTypes'
import type { VisualizationIdentifier } from 'shared/yjsModel'
import * as vue from 'vue'
import { type DefineComponent } from 'vue'
/** A module containing the default visualization function. */
const DEFAULT_VISUALIZATION_MODULE = 'Standard.Visualization.Preprocessor'
/** A name of the default visualization function. */
const DEFAULT_VISUALIZATION_FUNCTION = 'default_preprocessor'
/** A list of arguments passed to the default visualization function. */
const DEFAULT_VISUALIZATION_ARGUMENTS: string[] = []
export const DEFAULT_VISUALIZATION_CONFIGURATION = {
visualizationModule: DEFAULT_VISUALIZATION_MODULE,
expression: DEFAULT_VISUALIZATION_FUNCTION,
positionalArgumentsExpressions: DEFAULT_VISUALIZATION_ARGUMENTS,
} satisfies Partial<VisualizationConfiguration>
export const DEFAULT_VISUALIZATION_IDENTIFIER: VisualizationIdentifier = {
module: { kind: 'Builtin' },
name: 'JSON',
}
const moduleCache: Record<string, any> = {
vue,
'@vueuse/core': vueUseCore,
builtins: { VisualizationContainer, useVisualizationConfig, defineKeybinds, d3 },
get d3() {
return import('d3')
},
builtins: { VisualizationContainer, useVisualizationConfig, defineKeybinds },
}
// @ts-expect-error Intentionally not defined in `env.d.ts` as it is a mistake to access anywhere
// else.
window.__visualizationModules = moduleCache
export type Visualization = DefineComponent<
{ data: {} },
{},
{},
// Props
{ data: { type: vue.PropType<unknown>; required: true } },
{},
unknown,
{},
{},
{},
{},
// Emits
{
'update:preprocessor': (module: string, method: string, ...args: string[]) => void
'update:preprocessor'?: (module: string, method: string, ...args: string[]) => void
}
>
type VisualizationModule = {
default: Visualization
name: string
inputType: string
inputType?: string
defaultPreprocessor?: readonly [module: string, method: string, ...args: string[]]
scripts?: string[]
styles?: string[]
}
@ -63,7 +84,6 @@ const builtinVisualizationImports: Record<string, () => Promise<VisualizationMod
}
const dynamicVisualizationPaths: Record<string, string> = {
Test: '/visualizations/TestVisualization.vue',
Scatterplot: '/visualizations/ScatterplotVisualization.vue',
'Geo Map': '/visualizations/GeoMapVisualization.vue',
}
@ -73,7 +93,13 @@ export const useVisualizationStore = defineStore('visualization', () => {
const imports = { ...builtinVisualizationImports }
const paths = { ...dynamicVisualizationPaths }
let cache: Record<string, VisualizationModule> = {}
const types = [...Object.keys(imports), ...Object.keys(paths)]
const builtinTypes = [...Object.keys(imports), ...Object.keys(paths)]
const types = builtinTypes.map(
(name): VisualizationIdentifier => ({
module: { kind: 'Builtin' },
name,
}),
)
let worker: Worker | undefined
let workerMessageId = 0
const workerCallbacks: Record<
@ -220,7 +246,12 @@ export const useVisualizationStore = defineStore('visualization', () => {
}
// NOTE: Because visualization scripts are cached, they are not guaranteed to be up to date.
async function get(type: string) {
async function get(meta: VisualizationIdentifier) {
if (meta.module.kind !== 'Builtin') {
console.warn('Custom visualization module support is not yet implemented:', meta.module)
return
}
const type = meta.name
let module = cache[type]
if (module == null) {
module = await imports[type]?.()
@ -237,121 +268,12 @@ export const useVisualizationStore = defineStore('visualization', () => {
register(module)
await loadScripts(module)
cache[type] = module
return module.default
return module
}
function clear() {
cache = {}
}
function sampleData(type: string) {
switch (type) {
case 'Warnings': {
return ['warning 1', "warning 2!!&<>;'\x22"]
}
case 'Image': {
return {
mediaType: 'image/svg+xml',
base64: `PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0\
MCI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBkPSJNMjAuMDUgMEEyMCAyMCAwIDAgMCAwIDIwLjA1IDIwLjA2IDIwLjA\
2IDAgMSAwIDIwLjA1IDBabTAgMzYuMDVjLTguOTMgMC0xNi4xLTcuMTctMTYuMS0xNi4xIDAtOC45NCA3LjE3LTE2LjEgMTYuMS\
0xNi4xIDguOTQgMCAxNi4xIDcuMTYgMTYuMSAxNi4xYTE2LjE4IDE2LjE4IDAgMCAxLTE2LjEgMTYuMVoiLz48cGF0aCBkPSJNM\
jcuMTIgMTcuNzdhNC42OCA0LjY4IDAgMCAxIDIuMzkgNS45MiAxMC4yMiAxMC4yMiAwIDAgMS05LjU2IDYuODZBMTAuMiAxMC4y\
IDAgMCAxIDkuNzcgMjAuMzZzMS41NSAyLjA4IDQuNTcgMi4wOGMzLjAxIDAgNC4zNi0xLjE0IDUuNi0yLjA4IDEuMjUtLjkzIDI\
uMDktMyA1LjItMyAuNzMgMCAxLjQ2LjIgMS45OC40WiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiPjxwYXRoIGZpbGw9Ii\
NmZmYiIGQ9Ik0wIDBoNDB2NDBIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=`,
}
}
case 'JSON':
case 'Scatterplot':
case 'Scatterplot 2': {
return {
axis: {
x: { label: 'x-axis label', scale: 'linear' },
y: { label: 'y-axis label', scale: 'logarithmic' },
},
focus: { x: 1.7, y: 2.1, zoom: 3.0 },
points: { labels: 'visible' },
data: [
{ x: 0.1, y: 0.7, label: 'foo', color: 'FF0000', shape: 'circle', size: 0.2 },
{ x: 0.4, y: 0.2, label: 'baz', color: '0000FF', shape: 'square', size: 0.3 },
],
}
}
case 'Geo Map':
case 'Geo Map 2': {
return {
latitude: 37.8,
longitude: -122.45,
zoom: 15,
controller: true,
showingLabels: true, // Enables presenting labels when hovering over a point.
layers: [
{
type: 'Scatterplot_Layer',
data: [
{
latitude: 37.8,
longitude: -122.45,
color: [255, 0, 0],
radius: 100,
label: 'an example label',
},
],
},
],
}
}
case 'Heatmap': {
return [
['a', 'thing', 'c', 'd', 'a'],
[1, 2, 3, 2, 3],
[50, 25, 40, 20, 10],
]
}
case 'Histogram': {
return {
axis: {
x: { label: 'x-axis label', scale: 'linear' },
y: { label: 'y-axis label', scale: 'logarithmic' },
},
focus: { x: 1.7, y: 2.1, zoom: 3.0 },
color: 'rgb(1.0,0.0,0.0)',
bins: 10,
data: {
values: [0.1, 0.2, 0.1, 0.15, 0.7],
},
}
}
case 'Table': {
return {
type: 'Matrix',
// eslint-disable-next-line camelcase
column_count: 5,
// eslint-disable-next-line camelcase
all_rows_count: 10,
json: Array.from({ length: 10 }, (_, i) =>
Array.from({ length: 5 }, (_, j) => `${i},${j}`),
),
}
}
case 'SQL Query': {
return {
dialect: 'sql',
code: `SELECT * FROM \`foo\` WHERE \`a\` = ? AND b LIKE ?;`,
interpolations: [
// eslint-disable-next-line camelcase
{ enso_type: 'Data.Numbers.Number', value: '123' },
// eslint-disable-next-line camelcase
{ enso_type: 'Builtins.Main.Text', value: "a'bcd" },
],
}
}
default: {
return {}
}
}
}
return { types, get, sampleData, clear }
return { types, get, clear }
})

View File

@ -0,0 +1,10 @@
/**
* @file This module is a collection of codemirror related imports that are intended to be loaded
* asynchronously using a single dynamic import, allowing for code splitting.
*/
export { defaultKeymap } from '@codemirror/commands'
export { EditorState } from '@codemirror/state'
export { EditorView } from '@codemirror/view'
export { minimalSetup } from 'codemirror'
export { yCollab } from 'y-codemirror.next'

View File

@ -40,9 +40,7 @@ interface SubdocsEvent {
removed: Set<Y.Doc>
}
/**
* URL query parameters used in gateway server websocket connection.
*/
/** URL query parameters used in gateway server websocket connection. */
export type ProviderParams = {
/** URL for the project's language server RPC connection. */
ls: string

View File

@ -11,12 +11,26 @@ import {
type Ref,
type WatchSource,
} from 'vue'
import type { Opt } from './opt'
/** Whether an element currently has keyboard focus. */
/** Whether any element currently has keyboard focus. */
export function keyboardBusy() {
return document.activeElement != document.body
}
/** Whether focused element is within given element's subtree. */
export function focusIsIn(el: Element) {
return el.contains(document.activeElement)
}
/**
* Whether any element currently has keyboard focus, except for elements within given subtree.
* When `el` is `null` or `undefined`, the function behaves as `keyboardBusy()`.
*/
export function keyboardBusyExceptIn(el: Opt<Element>) {
return keyboardBusy() && (el == null || !focusIsIn(el))
}
/**
* Add an event listener on an {@link HTMLElement} for the duration of the component's lifetime.
* @param target element on which to register the event

View File

@ -19,9 +19,12 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
center.value = center.value.addScaled(pos.delta, -1 / scale.value)
}, PointerButtonMask.Auxiliary)
function eventToScenePos(event: PointerEvent, client?: Vec2): Vec2 {
function eventScreenPos(e: PointerEvent): Vec2 {
return new Vec2(e.clientX, e.clientY)
}
function clientToScenePos(clientPos: Vec2): Vec2 {
const rect = elemRect(viewportNode.value)
const clientPos = client ?? new Vec2(event.clientX, event.clientY)
const canvasPos = clientPos.sub(rect.pos)
const v = viewport.value
return new Vec2(
@ -31,9 +34,9 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
}
let zoomPivot = Vec2.Zero()
const zoomPointer = usePointer((pos, event, ty) => {
const zoomPointer = usePointer((pos, _event, ty) => {
if (ty === 'start') {
zoomPivot = eventToScenePos(event, pos.initial)
zoomPivot = clientToScenePos(pos.initial)
}
const prevScale = scale.value
@ -87,17 +90,20 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
{ capture: true },
)
const sceneMousePos = ref<Vec2 | null>(null)
const eventMousePos = ref<Vec2 | null>(null)
const sceneMousePos = computed(() =>
eventMousePos.value ? clientToScenePos(eventMousePos.value) : null,
)
return proxyRefs({
events: {
pointermove(e: PointerEvent) {
sceneMousePos.value = eventToScenePos(e)
eventMousePos.value = eventScreenPos(e)
panPointer.events.pointermove(e)
zoomPointer.events.pointermove(e)
},
pointerleave() {
sceneMousePos.value = null
eventMousePos.value = null
},
pointerup(e: PointerEvent) {
panPointer.events.pointerup(e)

233
app/gui2/src/util/net.ts Normal file
View File

@ -0,0 +1,233 @@
import { wait } from 'lib0/promise'
import { LsRpcError } from 'shared/languageServer'
import { Err, Error, Ok, rejectionToResult, type Result } from './result'
export interface BackoffOptions<E> {
maxRetries?: number
retryDelay?: number
retryDelayMultiplier?: number
retryDelayMax?: number
/**
* Called when the promise return an error result, and the next retry is about to be attempted.
* When this function returns `false`, the backoff is immediately aborted. When this function is
* not provided, the backoff will always continue until the maximum number of retries is reached.
*/
onBeforeRetry?: (error: Error<E>, retryCount: number, delay: number) => boolean | void
}
const defaultBackoffOptions: Required<BackoffOptions<any>> = {
maxRetries: 3,
retryDelay: 1000,
retryDelayMultiplier: 2,
retryDelayMax: 10000,
onBeforeRetry: () => {},
}
/**
* Retry a failing promise function with exponential backoff.
*/
export async function exponentialBackoff<T, E>(
f: () => Promise<Result<T, E>>,
backoffOptions?: BackoffOptions<E>,
): Promise<Result<T, E>> {
const options = { ...defaultBackoffOptions, ...backoffOptions }
for (let retries = 0; ; retries += 1) {
const result = await f()
const delay = Math.min(
options.retryDelayMax,
options.retryDelay * options.retryDelayMultiplier ** retries,
)
if (
result.ok ||
retries >= options.maxRetries ||
options.onBeforeRetry(result.error, retries, delay) === false
) {
return result
}
await wait(delay)
}
}
export const lsRequestResult = rejectionToResult(LsRpcError)
/**
* Retry a failing Language Server RPC call with exponential backoff. The provided async function is
* called on each retry.
*/
export async function rpcWithRetries<T>(
f: () => Promise<T>,
backoffOptions?: BackoffOptions<LsRpcError>,
): Promise<T> {
const result = await exponentialBackoff(() => lsRequestResult(f()), backoffOptions)
if (result.ok) return result.value
else {
console.error('Too many failed retries.')
throw result.error
}
}
type QueueTask<State> = (state: State) => Promise<State>
/**
* A serializing queue of asynchronous tasks transforming a state. Each task is a function that
* takes the current state and produces a promise to the transformed state. Each task waits for the
* previous task to finish before starting.
*/
export class AsyncQueue<State> {
lastTask: Promise<State>
taskRunning = false
queuedTasks: QueueTask<State>[] = []
constructor(initTask: Promise<State>) {
this.lastTask = initTask
}
private run() {
if (this.taskRunning) return
const task = this.queuedTasks.shift()
if (task == null) return
this.taskRunning = true
this.lastTask = this.lastTask
.then((state) => task(state))
.finally(() => {
this.taskRunning = false
this.run()
})
}
pushTask(f: QueueTask<State>) {
this.queuedTasks.push(f)
this.run()
}
clear() {
this.queuedTasks.length = 0
}
async waitForCompletion(): Promise<State> {
let lastState: State
do {
console.log('this.lastTask', this.lastTask)
lastState = await this.lastTask
console.log('lastState', lastState)
} while (this.taskRunning)
return lastState
}
}
if (import.meta.vitest) {
const { describe, test, expect, beforeEach, afterEach, vi } = import.meta.vitest
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('AsyncQueue', () => {
test('sets initial state', async () => {
const queue = new AsyncQueue(Promise.resolve(1))
expect(await queue.waitForCompletion()).toBe(1)
})
test('runs tasks in sequence', async () => {
const queue = new AsyncQueue(Promise.resolve(1))
queue.pushTask(async (state) => {
expect(state).toBe(1)
await wait(100)
return 2
})
queue.pushTask(async (state) => {
expect(state).toBe(2)
return 3
})
vi.runAllTimersAsync()
expect(await queue.waitForCompletion()).toBe(3)
})
test('clear removes all not yet started tasks', async () => {
const queue = new AsyncQueue(Promise.resolve(1))
queue.pushTask(async (state) => {
expect(state).toBe(1)
await wait(100)
return 2
})
queue.pushTask(async (state) => {
expect(state).toBe(2)
return 3
})
queue.clear()
queue.pushTask(async (state) => {
expect(state).toBe(2)
return 5
})
vi.runAllTimersAsync()
expect(await queue.waitForCompletion()).toBe(5)
})
})
describe('exponentialBackoff', () => {
test('runs successful task once', async () => {
const task = vi.fn(async () => Ok(1))
const result = await exponentialBackoff(task)
expect(result).toEqual({ ok: true, value: 1 })
expect(task).toHaveBeenCalledTimes(1)
})
test('retry failing task up to a limit', async () => {
const task = vi.fn(async () => Err(1))
const promise = exponentialBackoff(task, { maxRetries: 4 })
vi.runAllTimersAsync()
const result = await promise
expect(result).toEqual({ ok: false, error: new Error(1) })
expect(task).toHaveBeenCalledTimes(5)
})
test('wait before retrying', async () => {
const task = vi.fn(async () => Err(null))
exponentialBackoff(task, {
maxRetries: 10,
retryDelay: 100,
retryDelayMultiplier: 3,
retryDelayMax: 1000,
})
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(100)
expect(task).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(300)
expect(task).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(900)
expect(task).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(5000)
expect(task).toHaveBeenCalledTimes(9)
})
test('retry task until success', async () => {
const task = vi.fn()
task.mockReturnValueOnce(Promise.resolve(Err(3)))
task.mockReturnValueOnce(Promise.resolve(Err(2)))
task.mockReturnValueOnce(Promise.resolve(Ok(1)))
const promise = exponentialBackoff(task)
vi.runAllTimersAsync()
const result = await promise
expect(result).toEqual({ ok: true, value: 1 })
expect(task).toHaveBeenCalledTimes(3)
})
test('call retry callback', async () => {
const task = vi.fn()
task.mockReturnValueOnce(Promise.resolve(Err(3)))
task.mockReturnValueOnce(Promise.resolve(Err(2)))
task.mockReturnValueOnce(Promise.resolve(Ok(1)))
const onBeforeRetry = vi.fn()
const promise = exponentialBackoff(task, { onBeforeRetry })
vi.runAllTimersAsync()
await promise
expect(onBeforeRetry).toHaveBeenCalledTimes(2)
expect(onBeforeRetry).toHaveBeenNthCalledWith(1, new Error(3), 0, 1000)
expect(onBeforeRetry).toHaveBeenNthCalledWith(2, new Error(2), 1, 2000)
})
})
}

View File

@ -48,3 +48,22 @@ export function withContext<T, E>(context: () => string, f: () => Result<T, E>):
if (!result.ok) result.error.context.splice(0, 0, context)
return result
}
/**
* Catch promise rejection of provided types and convert them to a Result type.
*/
export function rejectionToResult<ErrorKind extends new (...args: any[]) => any>(
errorKinds: ErrorKind | ErrorKind[],
): <T>(promise: Promise<T>) => Promise<Result<T, InstanceType<ErrorKind>>> {
const errorKindArray = Array.isArray(errorKinds) ? errorKinds : [errorKinds]
return async (promise) => {
try {
return Ok(await promise)
} catch (error) {
for (const errorKind of errorKindArray) {
if (error instanceof errorKind) return Err(error)
}
throw error
}
}
}

View File

@ -92,7 +92,7 @@ const allKeys = [
'PageUp',
'PageDown',
'Insert',
' ',
'Space',
'A',
'B',
'C',
@ -177,10 +177,11 @@ const allKeys = [
type Key = (typeof allKeys)[number]
type LowercaseKey = Lowercase<Key>
type KeybindSegment = Modifier | Pointer | Key
const normalizedKeyboardSegmentLookup = Object.fromEntries<KeybindSegment | undefined>(
const normalizedKeyboardSegmentLookup = Object.fromEntries<string>(
[...allModifiers, ...allPointers, ...allKeys].map((entry) => [entry.toLowerCase(), entry]),
)
normalizedKeyboardSegmentLookup[''] = '+'
normalizedKeyboardSegmentLookup['space'] = ' '
normalizedKeyboardSegmentLookup['osdelete'] = isMacLike ? 'Delete' : 'Backspace'
type NormalizeKeybindSegment = {
[K in KeybindSegment as Lowercase<K>]: K
@ -234,7 +235,7 @@ export function defineKeybinds<
BindingName extends keyof T = keyof T,
>(namespace: string, bindings: Keybinds<T>) {
if (definedNamespaces.has(namespace)) {
console.error(`The keybind namespace '${namespace}' has already been defined.`)
console.warn(`The keybind namespace '${namespace}' has already been defined.`)
} else {
definedNamespaces.add(namespace)
}
@ -263,7 +264,9 @@ export function defineKeybinds<
}
function handler<Event_ extends KeyboardEvent | MouseEvent | PointerEvent>(
handlers: Partial<Record<BindingName | typeof DefaultHandler, (event: Event_) => void>>,
handlers: Partial<
Record<BindingName | typeof DefaultHandler, (event: Event_) => boolean | void>
>,
): (event: Event_) => boolean {
return (event) => {
const eventModifierFlags = modifierFlagsForEvent(event)
@ -283,7 +286,9 @@ export function defineKeybinds<
if (handle == null) {
return false
}
handle(event)
if (handle(event) === false) {
return false
}
event.stopImmediatePropagation()
event.preventDefault()
return true
@ -317,13 +322,6 @@ function decomposeKeybindString(string: string): ModifierStringDecomposition {
function parseKeybindString(string: string): Keybind | Mousebind {
const decomposed = decomposeKeybindString(string)
const normalized =
decomposed.modifiers.length === 0
? decomposed.key
: `${decomposed.modifiers.join('+')}+${decomposed.key}`
if (normalized !== string) {
console.warn(`Modifier string '${string}' should be '${normalized}'`)
}
if (isPointer(decomposed.key)) {
return {
type: 'mousebind',
@ -359,6 +357,10 @@ interface Mousebind {
if (import.meta.vitest) {
const { test, expect } = import.meta.vitest
test.each([
{ keybind: 'A', expected: { modifiers: [], key: 'A' } },
{ keybind: 'b', expected: { modifiers: [], key: 'B' } },
{ keybind: 'Space', expected: { modifiers: [], key: ' ' } },
{ keybind: 'Mod+Space', expected: { modifiers: ['Mod'], key: ' ' } },
// `+`
{ keybind: 'Mod++', expected: { modifiers: ['Mod'], key: '+' } },
// `+` and capitalization

View File

@ -12,6 +12,9 @@
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"outDir": "../../node_modules/.cache/tsc",
"types": ["node", "vitest/importMeta"]
}

View File

@ -3,11 +3,10 @@ import { fileURLToPath } from 'node:url'
import postcssNesting from 'postcss-nesting'
import tailwindcss from 'tailwindcss'
import tailwindcssNesting from 'tailwindcss/nesting'
import { defineConfig, Plugin } from 'vite'
import { defineConfig, type Plugin } from 'vite'
import topLevelAwait from 'vite-plugin-top-level-await'
import * as tailwindConfig from '../ide-desktop/lib/dashboard/tailwind.config'
import { createGatewayServer } from './ydoc-server'
const projectManagerUrl = 'ws://127.0.0.1:30535'
// https://vitejs.dev/config/
@ -21,16 +20,19 @@ export default defineConfig({
alias: {
shared: fileURLToPath(new URL('./shared', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
events$: fileURLToPath(new URL('./shared/events.ts', import.meta.url)),
},
},
define: {
REDIRECT_OVERRIDE: JSON.stringify('http://localhost:8080'),
PROJECT_MANAGER_URL: JSON.stringify(projectManagerUrl),
global: 'globalThis',
IS_DEV_MODE: JSON.stringify(process.env.NODE_ENV !== 'production'),
CLOUD_ENV:
process.env.ENSO_CLOUD_ENV != null ? JSON.stringify(process.env.ENSO_CLOUD_ENV) : 'undefined',
RUNNING_VTEST: false,
'import.meta.vitest': false,
// Single hardcoded usage of `global` in by aws-amplify.
'global.TYPED_ARRAY_SUPPORT': true,
},
assetsInclude: ['**/*.yaml', '**/*.svg'],
css: {
@ -41,6 +43,14 @@ export default defineConfig({
build: {
// dashboard chunk size is larger than the default warning limit
chunkSizeWarningLimit: 700,
rollupOptions: {
output: {
manualChunks: {
fontawesome: ['@fortawesome/react-fontawesome', '@fortawesome/free-brands-svg-icons'],
'aws-amplify': ['@aws-amplify/core', '@aws-amplify/auth'],
},
},
},
},
})

View File

@ -1,6 +1,5 @@
import { fc, test } from '@fast-check/vitest'
import { Position, TextEdit } from 'shared/languageServerTypes'
import { inspect } from 'util'
import { describe, expect } from 'vitest'
import * as Y from 'yjs'
import { applyDiffAsTextEdits, convertDeltaToTextEdits } from '../edits'
@ -21,7 +20,7 @@ export function applyTextEdits(content: string, edits: TextEdit[]): string {
return c.slice(0, startOffset) + edit.text + c.slice(endOffset)
} catch (e) {
throw new Error(
`Failed to apply edit ${inspect(edit)} to content:\n${inspect(c)}\n${String(e)}`,
`Failed to apply edit ${JSON.stringify(edit)} to content:\n${JSON.stringify(c)}\n${e}`,
)
}
}, content)

View File

@ -7,7 +7,12 @@ import diff from 'fast-diff'
import * as json from 'lib0/json'
import * as Y from 'yjs'
import { TextEdit } from '../shared/languageServerTypes'
import { ModuleDoc, NodeMetadata, decodeRange } from '../shared/yjsModel'
import {
ModuleDoc,
decodeRange,
type NodeMetadata,
type VisualizationMetadata,
} from '../shared/yjsModel'
import * as fileFormat from './fileFormat'
interface AppliedUpdates {
@ -16,7 +21,7 @@ interface AppliedUpdates {
newMetadata: fileFormat.Metadata
}
const META_TAG = '#### METADATA ####'
const META_TAG = '\n\n\n#### METADATA ####'
export function applyDocumentUpdates(
doc: ModuleDoc,
@ -66,6 +71,9 @@ export function applyDocumentUpdates(
position: {
vector: [updatedMeta.x, updatedMeta.y],
},
visualization: updatedMeta.vis
? translateVisualizationToFile(updatedMeta.vis)
: undefined,
}
break
}
@ -92,6 +100,54 @@ export function applyDocumentUpdates(
}
}
function translateVisualizationToFile(
vis: VisualizationMetadata,
): fileFormat.VisualizationMetadata | undefined {
let project = undefined
switch (vis.module.kind) {
case 'Builtin':
project = { project: 'Builtin' } as const
break
case 'CurrentProject':
project = { project: 'CurrentProject' } as const
break
case 'Library':
project = { project: 'Library', contents: vis.module.name } as const
break
default:
return
}
return {
name: vis.name,
show: vis.visible,
project,
}
}
export function translateVisualizationFromFile(
vis: fileFormat.VisualizationMetadata,
): VisualizationMetadata | undefined {
let module
switch (vis.project.project) {
case 'Builtin':
module = { kind: 'Builtin' } as const
break
case 'CurrentProject':
module = { kind: 'CurrentProject' } as const
break
case 'Library':
module = { kind: 'Library', name: vis.project.contents } as const
break
default:
return
}
return {
name: vis.name,
visible: vis.show,
module,
}
}
export function convertDeltaToTextEdits(
prevText: string,
contentDelta: Y.YTextEvent['delta'],

View File

@ -4,8 +4,20 @@ import z from 'zod'
export type Vector = z.infer<typeof vector>
export const vector = z.tuple([z.number(), z.number()])
const visualizationProject = z.discriminatedUnion('project', [
z.object({ project: z.literal('Builtin') }),
z.object({ project: z.literal('CurrentProject') }),
z.object({ project: z.literal('Library'), contents: z.string() }),
])
export type VisualizationMetadata = z.infer<typeof visualizationMetadata>
const visualizationMetadata = z.object({}).passthrough()
const visualizationMetadata = z
.object({
show: z.boolean().default(true),
project: visualizationProject,
name: z.string(),
})
.passthrough()
export type NodeMetadata = z.infer<typeof nodeMetadata>
export const nodeMetadata = z
@ -14,7 +26,7 @@ export const nodeMetadata = z
printError(ctx)
return { vector: [0, 0] satisfies Vector }
}),
visualization: visualizationMetadata.catch(() => ({})),
visualization: visualizationMetadata.optional().catch(() => undefined),
})
.passthrough()

View File

@ -11,10 +11,15 @@ import {
ExprId,
IdMap,
ModuleDoc,
NodeMetadata,
Uuid,
type NodeMetadata,
type Uuid,
} from '../shared/yjsModel'
import { applyDocumentUpdates, preParseContent, prettyPrintDiff } from './edits'
import {
applyDocumentUpdates,
preParseContent,
prettyPrintDiff,
translateVisualizationFromFile,
} from './edits'
import * as fileFormat from './fileFormat'
import { WSSharedDoc } from './ydoc'
@ -362,6 +367,8 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
}
this.changeState(LsSyncState.WritingFile)
const execute = contentDelta != null || idMapKeys != null
const apply = this.ls.applyEdit(
{
path: this.path,
@ -369,7 +376,7 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
oldVersion: this.syncedVersion,
newVersion,
},
true,
execute,
)
return (this.lastAction = apply.then(
() => {
@ -411,9 +418,9 @@ class ModulePersistence extends ObservableV2<{ removed: () => void }> {
for (const [id, meta] of Object.entries(nodeMeta)) {
if (typeof id !== 'string') continue
const formattedMeta: NodeMetadata = {
x: meta?.position?.vector?.[0] ?? 0,
y: meta?.position?.vector?.[1] ?? 0,
vis: meta?.visualization ?? undefined,
x: meta.position.vector[0],
y: meta.position.vector[1],
vis: (meta.visualization && translateVisualizationFromFile(meta.visualization)) ?? null,
}
keysToDelete.delete(id)
this.doc.metadata.set(id, formattedMeta)

View File

@ -9,7 +9,7 @@ import * as Y from 'yjs'
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import { ObservableV2 } from 'lib0/observable.js'
import { ObservableV2 } from 'lib0/observable'
import { WebSocket } from 'ws'
import { LanguageServerSession } from './languageServerSession'
@ -117,7 +117,7 @@ export function setupGatewayClient(ws: WebSocket, lsUrl: string, docName: string
})
}
class YjsConnection extends ObservableV2<{ close: () => void }> {
class YjsConnection extends ObservableV2<{ close(): void }> {
ws: WebSocket
wsDoc: WSSharedDoc
constructor(ws: WebSocket, wsDoc: WSSharedDoc) {

92
package-lock.json generated
View File

@ -39,16 +39,16 @@
"fast-diff": "^1.3.0",
"hash-sum": "^2.0.0",
"isomorphic-ws": "^5.0.0",
"lib0": "^0.2.83",
"lib0": "^0.2.85",
"magic-string": "^0.30.3",
"murmurhash": "^2.0.1",
"pinia": "^2.1.6",
"postcss-inline-svg": "^6.0.0",
"postcss-nesting": "^12.0.1",
"rollup-plugin-visualizer": "^5.9.2",
"sha3": "^2.1.4",
"sucrase": "^3.34.0",
"vue": "^3.3.4",
"vue-codemirror": "^6.1.1",
"ws": "^8.13.0",
"y-codemirror.next": "^0.3.2",
"y-protocols": "^1.0.5",
@ -9906,7 +9906,6 @@
},
"node_modules/is-wsl": {
"version": "2.2.0",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
@ -9917,7 +9916,6 @@
},
"node_modules/is-wsl/node_modules/is-docker": {
"version": "2.2.1",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
@ -13518,6 +13516,77 @@
"rollup-plugin-inject": "^3.0.0"
}
},
"node_modules/rollup-plugin-visualizer": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.9.2.tgz",
"integrity": "sha512-waHktD5mlWrYFrhOLbti4YgQCn1uR24nYsNuXxg7LkPH8KdTXVWR9DNY1WU0QqokyMixVXJS4J04HNrVTMP01A==",
"dependencies": {
"open": "^8.4.0",
"picomatch": "^2.3.1",
"source-map": "^0.7.4",
"yargs": "^17.5.1"
},
"bin": {
"rollup-plugin-visualizer": "dist/bin/cli.js"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"rollup": "2.x || 3.x"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/rollup-plugin-visualizer/node_modules/define-lazy-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
"integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
"engines": {
"node": ">=8"
}
},
"node_modules/rollup-plugin-visualizer/node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/rollup-plugin-visualizer/node_modules/open": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
"integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
"dependencies": {
"define-lazy-prop": "^2.0.0",
"is-docker": "^2.1.1",
"is-wsl": "^2.2.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/rollup-plugin-visualizer/node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
"integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
"engines": {
"node": ">= 8"
}
},
"node_modules/rollup-pluginutils": {
"version": "2.8.2",
"dev": true,
@ -16572,21 +16641,6 @@
"@vue/shared": "3.3.4"
}
},
"node_modules/vue-codemirror": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/vue-codemirror/-/vue-codemirror-6.1.1.tgz",
"integrity": "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==",
"dependencies": {
"@codemirror/commands": "6.x",
"@codemirror/language": "6.x",
"@codemirror/state": "6.x",
"@codemirror/view": "6.x"
},
"peerDependencies": {
"codemirror": "6.x",
"vue": "3.x"
}
},
"node_modules/vue-component-type-helpers": {
"version": "1.8.4",
"dev": true,