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
This commit is contained in:
somebody1234 2024-03-08 14:31:32 +10:00 committed by GitHub
parent d2f6b1026a
commit 6c2b2383d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 207 additions and 141 deletions

View File

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

View File

@ -677,11 +677,13 @@
</symbol>
<symbol id="record" viewBox="0 0 16 16" width="16" height="16" fill="none">
<path d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15Z" fill="currentColor"></path>
<circle cx="8" cy="8" r="7" fill="currentColor"></circle>
</symbol>
<symbol id="record_once" viewBox="0 0 16 16" width="16" height="16" fill="none">
<path d="M1.33333 2H.66667C.29848 2 0 2.38376 0 2.85714V13.1429C0 13.6162.29848 14 .66667 14H1.33333C1.70152 14 2 13.6162 2 13.1429V2.85714C2 2.38376 1.70152 2 1.33333 2ZM8 13C10.7614 13 13 10.7614 13 8 13 5.23858 10.7614 3 8 3 5.23858 3 3 5.23858 3 8 3 10.7614 5.23858 13 8 13ZM15.3333 2H14.6667C14.2985 2 14 2.38376 14 2.85714V13.1429C14 13.6162 14.2985 14 14.6667 14H15.3333C15.7015 14 16 13.6162 16 13.1429V2.85714C16 2.38376 15.7015 2 15.3333 2Z" fill="currentColor"></path>
<rect x="14" y="3.33334" width="2" height="9.33333" rx="0.666667" fill="currentColor"></rect>
<circle cx="8.00001" cy="8.00001" r="4.66667" fill="currentColor"></circle>
<rect y="3.33334" width="2" height="9.33333" rx="0.666667" fill="currentColor"></rect>
</symbol>
<symbol id="right_side_panel" viewBox="0 0 16 16" width="16" height="16" fill="none">
@ -709,7 +711,7 @@
</symbol>
<symbol id="scissors" viewBox="0 0 16 16" width="16" height="16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.7699 12.8559C14.5352 13.2977 15.5137 13.0355 15.9555 12.2703C16.066 12.0789 16.0005 11.8343 15.8091 11.7238L15.4786 11.533L10.3136 8.55098L8.31356 9.70568L13.4394 12.6651L13.444 12.6677L13.7699 12.8559ZM5.76183 10.0243L4.9631 10.4855C5.07499 10.6039 5.17484 10.7358 5.26034 10.8807C5.96159 12.0689 5.43125 13.6806 4.0758 14.4806C2.72034 15.2805 1.05304 14.9658 0.351788 13.7776C-0.349466 12.5894 0.180871 10.9777 1.53633 10.1777C1.55931 10.1641 1.58239 10.1509 1.60554 10.138L1.57329 10.1332L2.86875 9.38524L5.31365 7.97368L2.82655 6.53775L1.53108 5.7898L1.56334 5.78501C1.54018 5.77209 1.51711 5.75884 1.49412 5.74528C0.138666 4.94531 -0.391672 3.33358 0.309582 2.14538C1.01084 0.957185 2.67813 0.642461 4.03359 1.44243C5.38905 2.2424 5.91938 3.85412 5.21813 5.04232C5.13264 5.18718 5.03279 5.31906 4.92089 5.43751L7.31365 6.81897L13.4816 3.25791L13.4862 3.25527L13.8121 3.0671C14.5774 2.62527 15.5559 2.88747 15.9978 3.65274C16.1082 3.84406 16.0427 4.08869 15.8513 4.19915L15.5208 4.38996L11.9577 6.44716L11.9576 6.44708L5.76178 10.0243L5.76183 10.0243ZM3.92633 4.27992C4.1153 3.95974 4.07828 3.21056 3.27119 2.73423C2.4641 2.2579 1.79035 2.5876 1.60138 2.90778C1.41242 3.22796 1.44943 3.97715 2.25652 4.45348C3.06361 4.92981 3.73736 4.60011 3.92633 4.27992ZM3.96854 11.6431C4.1575 11.9632 4.12049 12.7124 3.3134 13.1888C2.50631 13.6651 1.83255 13.3354 1.64359 13.0152C1.45462 12.695 1.49164 11.9458 2.29873 11.4695C3.10582 10.9932 3.77957 11.3229 3.96854 11.6431Z" fill="currentColor"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.7699 12.8559C14.5352 13.2977 15.5137 13.0355 15.9555 12.2703C16.066 12.0789 16.0004 11.8343 15.8091 11.7238L15.4786 11.533L10.3136 8.55098L8.31355 9.70568L13.4394 12.6651L13.444 12.6677L13.7699 12.8559ZM5.76182 10.0243L4.96309 10.4855C5.07499 10.6039 5.17484 10.7358 5.26033 10.8807C5.96158 12.0689 5.43125 13.6806 4.07579 14.4806C2.72033 15.2805 1.05303 14.9658 0.35178 13.7776C-0.349474 12.5894 0.180864 10.9777 1.53632 10.1777C1.5593 10.1641 1.58238 10.1509 1.60553 10.138L1.57328 10.1332L2.86874 9.38524L5.31364 7.97368L2.82654 6.53775L1.53107 5.7898L1.56333 5.78501C1.54017 5.77208 1.5171 5.75884 1.49412 5.74528C0.138658 4.94531 -0.391679 3.33358 0.309574 2.14538C1.01083 0.957183 2.67812 0.642459 4.03358 1.44243C5.38904 2.24239 5.91938 3.85412 5.21812 5.04232C5.13263 5.18718 5.03278 5.31905 4.92089 5.43751L7.31365 6.81897L13.4816 3.25791L13.4862 3.25527L13.8121 3.0671C14.5774 2.62527 15.5559 2.88747 15.9977 3.65274C16.1082 3.84405 16.0427 4.08869 15.8513 4.19915L15.5208 4.38996L11.9577 6.44716L11.9576 6.44708L5.76177 10.0243L5.76182 10.0243ZM3.92632 4.27992C4.11529 3.95974 4.07827 3.21056 3.27118 2.73423C2.46409 2.2579 1.79034 2.5876 1.60138 2.90778C1.41241 3.22796 1.44942 3.97714 2.25651 4.45348C3.0636 4.92981 3.73736 4.6001 3.92632 4.27992ZM3.96853 11.6431C4.15749 11.9632 4.12048 12.7124 3.31339 13.1888C2.5063 13.6651 1.83255 13.3354 1.64358 13.0152C1.45462 12.695 1.49163 11.9458 2.29872 11.4695C3.10581 10.9932 3.77956 11.3229 3.96853 11.6431Z" fill="currentColor"></path>
</symbol>
<symbol id="select_column" viewBox="0 0 16 16" width="16" height="16" fill="none">

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -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')"
/>
<ToggleIcon
:icon="props.isOutputContextEnabledGlobally ? 'no_auto_replay' : 'auto_replay'"
icon="record"
class="icon-container button slot7"
:class="{ 'output-context-overridden': props.isOutputContextOverridden }"
:alt="`${
props.isOutputContextEnabledGlobally != props.isOutputContextOverridden ?
'Disable'
: 'Enable'
} output context`"
:modelValue="props.isOutputContextOverridden"
@update:modelValue="emit('update:isOutputContextOverridden', $event)"
:class="{ 'recording-overridden': props.isRecordingOverridden }"
:alt="`${props.isRecordingOverridden ? 'Disable' : 'Enable'} recording`"
:modelValue="props.isRecordingOverridden"
@update:modelValue="emit('update:isRecordingOverridden', $event)"
/>
</div>
</template>
@ -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 {

View File

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

View File

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

View File

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

View File

@ -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<string | undefined>({
<div class="binding" @pointerdown.stop>
{{ node.pattern?.code() ?? '' }}
</div>
<button
v-if="!menuVisible && isRecordingOverridden"
class="overrideRecordButton"
@click="isRecordingOverridden = false"
>
<SvgIcon name="record" />
</button>
<CircularMenu
v-if="menuVisible"
v-model:isOutputContextOverridden="isOutputContextOverridden"
v-model:isRecordingOverridden="isRecordingOverridden"
v-model:isDocsVisible="isDocsVisible"
:isOutputContextEnabledGlobally="projectStore.isOutputContextEnabled"
:isRecordingEnabledGlobally="projectStore.isRecordingEnabled"
:isVisualizationVisible="isVisualizationVisible"
:isFullMenuVisible="menuVisible && menuFull"
@update:isVisualizationVisible="emit('update:visualizationVisible', $event)"
@ -423,9 +390,9 @@ const documentation = computed<string | undefined>({
: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<string | undefined>({
@pointerup.stop
>
<NodeWidgetTree
:ast="displayedExpression"
:ast="props.node.innerExpr"
:nodeId="nodeId"
:icon="icon"
:connectedSelfArgumentId="connectedSelfArgumentId"
@ -729,4 +696,19 @@ const documentation = computed<string | undefined>({
.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;
}
</style>

View File

@ -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<string, NodeId>()
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)
}

View File

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

View File

@ -8,7 +8,7 @@ const emit = defineEmits<{ recordOnce: []; 'update:recordMode': [enabled: boolea
<template>
<div class="RecordControl" @pointerdown.stop @pointerup.stop @click.stop>
<div class="control left-end">
<div class="control left-end" @click.stop="() => emit('update:recordMode', !props.recordMode)">
<ToggleIcon
icon="record"
class="button"
@ -17,14 +17,8 @@ const emit = defineEmits<{ recordOnce: []; 'update:recordMode': [enabled: boolea
@update:modelValue="emit('update:recordMode', $event)"
/>
</div>
<div class="control right-end">
<SvgIcon
alt="Record once"
class="button"
name="record_once"
draggable="false"
@click.stop="() => emit('recordOnce')"
/>
<div class="control right-end" @click.stop="() => emit('recordOnce')">
<SvgIcon alt="Record once" class="button" name="record_once" draggable="false" :scale="1.5" />
</div>
</div>
</template>
@ -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 {

View File

@ -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
}>()
</script>
<template>
<svg :style="{ '--width': `${width ?? 16}px`, '--height': `${height ?? 16}px` }">
<svg
:style="{
'--width': `${props.width ?? 16}px`,
'--height': `${props.height ?? 16}px`,
'--scale': props.scale ?? 1,
}"
>
<use :href="props.name.includes(':') ? props.name : `${icons}#${props.name}`"></use>
</svg>
</template>
@ -23,5 +34,7 @@ svg {
min-width: var(--width);
height: var(--height);
min-height: var(--height);
transform: scale(var(--scale));
transform-origin: top left;
}
</style>

