From 10a95e43d60244100ae644b54724fdd39eb10acb Mon Sep 17 00:00:00 2001 From: Michael Mauderer Date: Mon, 9 Oct 2023 07:55:12 +0200 Subject: [PATCH] Add functionality to handle Expression Updates to GUI2 (#7982) Implements #7783. Adds functionality to handle and store expression updates, as well as show the output type of node. https://github.com/enso-org/enso/assets/1428930/31ffff78-ff2c-4e0b-bcde-ddc507cc0226 --- app/gui2/shared/dataServer.ts | 15 +- app/gui2/shared/languageServer.ts | 2 +- app/gui2/shared/languageServerTypes.ts | 4 + app/gui2/shared/yjsModel.ts | 2 +- app/gui2/src/bindings/index.ts | 6 +- app/gui2/src/components/GraphEditor.vue | 15 +- app/gui2/src/components/GraphNode.vue | 74 +++++++++- app/gui2/src/components/NodeSpan.vue | 14 +- app/gui2/src/stores/graph.ts | 9 +- app/gui2/src/stores/project.ts | 139 +++++++++++++----- app/gui2/src/util/computedValueRegistry.ts | 49 ++++++ .../src/util/visualizationDataRegistry.ts | 66 +++++++++ 12 files changed, 327 insertions(+), 68 deletions(-) create mode 100644 app/gui2/src/util/computedValueRegistry.ts create mode 100644 app/gui2/src/util/visualizationDataRegistry.ts diff --git a/app/gui2/shared/dataServer.ts b/app/gui2/shared/dataServer.ts index ecb0c22a3a0..839322edc99 100644 --- a/app/gui2/shared/dataServer.ts +++ b/app/gui2/shared/dataServer.ts @@ -28,10 +28,10 @@ import { import type { WebsocketClient } from './websocket' import type { Uuid } from './yjsModel' -export function uuidFromBits(leastSigBits: bigint, mostSigBits: bigint): string { +export function uuidFromBits(leastSigBits: bigint, mostSigBits: bigint): Uuid { const bits = (mostSigBits << 64n) | leastSigBits const string = bits.toString(16).padStart(32, '0') - return string.replace(/(........)(....)(....)(....)(............)/, '$1-$2-$3-$4-$5') + return string.replace(/(........)(....)(....)(....)(............)/, '$1-$2-$3-$4-$5') as Uuid } export function uuidToBits(uuid: string): [leastSigBits: bigint, mostSigBits: bigint] { @@ -51,8 +51,9 @@ const PAYLOAD_CONSTRUCTOR = { } satisfies Record Table> export type DataServerEvents = { - [K in keyof typeof PAYLOAD_CONSTRUCTOR as K | `${K}:${string}`]: ( - arg: InstanceType<(typeof PAYLOAD_CONSTRUCTOR)[K]>, + [K in keyof typeof PAYLOAD_CONSTRUCTOR as `${K}`]: ( + payload: InstanceType<(typeof PAYLOAD_CONSTRUCTOR)[K]>, + uuid: Uuid | null, ) => void } @@ -84,18 +85,18 @@ export class DataServer extends ObservableV2 { const payloadType = binaryMessage.payloadType() const payload = binaryMessage.payload(new PAYLOAD_CONSTRUCTOR[payloadType]()) if (payload != null) { - this.emit(`${payloadType}`, [payload]) + this.emit(`${payloadType}`, [payload, null]) const id = binaryMessage.correlationId() if (id != null) { const uuid = uuidFromBits(id.leastSigBits(), id.mostSigBits()) - this.emit(`${payloadType}:${uuid}`, [payload]) + this.emit(`${payloadType}`, [payload, uuid]) 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]) + this.emit(`${payloadType}`, [payload, uuid]) } } } diff --git a/app/gui2/shared/languageServer.ts b/app/gui2/shared/languageServer.ts index ede73271aa8..758a3a3ca2c 100644 --- a/app/gui2/shared/languageServer.ts +++ b/app/gui2/shared/languageServer.ts @@ -250,7 +250,7 @@ export class LanguageServer extends ObservableV2 { expressionId: ExpressionId, visualizationConfig: VisualizationConfiguration, ): Promise { - return this.request('executionContext/interrupt', { + return this.request('executionContext/executeExpression', { visualizationId, expressionId, visualizationConfig, diff --git a/app/gui2/shared/languageServerTypes.ts b/app/gui2/shared/languageServerTypes.ts index 50e0a4188df..ecd4bfbfc48 100644 --- a/app/gui2/shared/languageServerTypes.ts +++ b/app/gui2/shared/languageServerTypes.ts @@ -96,6 +96,7 @@ export type ExpressionUpdatePayload = Value | DataflowError | Panic | Pending * Indicates that the expression was computed to a value. */ export interface Value { + type: 'Value' /** * Information about attached warnings. */ @@ -111,6 +112,7 @@ export interface Value { * Indicates that the expression was computed to an error. */ export interface DataflowError { + type: 'DataflowError' /** * The list of expressions leading to the root error. */ @@ -121,6 +123,7 @@ export interface DataflowError { * Indicates that the expression failed with the runtime exception. */ export interface Panic { + type: 'Panic' /** * The error message. */ @@ -137,6 +140,7 @@ export interface Panic { * provides description and percentage (`0.0-1.0`) of completeness. */ export interface Pending { + type: 'Pending' /** Optional message describing current operation. */ message?: string /** Optional amount of already done work as a number between `0.0` to `1.0`. */ diff --git a/app/gui2/shared/yjsModel.ts b/app/gui2/shared/yjsModel.ts index 99c8ba2ac84..d97134ebc3b 100644 --- a/app/gui2/shared/yjsModel.ts +++ b/app/gui2/shared/yjsModel.ts @@ -140,7 +140,7 @@ export class DistributedModule { return newId } - deleteNode(id: ExprId): void { + deleteExpression(id: ExprId): void { const rangeBuffer = this.doc.idMap.get(id) if (rangeBuffer == null) return const [relStart, relEnd] = decodeRange(rangeBuffer) diff --git a/app/gui2/src/bindings/index.ts b/app/gui2/src/bindings/index.ts index 31efecf1e77..96d418e02e2 100644 --- a/app/gui2/src/bindings/index.ts +++ b/app/gui2/src/bindings/index.ts @@ -12,7 +12,7 @@ export const graphBindings = defineKeybinds('graph-editor', { newNode: ['N'], }) -export const nodeBindings = defineKeybinds('node-selection', { +export const nodeSelectionBindings = defineKeybinds('node-selection', { deleteSelected: ['Delete'], selectAll: ['Mod+A'], deselectAll: ['Escape', 'PointerMain'], @@ -23,3 +23,7 @@ export const nodeBindings = defineKeybinds('node-selection', { invert: ['Mod+Shift+Alt+PointerMain'], toggleVisualization: ['Space'], }) + +export const nodeEditBindings = defineKeybinds('node-edit', { + selectAll: ['Mod+A'], +}) diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index 26b7adad452..054a3cf14a2 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -1,5 +1,5 @@ @@ -452,9 +480,19 @@ const dragPointer = usePointer((pos, event, type) => { --node-height: 32px; --node-border-radius: calc(var(--node-height) * 0.5); - --node-color-primary: #357ab9; + --node-group-color: #357ab9; + + --node-color-primary: color-mix(in oklab, var(--node-group-color) 100%, transparent 0%); + --node-color-port: color-mix(in oklab, var(--node-color-primary) 75%, white 15%); + --node-color-error: color-mix(in oklab, var(--node-group-color) 30%, rgba(255, 0, 0) 70%); + + &.executionState-Unknown, + &.executionState-Pending { + --node-color-primary: color-mix(in oklab, var(--node-group-color) 60%, #aaa 40%); + } + position: absolute; - border-radius: var(--radius-full); + border-radius: var(--node-border-radius); transition: box-shadow 0.2s ease-in-out; ::selection { background-color: rgba(255, 255, 255, 20%); @@ -476,10 +514,15 @@ const dragPointer = usePointer((pos, event, type) => { white-space: nowrap; padding: 4px 8px; z-index: 2; + transition: + background 0.2s ease, + outline 0.2s ease; + outline: 0px solid transparent; } .GraphNode .selection { position: absolute; inset: calc(0px - var(--selected-node-border-width)); + --node-current-selection-width: 0px; &:before { content: ''; @@ -488,7 +531,7 @@ const dragPointer = usePointer((pos, event, type) => { border-radius: var(--node-border-radius); display: block; inset: var(--selected-node-border-width); - box-shadow: 0 0 0 0 var(--node-color-primary); + box-shadow: 0 0 0 var(--node-current-selection-width) var(--node-color-primary); transition: box-shadow 0.2s ease-in-out, @@ -498,7 +541,7 @@ const dragPointer = usePointer((pos, event, type) => { .GraphNode:is(:hover, .selected) .selection:before, .GraphNode .selection:hover:before { - box-shadow: 0 0 0 var(--selected-node-border-width) var(--node-color-primary); + --node-current-selection-width: var(--selected-node-border-width); } .GraphNode .selection:hover:before { @@ -531,7 +574,8 @@ const dragPointer = usePointer((pos, event, type) => { .editable { outline: none; height: 24px; - padding: 1px 0; + display: inline-flex; + align-items: center; } .container { @@ -548,4 +592,20 @@ const dragPointer = usePointer((pos, event, type) => { .CircularMenu { z-index: 1; } + +.outputTypeName { + user-select: none; + position: absolute; + left: 50%; + top: 110%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.3s ease-in-out; + pointer-events: none; + color: var(--node-color-primary); +} + +.GraphNode:hover .outputTypeName { + opacity: 1; +} diff --git a/app/gui2/src/components/NodeSpan.vue b/app/gui2/src/components/NodeSpan.vue index cfaf3d893d0..86e78f1f20f 100644 --- a/app/gui2/src/components/NodeSpan.vue +++ b/app/gui2/src/components/NodeSpan.vue @@ -82,16 +82,13 @@ watch(exprRect, (rect) => { .Span { color: white; white-space: pre; + align-items: center; + transition: background 0.2s ease; &.Root { - display: inline-block; color: white; } - &.Ident { - /* color: #f97; */ - } - &.Token { color: rgb(255 255 255 / 0.33); } @@ -99,5 +96,12 @@ watch(exprRect, (rect) => { &.Literal { font-weight: bold; } + + &.Ident { + background-color: var(--node-color-port); + border-radius: var(--node-border-radius); + margin: -2px -4px; + padding: 2px 4px; + } } diff --git a/app/gui2/src/stores/graph.ts b/app/gui2/src/stores/graph.ts index c13897abfc9..edbd675c153 100644 --- a/app/gui2/src/stores/graph.ts +++ b/app/gui2/src/stores/graph.ts @@ -146,6 +146,7 @@ export const useGraphStore = defineStore('graph', () => { function nodeInserted(stmt: Statement, text: Y.Text, content: string, meta: Opt) { const nodeId = stmt.expression.id const node: Node = { + outerExprId: stmt.id, content, binding: stmt.binding ?? '', rootSpan: stmt.expression, @@ -173,6 +174,9 @@ export const useGraphStore = defineStore('graph', () => { node.binding = stmt.binding ?? '' identDefinitions.set(node.binding, nodeId) } + if (node.outerExprId !== stmt.id) { + node.outerExprId = stmt.id + } if (node.rootSpan.id === stmt.expression.id) { patchSpan(node.rootSpan, stmt.expression) } else { @@ -270,7 +274,9 @@ export const useGraphStore = defineStore('graph', () => { } function deleteNode(id: ExprId) { - proj.module?.deleteNode(id) + const node = nodes.get(id) + if (node == null) return + proj.module?.deleteExpression(node.outerExprId) } function setNodeContent(id: ExprId, content: string) { @@ -359,6 +365,7 @@ function randomString() { } export interface Node { + outerExprId: ExprId content: string binding: string rootSpan: Span diff --git a/app/gui2/src/stores/project.ts b/app/gui2/src/stores/project.ts index 0caf3537072..1f56d8ec3ec 100644 --- a/app/gui2/src/stores/project.ts +++ b/app/gui2/src/stores/project.ts @@ -1,21 +1,26 @@ import { useGuiConfig, type GuiConfig } from '@/providers/guiConfig' +import { ComputedValueRegistry } from '@/util/computedValueRegistry' import { attachProvider } from '@/util/crdt' import { AsyncQueue, rpcWithRetries as lsRpcWithRetries } from '@/util/net' import { isSome, type Opt } from '@/util/opt' +import { VisualizationDataRegistry } from '@/util/visualizationDataRegistry' 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 { ObservableV2 } from 'lib0/observable' 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, + Diagnostic, + ExecutionEnvironment, ExplicitCall, ExpressionId, + ExpressionUpdate, StackItem, VisualizationConfiguration, } from 'shared/languageServerTypes' @@ -118,6 +123,20 @@ function visualizationConfigEqual( type EntryPoint = Omit +type ExecutionContextNotification = { + 'expressionUpdates'(updates: ExpressionUpdate[]): void + 'visualizationEvaluationFailed'( + visualizationId: Uuid, + expressionId: ExpressionId, + message: string, + diagnostic: Diagnostic | undefined, + ): void + 'executionFailed'(message: string): void + 'executionComplete'(): void + 'executionStatus'(diagnostics: Diagnostic[]): void + 'visualizationsConfigured'(configs: Set): void +} + /** * Execution Context * @@ -127,7 +146,7 @@ type EntryPoint = Omit * It hides the asynchronous nature of the language server. Each call is scheduled and * run only when the previous call is done. */ -export class ExecutionContext { +export class ExecutionContext extends ObservableV2 { id: ContextId = random.uuidv4() as ContextId queue: AsyncQueue taskRunning = false @@ -136,6 +155,12 @@ export class ExecutionContext { abortCtl = new AbortController() constructor(lsRpc: Promise, entryPoint: EntryPoint) { + super() + + this.abortCtl.signal.addEventListener('abort', () => { + this.queue.clear() + }) + this.queue = new AsyncQueue( lsRpc.then((lsRpc) => ({ lsRpc, @@ -144,8 +169,10 @@ export class ExecutionContext { stack: [], })), ) + this.registerHandlers() this.create() this.pushItem({ type: 'ExplicitCall', ...entryPoint }) + this.recompute() } private withBackoff(f: () => Promise, message: string): Promise { @@ -241,6 +268,8 @@ export class ExecutionContext { console.error('Failed to synchronize visualizations:', errors) } + this.emit('visualizationsConfigured', [new Set(this.visualizationConfigs.keys())]) + // State object was updated in-place in each successful promise. return state }) @@ -297,16 +326,65 @@ export class ExecutionContext { return { ...state, created: true } }, 'Failed to create execution context') }) + this.abortCtl.signal.addEventListener('abort', () => { + this.queue.pushTask(async (state) => { + if (!state.created) return state + await state.lsRpc.destroyExecutionContext(this.id) + return { ...state, created: false } + }) + }) + } + + private registerHandlers() { + this.queue.pushTask(async (state) => { + const expressionUpdates = state.lsRpc.on('executionContext/expressionUpdates', (event) => { + if (event.contextId == this.id) this.emit('expressionUpdates', [event.updates]) + }) + const executionFailed = state.lsRpc.on('executionContext/executionFailed', (event) => { + if (event.contextId == this.id) this.emit('executionFailed', [event.message]) + }) + const executionComplete = state.lsRpc.on('executionContext/executionComplete', (event) => { + if (event.contextId == this.id) this.emit('executionComplete', []) + }) + const executionStatus = state.lsRpc.on('executionContext/executionStatus', (event) => { + if (event.contextId == this.id) this.emit('executionStatus', [event.diagnostics]) + }) + const visualizationEvaluationFailed = state.lsRpc.on( + 'executionContext/visualizationEvaluationFailed', + (event) => { + if (event.contextId == this.id) + this.emit('visualizationEvaluationFailed', [ + event.visualizationId, + event.expressionId, + event.message, + event.diagnostic, + ]) + }, + ) + this.abortCtl.signal.addEventListener('abort', () => { + state.lsRpc.off('executionContext/expressionUpdates', expressionUpdates) + state.lsRpc.off('executionContext/executionFailed', executionFailed) + state.lsRpc.off('executionContext/executionComplete', executionComplete) + state.lsRpc.off('executionContext/executionStatus', executionStatus) + state.lsRpc.off( + 'executionContext/visualizationEvaluationFailed', + visualizationEvaluationFailed, + ) + }) + return state + }) + } + + recompute(expressionIds: 'all' | ExprId[] = 'all', executionEnvironment?: ExecutionEnvironment) { + this.queue.pushTask(async (state) => { + if (!state.created) return state + await state.lsRpc.recomputeExecutionContext(this.id, expressionIds, executionEnvironment) + return state + }) } destroy() { 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 } - }) } } @@ -393,38 +471,31 @@ export const useProjectStore = defineStore('project', () => { } const executionContext = createExecutionContextForMain() - const dataConnectionRef = computedAsync(() => dataConnection) + const computedValueRegistry = new ComputedValueRegistry(executionContext) + const visualizationDataRegistry = new VisualizationDataRegistry(executionContext, dataConnection) function useVisualizationData( configuration: WatchSource>, ): 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) - }) - }) + watch( + configuration, + async (config, _, onCleanup) => { + executionContext.setVisualization(id, config) + onCleanup(() => { + executionContext.setVisualization(id, null) + }) + }, + { immediate: true }, + ) - 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 + return shallowRef( + computed(() => { + const json = visualizationDataRegistry.getRawData(id) + return json != null ? JSON.parse(json) : undefined + }), + ) } function stopCapturingUndo() { @@ -436,11 +507,11 @@ export const useProjectStore = defineStore('project', () => { observedFileName.value = name }, name: projectName, - createExecutionContextForMain, executionContext, module, contentRoots, awareness, + computedValueRegistry, lsRpcConnection: markRaw(lsRpcConnection), dataConnection: markRaw(dataConnection), useVisualizationData, diff --git a/app/gui2/src/util/computedValueRegistry.ts b/app/gui2/src/util/computedValueRegistry.ts new file mode 100644 index 00000000000..ed36dd09fd4 --- /dev/null +++ b/app/gui2/src/util/computedValueRegistry.ts @@ -0,0 +1,49 @@ +import type { ExecutionContext } from '@/stores/project.ts' +import { reactive } from 'vue' +import type { + ExpressionId, + ExpressionUpdate, + ExpressionUpdatePayload, + MethodCall, + ProfilingInfo, +} from '../../shared/languageServerTypes.ts' + +export interface ExpressionInfo { + typename: string | undefined + methodCall: MethodCall | undefined + payload: ExpressionUpdatePayload + profilingInfo: ProfilingInfo[] +} + +//* This class holds the computed values that have been received from the language server. */ +export class ComputedValueRegistry { + private expressionMap: Map + private _updateHandler = this.processUpdates.bind(this) + private executionContext + + constructor(executionContext: ExecutionContext) { + this.executionContext = executionContext + this.expressionMap = reactive(new Map()) + + executionContext.on('expressionUpdates', this._updateHandler) + } + + processUpdates(updates: ExpressionUpdate[]) { + for (const update of updates) { + this.expressionMap.set(update.expressionId, { + typename: update.type, + methodCall: update.methodCall, + payload: update.payload, + profilingInfo: update.profilingInfo, + }) + } + } + + getExpressionInfo(exprId: ExpressionId): ExpressionInfo | undefined { + return this.expressionMap.get(exprId) + } + + destroy() { + this.executionContext.off('expressionUpdates', this._updateHandler) + } +} diff --git a/app/gui2/src/util/visualizationDataRegistry.ts b/app/gui2/src/util/visualizationDataRegistry.ts new file mode 100644 index 00000000000..35d44987a29 --- /dev/null +++ b/app/gui2/src/util/visualizationDataRegistry.ts @@ -0,0 +1,66 @@ +import type { ExecutionContext } from '@/stores/project.ts' +import { OutboundPayload, VisualizationUpdate } from 'shared/binaryProtocol.ts' +import type { DataServer } from 'shared/dataServer.ts' +import { reactive } from 'vue' +import type { + ExpressionUpdatePayload, + MethodCall, + ProfilingInfo, + Uuid, +} from '../../shared/languageServerTypes.ts' + +export interface ExpressionInfo { + typename: string | undefined + methodCall: MethodCall | undefined + payload: ExpressionUpdatePayload + profilingInfo: ProfilingInfo[] +} + +//* This class holds the computed values that have been received from the language server. */ +export class VisualizationDataRegistry { + private visualizationValues: Map + private dataServer: Promise + private executionContext: ExecutionContext + private _reconfiguredHandler = this.visualizationsConfigured.bind(this) + private _dataHandler = this.visualizationUpdate.bind(this) + + constructor(executionContext: ExecutionContext, dataServer: Promise) { + this.executionContext = executionContext + this.dataServer = dataServer + this.visualizationValues = reactive(new Map()) + + this.executionContext.on('visualizationsConfigured', this._reconfiguredHandler) + this.dataServer.then((data) => { + data.on(`${OutboundPayload.VISUALIZATION_UPDATE}`, this._dataHandler) + }) + } + + private visualizationsConfigured(uuids: Set) { + for (const key of this.visualizationValues.keys()) { + if (!uuids.has(key)) { + this.visualizationValues.delete(key) + } + } + } + + private visualizationUpdate(update: VisualizationUpdate, uuid: Uuid | null) { + if (uuid) { + const newData = update.dataString() + const current = this.visualizationValues.get(uuid) + if (current !== newData) { + this.visualizationValues.set(uuid, newData) + } + } + } + + getRawData(visualizationId: Uuid): string | null { + return this.visualizationValues.get(visualizationId) ?? null + } + + destroy() { + this.executionContext.off('visualizationsConfigured', this._reconfiguredHandler) + this.dataServer.then((data) => { + data.off(`${OutboundPayload.VISUALIZATION_UPDATE}`, this._dataHandler) + }) + } +}