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
This commit is contained in:
Michael Mauderer 2023-10-09 07:55:12 +02:00 committed by GitHub
parent 44f2f425c0
commit 10a95e43d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 327 additions and 68 deletions

View File

@ -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<OutboundPayload, new () => 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<DataServerEvents> {
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])
}
}
}

View File

@ -250,7 +250,7 @@ export class LanguageServer extends ObservableV2<Notifications> {
expressionId: ExpressionId,
visualizationConfig: VisualizationConfiguration,
): Promise<void> {
return this.request('executionContext/interrupt', {
return this.request('executionContext/executeExpression', {
visualizationId,
expressionId,
visualizationConfig,

View File

@ -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`. */

View File

@ -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)

View File

@ -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'],
})

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { codeEditorBindings, graphBindings, nodeBindings } from '@/bindings'
import { codeEditorBindings, graphBindings, nodeSelectionBindings } from '@/bindings'
import CodeEditor from '@/components/CodeEditor.vue'
import ComponentBrowser from '@/components/ComponentBrowser.vue'
import GraphEdge from '@/components/GraphEdge.vue'
@ -159,6 +159,7 @@ const graphBindingsHandler = graphBindings.handler({
}
},
newNode() {
if (keyboardBusy()) return false
if (navigator.sceneMousePos != null) {
graphStore.createNode(navigator.sceneMousePos, 'hello "world"! 123 + x')
}
@ -174,7 +175,7 @@ const codeEditorHandler = codeEditorBindings.handler({
},
})
const nodeSelectionHandler = nodeBindings.handler({
const nodeSelectionHandler = nodeSelectionBindings.handler({
deleteSelected() {
graphStore.transact(() => {
for (const node of selectedNodes.value) {
@ -207,7 +208,7 @@ const nodeSelectionHandler = nodeBindings.handler({
},
})
const mouseHandler = nodeBindings.handler({
const mouseHandler = nodeSelectionBindings.handler({
replace() {
selectedNodes.value = new Set(intersectingNodes.value)
},
@ -372,12 +373,4 @@ svg {
width: 0;
height: 0;
}
.circle {
position: absolute;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: purple;
}
</style>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { nodeBindings } from '@/bindings'
import { nodeEditBindings, nodeSelectionBindings } from '@/bindings'
import CircularMenu from '@/components/CircularMenu.vue'
import NodeSpan from '@/components/NodeSpan.vue'
import SvgIcon from '@/components/SvgIcon.vue'
@ -27,6 +27,7 @@ const MAXIMUM_CLICK_LENGTH_MS = 300
const props = defineProps<{
node: Node
// id: string & ExprId
selected: boolean
isLatestSelected: boolean
fullscreenVis: boolean
@ -354,7 +355,7 @@ watch(
},
)
const mouseHandler = nodeBindings.handler({
const mouseHandler = nodeSelectionBindings.handler({
replace() {
emit('replaceSelection')
},
@ -372,6 +373,18 @@ const mouseHandler = nodeBindings.handler({
},
})
const editableKeydownHandler = nodeEditBindings.handler({
selectAll() {
const element = editableRootNode.value
const selection = window.getSelection()
if (element == null || selection == null) return
const range = document.createRange()
range.selectNodeContents(element)
selection.removeAllRanges()
selection.addRange(range)
},
})
const startEpochMs = ref(0)
const startEvent = ref<PointerEvent>()
@ -395,6 +408,18 @@ const dragPointer = usePointer((pos, event, type) => {
}
}
})
const expressionInfo = computed(() => {
return projectStore.computedValueRegistry.getExpressionInfo(props.node.rootSpan.id)
})
const outputTypeName = computed(() => {
return expressionInfo.value?.typename ?? 'Unknown'
})
const executionState = computed(() => {
return expressionInfo.value?.payload.type ?? 'Unknown'
})
</script>
<template>
@ -406,6 +431,7 @@ const dragPointer = usePointer((pos, event, type) => {
dragging: dragPointer.dragging,
selected,
visualizationVisible: isVisualizationVisible,
['executionState-' + executionState]: true,
}"
>
<div class="selection" v-on="dragPointer.events"></div>
@ -433,6 +459,7 @@ const dragPointer = usePointer((pos, event, type) => {
contenteditable
spellcheck="false"
@beforeinput="editContent"
@keydown="editableKeydownHandler"
@pointerdown.stop
@blur="projectStore.stopCapturingUndo()"
>
@ -444,6 +471,7 @@ const dragPointer = usePointer((pos, event, type) => {
/>
</div>
</div>
<div class="outputTypeName">{{ outputTypeName }}</div>
</div>
</template>
@ -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;
}
</style>

View File

@ -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;
}
}
</style>

View File

@ -146,6 +146,7 @@ export const useGraphStore = defineStore('graph', () => {
function nodeInserted(stmt: Statement, text: Y.Text, content: string, meta: Opt<NodeMetadata>) {
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

View File

@ -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<ExplicitCall, 'type'>
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<Uuid>): void
}
/**
* Execution Context
*
@ -127,7 +146,7 @@ type EntryPoint = Omit<ExplicitCall, 'type'>
* 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<ExecutionContextNotification> {
id: ContextId = random.uuidv4() as ContextId
queue: AsyncQueue<ExecutionContextState>
taskRunning = false
@ -136,6 +155,12 @@ export class ExecutionContext {
abortCtl = new AbortController()
constructor(lsRpc: Promise<LanguageServer>, 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<T>(f: () => Promise<T>, message: string): Promise<T> {
@ -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<DataServer | undefined>(() => dataConnection)
const computedValueRegistry = new ComputedValueRegistry(executionContext)
const visualizationDataRegistry = new VisualizationDataRegistry(executionContext, 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)
})
})
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,

View File

@ -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<ExpressionId, ExpressionInfo>
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)
}
}

View File

@ -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<Uuid, string | null>
private dataServer: Promise<DataServer>
private executionContext: ExecutionContext
private _reconfiguredHandler = this.visualizationsConfigured.bind(this)
private _dataHandler = this.visualizationUpdate.bind(this)
constructor(executionContext: ExecutionContext, dataServer: Promise<DataServer>) {
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<Uuid>) {
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)
})
}
}