View File

@ -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<MouseEvent, 'currentTarget'>(this, 'currentTarget', 'get').mockReturnValue(
options.currentTarget ?? null,
)
this.pointerId = 4
}
}

View File

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

View File

@ -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<ExternalId, AstId>()
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)])

View File

@ -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<any, any>, newMap: Map<any, any>) => {
const updateMap = <K, V>(map: Map<K, V>, newMap: Map<K, V>) => {
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<Node>
/** 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'),
}
}

View File

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

View File

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

View File

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

View File

@ -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<typeof makePrefixes>
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,
}
}

View File

@ -5,7 +5,7 @@ import { unsafeKeys } from '@/util/record'
type Matches<T> = Record<keyof T, Ast.AstId[] | undefined>
interface MatchResult<T> {
innerExpr: Ast.AstId
innerExpr: Ast.Ast
matches: Record<keyof T, Ast.AstId[] | undefined>
}
@ -33,14 +33,14 @@ export class Prefixes<T extends Record<keyof T, Pattern>> {
return [name, matches]
}),
) as Matches<T>
return { matches, innerExpr: expression.id }
return { matches, innerExpr: expression }
}
modify(expression: Ast.Mutable, replacements: Partial<Record<keyof T, Ast.Owned[] | undefined>>) {
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 =

View File

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