From 6c2b2383d34e29c5ce58634762172765ee1b30a7 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 8 Mar 2024 14:31:32 +1000 Subject: [PATCH] Change "Override Execution Context" button to "Record" button on nodes (#9188) - Close #9164 - Fix appearance of Record/Record Once icon in top menu - Change icon for overriding execution context to record icon - Unconditionally show per-node record icon if it is set - Remove the ability to override the execution context to disabled - Fix the icon for nodes with an overridden execution context always being the Enso icon # Important Notes None --- app/gui2/e2e/main.ts | 6 +- app/gui2/src/assets/icons.svg | 8 +- app/gui2/src/components/CircularMenu.vue | 24 ++-- app/gui2/src/components/CodeEditor.vue | 2 +- .../src/components/ComponentBrowser/input.ts | 2 +- app/gui2/src/components/GraphEditor.vue | 4 +- .../src/components/GraphEditor/GraphNode.vue | 116 ++++++++---------- .../GraphEditor/__tests__/collapsing.test.ts | 4 +- .../src/components/GraphEditor/collapsing.ts | 2 +- app/gui2/src/components/RecordControl.vue | 24 ++-- app/gui2/src/components/SvgIcon.vue | 17 ++- .../composables/__tests__/selection.test.ts | 4 +- app/gui2/src/main.ts | 2 + .../graph/__tests__/graphDatabase.test.ts | 9 +- app/gui2/src/stores/graph/graphDatabase.ts | 58 ++++++--- app/gui2/src/stores/graph/index.ts | 6 +- app/gui2/src/stores/project/index.ts | 4 +- app/gui2/src/util/ast/__tests__/node.test.ts | 10 +- app/gui2/src/util/ast/node.ts | 24 +++- app/gui2/src/util/ast/prefixes.ts | 6 +- app/gui2/stories/GraphNode.story.vue | 16 +-- 21 files changed, 207 insertions(+), 141 deletions(-) diff --git a/app/gui2/e2e/main.ts b/app/gui2/e2e/main.ts index b9d30b3db3b7..8475d87446aa 100644 --- a/app/gui2/e2e/main.ts +++ b/app/gui2/e2e/main.ts @@ -6,6 +6,7 @@ import { mockDataHandler, mockLSHandler } from '../mock/engine' import '../src/assets/main.css' import { provideGuiConfig } from '../src/providers/guiConfig' import { provideVisualizationConfig } from '../src/providers/visualizationConfig' +import { initializePrefixes } from '../src/util/ast/node' import { Vec2 } from '../src/util/data/vec2' import { MockTransport, MockWebSocket } from '../src/util/net' import MockApp from './MockApp.vue' @@ -56,4 +57,7 @@ provideVisualizationConfig._mock( }, app, ) -initializeFFI().then(() => app.mount('#app')) +initializeFFI().then(() => { + initializePrefixes() + app.mount('#app') +}) diff --git a/app/gui2/src/assets/icons.svg b/app/gui2/src/assets/icons.svg index 848cee9ef98b..44f69ced84e7 100644 --- a/app/gui2/src/assets/icons.svg +++ b/app/gui2/src/assets/icons.svg @@ -677,11 +677,13 @@ - + - + + + @@ -709,7 +711,7 @@ - + diff --git a/app/gui2/src/components/CircularMenu.vue b/app/gui2/src/components/CircularMenu.vue index 0ecc83b613f1..d393b1f44ab3 100644 --- a/app/gui2/src/components/CircularMenu.vue +++ b/app/gui2/src/components/CircularMenu.vue @@ -3,14 +3,14 @@ import SvgIcon from '@/components/SvgIcon.vue' import ToggleIcon from '@/components/ToggleIcon.vue' const props = defineProps<{ - isOutputContextEnabledGlobally: boolean - isOutputContextOverridden: boolean + isRecordingEnabledGlobally: boolean + isRecordingOverridden: boolean isDocsVisible: boolean isVisualizationVisible: boolean isFullMenuVisible: boolean }>() const emit = defineEmits<{ - 'update:isOutputContextOverridden': [isOutputContextOverridden: boolean] + 'update:isRecordingOverridden': [isRecordingOverridden: boolean] 'update:isDocsVisible': [isDocsVisible: boolean] 'update:isVisualizationVisible': [isVisualizationVisible: boolean] startEditing: [] @@ -56,16 +56,12 @@ const emit = defineEmits<{ @click.stop="emit('startEditing')" /> @@ -155,7 +151,7 @@ const emit = defineEmits<{ opacity: 10%; } -.output-context-overridden { +.recording-overridden { opacity: 100%; color: red; } @@ -211,7 +207,7 @@ const emit = defineEmits<{ .slot7 { position: absolute; top: 44px; - left: 9px; + left: 8px; } .slot8 { diff --git a/app/gui2/src/components/CodeEditor.vue b/app/gui2/src/components/CodeEditor.vue index 908aa1622123..ed6af84b4a67 100644 --- a/app/gui2/src/components/CodeEditor.vue +++ b/app/gui2/src/components/CodeEditor.vue @@ -113,7 +113,7 @@ watchEffect(() => { const astSpan = ast.span() let foundNode: NodeId | undefined for (const [id, node] of graphStore.db.nodeIdToNode.entries()) { - const rootSpan = graphStore.moduleSource.getSpan(node.rootSpan.id) + const rootSpan = graphStore.moduleSource.getSpan(node.rootExpr.id) if (rootSpan && rangeEncloses(rootSpan, astSpan)) { foundNode = id break diff --git a/app/gui2/src/components/ComponentBrowser/input.ts b/app/gui2/src/components/ComponentBrowser/input.ts index 53e05f9e744c..e6a36494c5fc 100644 --- a/app/gui2/src/components/ComponentBrowser/input.ts +++ b/app/gui2/src/components/ComponentBrowser/input.ts @@ -456,7 +456,7 @@ export function useComponentBrowserInput( } break case 'editNode': - code.value = graphDb.nodeIdToNode.get(usage.node)?.rootSpan.code() ?? '' + code.value = graphDb.nodeIdToNode.get(usage.node)?.innerExpr.code() ?? '' selection.value = { start: usage.cursorPos, end: usage.cursorPos } break } diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index 44336d606f66..f8c6d1f7d019 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -495,8 +495,8 @@ function copyNodeContent() { const id = nodeSelection.selected.values().next().value const node = graphStore.db.nodeIdToNode.get(id) if (!node) return - const content = node.rootSpan.code() - const nodeMetadata = node.rootSpan.nodeMetadata + const content = node.innerExpr.code() + const nodeMetadata = node.rootExpr.nodeMetadata const metadata = { position: nodeMetadata.get('position'), visualization: nodeMetadata.get('visualization'), diff --git a/app/gui2/src/components/GraphEditor/GraphNode.vue b/app/gui2/src/components/GraphEditor/GraphNode.vue index 46a8700413da..71898f31fa64 100644 --- a/app/gui2/src/components/GraphEditor/GraphNode.vue +++ b/app/gui2/src/components/GraphEditor/GraphNode.vue @@ -16,7 +16,7 @@ import { asNodeId } from '@/stores/graph/graphDatabase' import { useProjectStore } from '@/stores/project' import { Ast } from '@/util/ast' import type { AstId } from '@/util/ast/abstract' -import { Prefixes } from '@/util/ast/prefixes' +import { prefixes } from '@/util/ast/node' import type { Opt } from '@/util/data/opt' import { Rect } from '@/util/data/rect' import { Vec2 } from '@/util/data/vec2' @@ -28,16 +28,6 @@ import { computed, onUnmounted, ref, watch, watchEffect } from 'vue' const MAXIMUM_CLICK_LENGTH_MS = 300 const MAXIMUM_CLICK_DISTANCE_SQ = 50 -const prefixes = Prefixes.FromLines({ - enableOutputContext: - 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __', - disableOutputContext: - 'Standard.Base.Runtime.with_disabled_context Standard.Base.Runtime.Context.Output __ <| __', - // Currently unused; included as PoC. - skip: 'SKIP __', - freeze: 'FREEZE __', -}) - const props = defineProps<{ node: Node edited: boolean @@ -71,12 +61,11 @@ const outputPortsSet = computed(() => { return bindings }) -const nodeId = computed(() => asNodeId(props.node.rootSpan.id)) -const externalId = computed(() => props.node.rootSpan.externalId) +const nodeId = computed(() => asNodeId(props.node.rootExpr.id)) const potentialSelfArgumentId = computed(() => props.node.primarySubject) const connectedSelfArgumentId = computed(() => - props.node.primarySubject && graph.isConnectedTarget(props.node.primarySubject) ? - props.node.primarySubject + potentialSelfArgumentId.value && graph.isConnectedTarget(potentialSelfArgumentId.value) ? + potentialSelfArgumentId.value : undefined, ) @@ -118,9 +107,11 @@ const isOnlyOneSelected = computed( const menuVisible = isOnlyOneSelected const menuFull = ref(false) + watch(menuVisible, (visible) => { if (!visible) menuFull.value = false }) + function openFullMenu() { menuFull.value = true nodeSelection?.setSelection(new Set([nodeId.value])) @@ -139,15 +130,16 @@ watchEffect(() => { }) const bgStyleVariables = computed(() => { + const { x: width, y: height } = nodeSize.value return { - '--node-width': `${nodeSize.value.x}px`, - '--node-height': `${nodeSize.value.y}px`, + '--node-width': `${width}px`, + '--node-height': `${height}px`, } }) const transform = computed(() => { - let pos = props.node.position - return `translate(${pos.x}px, ${pos.y}px)` + const { x, y } = props.node.position + return `translate(${x}px, ${y}px)` }) const startEpochMs = ref(0) @@ -186,62 +178,30 @@ const dragPointer = usePointer((pos, event, type) => { } }) -const matches = computed(() => prefixes.extractMatches(props.node.rootSpan)) -const displayedExpression = computed(() => props.node.rootSpan.module.get(matches.value.innerExpr)) - -const isOutputContextOverridden = computed({ +const isRecordingOverridden = computed({ get() { - const override = - matches.value.matches.enableOutputContext ?? matches.value.matches.disableOutputContext - const overrideEnabled = matches.value.matches.enableOutputContext != null - // An override is only counted as enabled if it is currently in effect. This requires: - // - that an override exists - if (!override) return false - // - that it is setting the "enabled" value to a non-default value - else if (overrideEnabled === projectStore.isOutputContextEnabled) return false - // - and that it applies to the current execution context. - else { - const module = props.node.rootSpan.module - const contextWithoutQuotes = module - .get(override[0]) - ?.code() - .replace(/^['"]|['"]$/g, '') - return contextWithoutQuotes === projectStore.executionMode - } + return props.node.prefixes.enableRecording != null }, set(shouldOverride) { - const module = projectStore.module - if (!module) return - const edit = props.node.rootSpan.module.edit() - const replacementText = - shouldOverride ? [Ast.TextLiteral.new(projectStore.executionMode, edit)] : undefined - const replacements = - projectStore.isOutputContextEnabled ? - { - enableOutputContext: undefined, - disableOutputContext: replacementText, - } - : { - enableOutputContext: replacementText, - disableOutputContext: undefined, - } - prefixes.modify(edit.getVersion(props.node.rootSpan), replacements) + const edit = props.node.rootExpr.module.edit() + const replacement = + shouldOverride && !projectStore.isRecordingEnabled ? + [Ast.TextLiteral.new(projectStore.executionMode, edit)] + : undefined + prefixes.modify(edit.getVersion(props.node.rootExpr), { enableRecording: replacement }) graph.commitEdit(edit) }, }) -// FIXME [sb]: https://github.com/enso-org/enso/issues/8442 -// This does not take into account `displayedExpression`. -const expressionInfo = computed(() => graph.db.getExpressionInfo(externalId.value)) +const expressionInfo = computed(() => graph.db.getExpressionInfo(props.node.innerExpr.externalId)) const outputPortLabel = computed(() => expressionInfo.value?.typename ?? 'Unknown') const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown') const suggestionEntry = computed(() => graph.db.nodeMainSuggestion.lookup(nodeId.value)) const color = computed(() => graph.db.getNodeColorStyle(nodeId.value)) const icon = computed(() => { - const expressionInfo = graph.db.getExpressionInfo(externalId.value) return displayedIconOf( suggestionEntry.value, - expressionInfo?.methodCall?.methodPointer, + expressionInfo.value?.methodCall?.methodPointer, outputPortLabel.value, ) }) @@ -259,7 +219,7 @@ const nodeEditHandler = nodeEditBindings.handler({ }) function startEditingNode(position: Vec2 | undefined) { - let sourceOffset = props.node.rootSpan.code().length + let sourceOffset = props.node.rootExpr.code().length if (position != null) { let domNode, domOffset if ((document as any).caretPositionFromPoint) { @@ -404,11 +364,18 @@ const documentation = computed({
{{ node.pattern?.code() ?? '' }}
+ ({ :scale="navigator?.scale ?? 1" :nodePosition="props.node.position" :isCircularMenuVisible="menuVisible" - :currentType="node.vis?.identifier" + :currentType="props.node.vis?.identifier" :isFullscreen="isVisualizationFullscreen" - :dataSource="{ type: 'node', nodeId: externalId }" + :dataSource="{ type: 'node', nodeId: props.node.rootExpr.externalId }" :typename="expressionInfo?.typename" :width="visualizationWidth" :isFocused="isOnlyOneSelected" @@ -452,7 +419,7 @@ const documentation = computed({ @pointerup.stop > ({ .GraphNode:has(.selection:hover) .statuses { opacity: 0; } + +.overrideRecordButton { + position: absolute; + cursor: pointer; + display: flex; + align-items: center; + backdrop-filter: var(--blur-app-bg); + background: var(--color-app-bg); + border-radius: var(--radius-full); + color: red; + padding: 8px; + height: 100%; + right: 100%; + margin-right: 4px; +} diff --git a/app/gui2/src/components/GraphEditor/__tests__/collapsing.test.ts b/app/gui2/src/components/GraphEditor/__tests__/collapsing.test.ts index 7b3b0565c759..2e13cc0f494a 100644 --- a/app/gui2/src/components/GraphEditor/__tests__/collapsing.test.ts +++ b/app/gui2/src/components/GraphEditor/__tests__/collapsing.test.ts @@ -2,12 +2,14 @@ import { prepareCollapsedInfo } from '@/components/GraphEditor/collapsing' import { GraphDb, type NodeId } from '@/stores/graph/graphDatabase' import { assert } from '@/util/assert' import { Ast, RawAst } from '@/util/ast' +import { initializePrefixes } from '@/util/ast/node' import { unwrap } from '@/util/data/result' import { tryIdentifier } from '@/util/qualifiedName' import { initializeFFI } from 'shared/ast/ffi' import { expect, test } from 'vitest' await initializeFFI() +initializePrefixes() function setupGraphDb(code: string, graphDb: GraphDb) { const { root, toRaw, getSpan } = Ast.parseExtended(code) @@ -121,7 +123,7 @@ test.each(testCases)('Collapsing nodes, $description', (testCase) => { const nodePatternToId = new Map() for (const code of testCase.initialNodes) { const [pattern, expr] = code.split(/\s*=\s*/) - const [id, _] = nodes.find(([_id, node]) => node.rootSpan.code() == expr)! + const [id, _] = nodes.find(([_id, node]) => node.innerExpr.code() == expr)! nodeCodeToId.set(code, id) if (pattern != null) nodePatternToId.set(pattern, id) } diff --git a/app/gui2/src/components/GraphEditor/collapsing.ts b/app/gui2/src/components/GraphEditor/collapsing.ts index a8523e9c266e..1847c0dc8ad6 100644 --- a/app/gui2/src/components/GraphEditor/collapsing.ts +++ b/app/gui2/src/components/GraphEditor/collapsing.ts @@ -196,7 +196,7 @@ export function performCollapse( // Insert a new function. const collapsedNodeIds = collapsed - .map((ast) => asNodeId(nodeFromAst(ast)?.rootSpan.id ?? ast.id)) + .map((ast) => asNodeId(nodeFromAst(ast)?.rootExpr.id ?? ast.id)) .reverse() let outputNodeId: NodeId | undefined const outputIdentifier = info.extracted.output?.identifier diff --git a/app/gui2/src/components/RecordControl.vue b/app/gui2/src/components/RecordControl.vue index b8322bb8c4ee..47b04729a4d2 100644 --- a/app/gui2/src/components/RecordControl.vue +++ b/app/gui2/src/components/RecordControl.vue @@ -8,7 +8,7 @@ const emit = defineEmits<{ recordOnce: []; 'update:recordMode': [enabled: boolea @@ -41,14 +35,26 @@ const emit = defineEmits<{ recordOnce: []; 'update:recordMode': [enabled: boolea background: var(--color-frame-bg); backdrop-filter: var(--blur-app-bg); padding: 8px 8px; + width: 42px; + cursor: pointer; } .left-end { border-radius: var(--radius-full) 0 0 var(--radius-full); + + .button { + margin: 0 4px 0 auto; + } } .right-end { border-radius: 0 var(--radius-full) var(--radius-full) 0; + + .button { + position: relative; + top: -4px; + margin: 0 auto 0 0; + } } .toggledOn { diff --git a/app/gui2/src/components/SvgIcon.vue b/app/gui2/src/components/SvgIcon.vue index 9ec99ae5e863..f432105c53e7 100644 --- a/app/gui2/src/components/SvgIcon.vue +++ b/app/gui2/src/components/SvgIcon.vue @@ -8,11 +8,22 @@ import icons from '@/assets/icons.svg' import type { URLString } from '@/util/data/urlString' import type { Icon } from '@/util/iconName' -const props = defineProps<{ name: Icon | URLString; width?: number; height?: number }>() +const props = defineProps<{ + name: Icon | URLString + width?: number + height?: number + scale?: number +}>() @@ -23,5 +34,7 @@ svg { min-width: var(--width); height: var(--height); min-height: var(--height); + transform: scale(var(--scale)); + transform-origin: top left; } diff --git a/app/gui2/src/composables/__tests__/selection.test.ts b/app/gui2/src/composables/__tests__/selection.test.ts index cf55aa348bc0..abb3302bb456 100644 --- a/app/gui2/src/composables/__tests__/selection.test.ts +++ b/app/gui2/src/composables/__tests__/selection.test.ts @@ -105,7 +105,9 @@ class MockPointerEvent extends MouseEvent { readonly pointerId: number constructor(type: string, options: MouseEventInit & { currentTarget?: Element | undefined }) { super(type, options) - vi.spyOn(this, 'currentTarget', 'get').mockReturnValue(options.currentTarget ?? null) + vi.spyOn(this, 'currentTarget', 'get').mockReturnValue( + options.currentTarget ?? null, + ) this.pointerId = 4 } } diff --git a/app/gui2/src/main.ts b/app/gui2/src/main.ts index 6fcd925a83d8..8a7ccbf9f774 100644 --- a/app/gui2/src/main.ts +++ b/app/gui2/src/main.ts @@ -1,3 +1,4 @@ +import { initializePrefixes } from '@/util/ast/node' import { baseConfig, configValue, mergeConfig } from '@/util/config' import { urlParams } from '@/util/urlParams' import { isOnLinux } from 'enso-common/src/detect' @@ -52,6 +53,7 @@ export interface StringConfig { async function runApp(config: StringConfig | null, accessToken: string | null, metadata?: object) { await initializeFFI() + initializePrefixes() running = true const { mountProjectApp } = await import('./createApp') if (!running) return diff --git a/app/gui2/src/stores/graph/__tests__/graphDatabase.test.ts b/app/gui2/src/stores/graph/__tests__/graphDatabase.test.ts index dfa9321a5754..aa19996bf140 100644 --- a/app/gui2/src/stores/graph/__tests__/graphDatabase.test.ts +++ b/app/gui2/src/stores/graph/__tests__/graphDatabase.test.ts @@ -1,11 +1,14 @@ import { asNodeId, GraphDb } from '@/stores/graph/graphDatabase' import { Ast, RawAst } from '@/util/ast' +import { initializePrefixes } from '@/util/ast/node' import assert from 'assert' +import type { AstId } from 'shared/ast' import { initializeFFI } from 'shared/ast/ffi' import { IdMap, type ExternalId } from 'shared/yjsModel' import { expect, test } from 'vitest' await initializeFFI() +initializePrefixes() /** * Create a predictable fake UUID which contains given number in decimal at the end. @@ -46,8 +49,10 @@ test('Reading graph from definition', () => { assert(rawFunc?.type === RawAst.Tree.Type.Function) db.readFunctionAst(func, rawFunc, code, getSpan, new Set()) - const idFromExternal = new Map() - ast.visitRecursiveAst((ast) => idFromExternal.set(ast.externalId, ast.id)) + const idFromExternal = new Map() + ast.visitRecursiveAst((ast) => { + idFromExternal.set(ast.externalId, ast.id) + }) const id = (x: number) => idFromExternal.get(eid(x))! expect(Array.from(db.nodeIdToNode.keys())).toEqual([id(4), id(8), id(12)]) diff --git a/app/gui2/src/stores/graph/graphDatabase.ts b/app/gui2/src/stores/graph/graphDatabase.ts index a6733d557bae..2839bd22e8f8 100644 --- a/app/gui2/src/stores/graph/graphDatabase.ts +++ b/app/gui2/src/stores/graph/graphDatabase.ts @@ -140,7 +140,7 @@ export class GraphDb { private nodeIdToExprIds = new ReactiveIndex(this.nodeIdToNode, (id, entry) => { const exprs: AstId[] = [] - entry.rootSpan.visitRecursiveAst((ast) => void exprs.push(ast.id)) + entry.innerExpr.visitRecursiveAst((ast) => void exprs.push(ast.id)) return Array.from(exprs, (expr) => [id, expr]) }) @@ -187,8 +187,8 @@ export class GraphDb { return Array.from(ports, (port) => [id, port]) }) - nodeMainSuggestion = new ReactiveMapping(this.nodeIdToNode, (id, _entry) => { - const expressionInfo = this.getExpressionInfo(id) + nodeMainSuggestion = new ReactiveMapping(this.nodeIdToNode, (id, entry) => { + const expressionInfo = this.getExpressionInfo(entry.innerExpr.id) const method = expressionInfo?.methodCall?.methodPointer if (method == null) return const suggestionId = this.suggestionDb.findByMethodPointer(method) @@ -340,7 +340,7 @@ export class GraphDb { for (const nodeAst of functionAst_.bodyExpressions()) { const newNode = nodeFromAst(nodeAst) if (!newNode) continue - const nodeId = asNodeId(newNode.rootSpan.id) + const nodeId = asNodeId(newNode.rootExpr.id) const node = this.nodeIdToNode.get(nodeId) currentNodeIds.add(nodeId) if (node == null) { @@ -351,7 +351,7 @@ export class GraphDb { // We are notified of new or changed metadata by `updateMetadata`, so we only need to read existing metadata // when we switch to a different function. if (functionChanged) { - const nodeMeta = newNode.rootSpan.nodeMetadata + const nodeMeta = newNode.rootExpr.nodeMetadata const pos = nodeMeta.get('position') ?? { x: 0, y: 0 } metadataFields = { position: new Vec2(pos.x, pos.y), @@ -360,20 +360,37 @@ export class GraphDb { } this.nodeIdToNode.set(nodeId, { ...newNode, ...metadataFields }) } else { - const { outerExprId, pattern, rootSpan, primarySubject, documentation } = newNode + const { + outerExprId, + pattern, + rootExpr, + innerExpr, + primarySubject, + prefixes, + documentation, + } = newNode const differentOrDirty = (a: Ast.Ast | undefined, b: Ast.Ast | undefined) => a?.id !== b?.id || (a && subtreeDirty(a.id)) - if (differentOrDirty(node.pattern, pattern)) node.pattern = pattern - if (differentOrDirty(node.rootSpan, rootSpan)) node.rootSpan = rootSpan if (node.outerExprId !== outerExprId) node.outerExprId = outerExprId + if (differentOrDirty(node.pattern, pattern)) node.pattern = pattern + if (differentOrDirty(node.rootExpr, rootExpr)) node.rootExpr = rootExpr + if (differentOrDirty(node.innerExpr, innerExpr)) node.innerExpr = innerExpr if (node.primarySubject !== primarySubject) node.primarySubject = primarySubject if (node.documentation !== documentation) node.documentation = documentation + if ( + Object.entries(node.prefixes).some( + ([k, v]) => prefixes[k as keyof typeof node.prefixes] !== v, + ) + ) + node.prefixes = prefixes // Ensure new fields can't be added to `NodeAstData` without this code being updated. const _allFieldsHandled = { outerExprId, pattern, - rootSpan, + rootExpr, + innerExpr, primarySubject, + prefixes, documentation, } satisfies NodeDataFromAst } @@ -394,7 +411,7 @@ export class GraphDb { idToExternalNew.set(ast.id, ast.externalId) idFromExternalNew.set(ast.externalId, ast.id) }) - const updateMap = (map: Map, newMap: Map) => { + const updateMap = (map: Map, newMap: Map) => { for (const key of map.keys()) if (!newMap.has(key)) map.delete(key) for (const [key, value] of newMap) map.set(key, value) } @@ -446,7 +463,8 @@ export class GraphDb { ...baseMockNode, outerExprId: id, pattern, - rootSpan: Ast.parse(code ?? '0'), + rootExpr: Ast.parse(code ?? '0'), + innerExpr: Ast.parse(code ?? '0'), } const bindingId = pattern.id this.nodeIdToNode.set(asNodeId(id), node) @@ -462,9 +480,19 @@ export function asNodeId(id: Ast.AstId): NodeId { } export interface NodeDataFromAst { + /** The ID of the outer expression. Usually this is an assignment expression (`a = b`). */ outerExprId: Ast.AstId + /** The left side of the assignment experssion, if `outerExpr` is an assignment expression. */ pattern: Ast.Ast | undefined - rootSpan: Ast.Ast + /** The value of the node. The right side of the assignment, if `outerExpr` is an assignment + * expression, else the entire `outerExpr`. */ + rootExpr: Ast.Ast + /** The expression displayed by the node. This is `rootExpr`, minus the prefixes, which are in + * `prefixes`. */ + innerExpr: Ast.Ast + /** Prefixes that are present in `rootExpr` but omitted in `innerExpr` to ensure a clean output. + */ + prefixes: Record<'enableRecording', Ast.AstId[] | undefined> /** A child AST in a syntactic position to be a self-argument input to the node. */ primarySubject: Ast.AstId | undefined documentation: string | undefined @@ -480,9 +508,10 @@ export interface Node extends NodeDataFromAst, NodeDataFromMetadata {} const baseMockNode = { position: Vec2.Zero, vis: undefined, + prefixes: { enableRecording: undefined }, primarySubject: undefined, documentation: undefined, -} +} satisfies Partial /** This should only be used for supplying as initial props when testing. * Please do {@link GraphDb.mockNode} with a `useGraphStore().db` after mount. */ @@ -491,7 +520,8 @@ export function mockNode(exprId?: Ast.AstId): Node { ...baseMockNode, outerExprId: exprId ?? (random.uuidv4() as Ast.AstId), pattern: undefined, - rootSpan: Ast.parse('0'), + rootExpr: Ast.parse('0'), + innerExpr: Ast.parse('0'), } } diff --git a/app/gui2/src/stores/graph/index.ts b/app/gui2/src/stores/graph/index.ts index aa6c6d98c346..fcce62c4d63f 100644 --- a/app/gui2/src/stores/graph/index.ts +++ b/app/gui2/src/stores/graph/index.ts @@ -326,11 +326,11 @@ export const useGraphStore = defineStore('graph', () => { const node = db.nodeIdToNode.get(id) if (!node) return edit((edit) => { - edit.getVersion(node.rootSpan).syncToCode(content) + edit.getVersion(node.rootExpr).syncToCode(content) if (withImports) { const conflicts = addMissingImports(edit, withImports) if (conflicts == null) return - const wholeAssignment = edit.getVersion(node.rootSpan)?.mutableParent() + const wholeAssignment = edit.getVersion(node.rootExpr)?.mutableParent() if (wholeAssignment == null) { console.error('Cannot find parent of the node expression. Conflict resolution failed.') return @@ -558,7 +558,7 @@ export const useGraphStore = defineStore('graph', () => { let exprId: AstId | undefined if (expr) { const node = db.nodeIdToNode.get(nodeId) - node?.rootSpan.visitRecursive((ast) => { + node?.innerExpr.visitRecursive((ast) => { if (ast instanceof Ast.Ast && ast.code() == expr) { exprId = ast.id } diff --git a/app/gui2/src/stores/project/index.ts b/app/gui2/src/stores/project/index.ts index 56d0dd813eb6..4839d2d0542a 100644 --- a/app/gui2/src/stores/project/index.ts +++ b/app/gui2/src/stores/project/index.ts @@ -637,7 +637,7 @@ export const useProjectStore = defineStore('project', () => { }) }) - const isOutputContextEnabled = computed(() => executionMode.value === 'live') + const isRecordingEnabled = computed(() => executionMode.value === 'live') function stopCapturingUndo() { module.value?.undoManager.stopCapturing() @@ -713,7 +713,7 @@ export const useProjectStore = defineStore('project', () => { lsRpcConnection: markRaw(lsRpcConnection), dataConnection: markRaw(dataConnection), useVisualizationData, - isOutputContextEnabled, + isRecordingEnabled, stopCapturingUndo, executionMode, recordMode, diff --git a/app/gui2/src/util/ast/__tests__/node.test.ts b/app/gui2/src/util/ast/__tests__/node.test.ts index 81dd0e5e6e4c..898947786544 100644 --- a/app/gui2/src/util/ast/__tests__/node.test.ts +++ b/app/gui2/src/util/ast/__tests__/node.test.ts @@ -1,22 +1,24 @@ import { Ast } from '@/util/ast' -import { nodeFromAst } from '@/util/ast/node' +import { initializePrefixes, nodeFromAst } from '@/util/ast/node' import { initializeFFI } from 'shared/ast/ffi' import { expect, test } from 'vitest' await initializeFFI() +initializePrefixes() test.each` - line | pattern | rootSpan | documentation + line | pattern | rootExpr | documentation ${'2 + 2'} | ${undefined} | ${'2 + 2'} | ${undefined} ${'foo = bar'} | ${'foo'} | ${'bar'} | ${undefined} ${'## Documentation\n2 + 2'} | ${undefined} | ${'2 + 2'} | ${'Documentation'} ${'## Documentation\nfoo = 2 + 2'} | ${'foo'} | ${'2 + 2'} | ${'Documentation'} -`('Node information from AST $line line', ({ line, pattern, rootSpan, documentation }) => { +`('Node information from AST $line line', ({ line, pattern, rootExpr, documentation }) => { const ast = Ast.Ast.parse(line) const node = nodeFromAst(ast) expect(node?.outerExprId).toBe(ast.id) expect(node?.pattern?.code()).toBe(pattern) - expect(node?.rootSpan.code()).toBe(rootSpan) + expect(node?.rootExpr.code()).toBe(rootExpr) + expect(node?.innerExpr.code()).toBe(rootExpr) expect(node?.documentation).toBe(documentation) }) diff --git a/app/gui2/src/util/ast/node.ts b/app/gui2/src/util/ast/node.ts index af694c80c97b..659b8d2be4c0 100644 --- a/app/gui2/src/util/ast/node.ts +++ b/app/gui2/src/util/ast/node.ts @@ -1,5 +1,20 @@ import type { NodeDataFromAst } from '@/stores/graph' import { Ast } from '@/util/ast' +import { Prefixes } from '@/util/ast/prefixes' + +export let prefixes!: ReturnType + +function makePrefixes() { + return Prefixes.FromLines({ + enableRecording: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __', + }) +} + +/** MUST be called after `initializeFFI`. */ +export function initializePrefixes() { + prefixes = makePrefixes() +} export function nodeFromAst(ast: Ast.Ast): NodeDataFromAst | undefined { const { nodeCode, documentation } = @@ -8,12 +23,15 @@ export function nodeFromAst(ast: Ast.Ast): NodeDataFromAst | undefined { : { nodeCode: ast, documentation: undefined } if (!nodeCode) return const pattern = nodeCode instanceof Ast.Assignment ? nodeCode.pattern : undefined - const rootSpan = nodeCode instanceof Ast.Assignment ? nodeCode.expression : nodeCode + const rootExpr = nodeCode instanceof Ast.Assignment ? nodeCode.expression : nodeCode + const { innerExpr, matches } = prefixes.extractMatches(rootExpr) return { outerExprId: ast.id, pattern, - rootSpan, - primarySubject: primaryApplicationSubject(rootSpan), + rootExpr, + innerExpr, + prefixes: matches, + primarySubject: primaryApplicationSubject(innerExpr), documentation, } } diff --git a/app/gui2/src/util/ast/prefixes.ts b/app/gui2/src/util/ast/prefixes.ts index a71b0c6dfae2..6836115542a4 100644 --- a/app/gui2/src/util/ast/prefixes.ts +++ b/app/gui2/src/util/ast/prefixes.ts @@ -5,7 +5,7 @@ import { unsafeKeys } from '@/util/record' type Matches = Record interface MatchResult { - innerExpr: Ast.AstId + innerExpr: Ast.Ast matches: Record } @@ -33,14 +33,14 @@ export class Prefixes> { return [name, matches] }), ) as Matches - return { matches, innerExpr: expression.id } + return { matches, innerExpr: expression } } modify(expression: Ast.Mutable, replacements: Partial>) { expression.updateValue((expression) => { const matches = this.extractMatches(expression) const edit = expression.module - let result = edit.take(matches.innerExpr) + let result = edit.take(matches.innerExpr.id) for (const key of unsafeKeys(this.prefixes).reverse()) { if (key in replacements && !replacements[key]) continue const replacement: Ast.Owned[] | undefined = diff --git a/app/gui2/stories/GraphNode.story.vue b/app/gui2/stories/GraphNode.story.vue index 71fea4b9f682..9072dce22fe2 100644 --- a/app/gui2/stories/GraphNode.story.vue +++ b/app/gui2/stories/GraphNode.story.vue @@ -8,11 +8,9 @@ import { Rect } from '@/util/data/rect' import { Vec2 } from '@/util/data/vec2' import { logEvent } from 'histoire/client' import { computed, reactive, ref, watchEffect } from 'vue' -import { IdMap, type SourceRange } from '../shared/yjsModel' +import { type SourceRange } from '../shared/yjsModel' import { createSetupComponent } from './histoire/utils' -const idMap = new IdMap() - const nodeBinding = ref('binding') const nodeContent = ref('content') const nodeX = ref(0) @@ -31,23 +29,27 @@ function updateContent(updates: [range: SourceRange, content: string][]) { nodeContent.value = content } -const rootSpan = computed(() => Ast.parseTransitional(nodeContent.value, idMap)) -const pattern = computed(() => Ast.parseTransitional(nodeBinding.value, idMap)) +const innerExpr = computed(() => Ast.parse(nodeContent.value)) +const pattern = computed(() => Ast.parse(nodeBinding.value)) const node = computed((): Node => { return { outerExprId: '' as any, pattern: pattern.value, position: position.value, - rootSpan: rootSpan.value, + prefixes: { enableRecording: undefined }, + rootExpr: innerExpr.value, + innerExpr: innerExpr.value, + primarySubject: undefined, vis: undefined, + documentation: undefined, } }) const mockRects = reactive(new Map()) watchEffect((onCleanup) => { - const id = node.value.rootSpan.id + const id = node.value.innerExpr.id mockRects.set(id, Rect.Zero) onCleanup(() => { mockRects.delete(id)