Function definition editor widget (#11655)

Fixes #11406

Also refactored the right panel state into its own store, so it is less coupled with the graph editor.

<img width="439" alt="image" src="https://github.com/user-attachments/assets/73e6bb92-235f-497d-9cff-126dc4110f8b">

The function definition widget tree displays the icon, name and all arguments. The name is editable, everything else right now is just read-only. The icons next to the arguments are just placeholders intended to be replaced with a "drag handle" icon.

Also fixed issues with missing rounded corners on the ag-grid widget.
<img width="263" alt="image" src="https://github.com/user-attachments/assets/cb61f62a-755c-4865-ba6c-ab9130167713">
This commit is contained in:
Paweł Grabarz 2024-12-05 19:03:48 +01:00 committed by GitHub
parent 88fdfb452a
commit be1b706d0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 1311 additions and 721 deletions

View File

@ -34,6 +34,8 @@
- [Table Input Widget is now matched for Table.input method instead of - [Table Input Widget is now matched for Table.input method instead of
Table.new. Values must be string literals, and their content is parsed to the Table.new. Values must be string literals, and their content is parsed to the
suitable type][11612]. suitable type][11612].
- [Added dedicated function signature viewer and editor in the right-side
panel][11655].
- [Visualizations on components are slightly transparent when not - [Visualizations on components are slightly transparent when not
focused][11582]. focused][11582].
- [New design for vector-editing widget][11620] - [New design for vector-editing widget][11620]
@ -71,6 +73,8 @@
[11582]: https://github.com/enso-org/enso/pull/11582 [11582]: https://github.com/enso-org/enso/pull/11582
[11597]: https://github.com/enso-org/enso/pull/11597 [11597]: https://github.com/enso-org/enso/pull/11597
[11612]: https://github.com/enso-org/enso/pull/11612 [11612]: https://github.com/enso-org/enso/pull/11612
[11655]: https://github.com/enso-org/enso/pull/11655
[11582]: https://github.com/enso-org/enso/pull/11582
[11620]: https://github.com/enso-org/enso/pull/11620 [11620]: https://github.com/enso-org/enso/pull/11620
[11666]: https://github.com/enso-org/enso/pull/11666 [11666]: https://github.com/enso-org/enso/pull/11666
[11690]: https://github.com/enso-org/enso/pull/11690 [11690]: https://github.com/enso-org/enso/pull/11690

View File

@ -96,9 +96,10 @@ test('Collapsing nodes', async ({ page }) => {
annotations: [], annotations: [],
}) })
const collapsedNode = locate.graphNodeByBinding(page, 'prod') const collapsedNode = locate.graphNodeByBinding(page, 'prod')
await expect(collapsedNode.locator('.WidgetFunctionName')).toExist() await expect(collapsedNode.locator('.WidgetApplication.prefix > .WidgetPort')).toExist()
await expect(collapsedNode.locator('.WidgetFunctionName .WidgetToken')).toHaveText(['Main', '.']) await expect(collapsedNode.locator('.WidgetApplication.prefix > .WidgetPort')).toHaveText(
await expect(collapsedNode.locator('.WidgetFunctionName input')).toHaveValue('collapsed') 'Main.collapsed',
)
await expect(collapsedNode.locator('.WidgetTopLevelArgument')).toHaveText('five') await expect(collapsedNode.locator('.WidgetTopLevelArgument')).toHaveText('five')
await locate.graphNodeIcon(collapsedNode).dblclick() await locate.graphNodeIcon(collapsedNode).dblclick()

View File

@ -17,7 +17,7 @@ import { useEventListener } from '@vueuse/core'
import type Backend from 'enso-common/src/services/Backend' import type Backend from 'enso-common/src/services/Backend'
import { computed, markRaw, toRaw, toRef, watch } from 'vue' import { computed, markRaw, toRaw, toRef, watch } from 'vue'
import TooltipDisplayer from './components/TooltipDisplayer.vue' import TooltipDisplayer from './components/TooltipDisplayer.vue'
import { provideTooltipRegistry } from './providers/tooltipState' import { provideTooltipRegistry } from './providers/tooltipRegistry'
import { provideVisibility } from './providers/visibility' import { provideVisibility } from './providers/visibility'
import { urlParams } from './util/urlParams' import { urlParams } from './util/urlParams'

View File

@ -55,6 +55,34 @@
--node-border-radius: calc(var(--node-base-height) / 2); --node-border-radius: calc(var(--node-base-height) / 2);
--node-port-border-radius: calc(var(--node-port-height) / 2); --node-port-border-radius: calc(var(--node-port-height) / 2);
/** Space between node and component above and below, such as comments and errors. */
--node-vertical-gap: 5px;
--group-color-fallback: #006b8a;
}
/**
* Class used on containers that need access to computed node color variables.
* Expects variable `--node-group-color` to be defined on the element to which this class is applied.
*/
.define-node-colors {
--color-node-text: white;
--color-node-background: var(--node-group-color);
--color-node-primary: var(--node-group-color);
--color-node-edge: color-mix(in oklab, var(--node-group-color) 85%, white 15%);
--color-node-port: var(--color-node-edge);
--color-node-error: color-mix(in oklab, var(--node-group-color) 30%, rgb(255, 0, 0) 70%);
--color-node-pending: color-mix(in oklab, var(--node-group-color) 60%, #aaa 40%);
&.pending {
--color-node-primary: var(--color-node-pending);
}
&.selected {
--color-node-background: color-mix(in oklab, var(--color-node-primary) 30%, white 70%);
--color-node-port: color-mix(in oklab, var(--color-node-background) 40%, white 60%);
--color-node-text: color-mix(in oklab, var(--color-node-primary) 70%, black 30%);
}
} }
/********************************* /*********************************

View File

@ -65,7 +65,7 @@ const emit = defineEmits<{
firstAppliedReturnType: Typename | undefined, firstAppliedReturnType: Typename | undefined,
] ]
canceled: [] canceled: []
selectedSuggestionId: [id: SuggestionId | null] selectedSuggestionId: [id: SuggestionId | undefined]
isAiPrompt: [boolean] isAiPrompt: [boolean]
}>() }>()
@ -296,7 +296,7 @@ const isVisualizationVisible = ref(true)
// === Documentation Panel === // === Documentation Panel ===
watch(selectedSuggestionId, (id) => emit('selectedSuggestionId', id ?? null)) watch(selectedSuggestionId, (id) => emit('selectedSuggestionId', id))
watch( watch(
() => input.mode, () => input.mode,
(mode) => emit('isAiPrompt', mode.mode === 'aiPrompt'), (mode) => emit('isAiPrompt', mode.mode === 'aiPrompt'),

View File

@ -49,7 +49,7 @@ const rootStyle = computed(() => {
</script> </script>
<template> <template>
<div class="ComponentEditor" :style="rootStyle"> <div class="ComponentEditor define-node-colors" :style="rootStyle">
<div v-if="props.icon" class="iconPort"> <div v-if="props.icon" class="iconPort">
<SvgIcon :name="props.icon" class="nodeIcon" /> <SvgIcon :name="props.icon" class="nodeIcon" />
</div> </div>
@ -72,7 +72,6 @@ const rootStyle = computed(() => {
<style scoped> <style scoped>
.ComponentEditor { .ComponentEditor {
--node-color-port: color-mix(in oklab, var(--color-node-primary) 85%, white 15%);
--port-padding: 6px; --port-padding: 6px;
--icon-height: 16px; --icon-height: 16px;
--icon-text-gap: 6px; --icon-text-gap: 6px;
@ -101,7 +100,7 @@ const rootStyle = computed(() => {
border-radius: var(--radius-full); border-radius: var(--radius-full);
padding: var(--port-padding); padding: var(--port-padding);
margin: 0 var(--icon-text-gap) 0 calc(0px - var(--port-padding)); margin: 0 var(--icon-text-gap) 0 calc(0px - var(--port-padding));
background-color: var(--node-color-port); background-color: var(--color-node-port);
isolation: isolate; isolation: isolate;
} }

View File

@ -7,7 +7,9 @@ import type { SuggestionId } from 'ydoc-shared/languageServerTypes/suggestions'
import { Err, Ok, unwrapOr } from 'ydoc-shared/util/data/result' import { Err, Ok, unwrapOr } from 'ydoc-shared/util/data/result'
// A displayed component can be overridren by this model, e.g. when the user clicks links in the documenation. // A displayed component can be overridren by this model, e.g. when the user clicks links in the documenation.
const overrideDisplayed = defineModel<SuggestionId | null>({ default: null }) const overrideDisplayed = defineModel<SuggestionId | undefined>()
const props = defineProps<{ aiMode?: boolean }>()
const selection = injectGraphSelection() const selection = injectGraphSelection()
const graphStore = useGraphStore() const graphStore = useGraphStore()
@ -21,7 +23,7 @@ function docsForSelection() {
const docs = computed(() => docsForSelection()) const docs = computed(() => docsForSelection())
// When the selection changes, we cancel the displayed suggestion override that can be in place. // When the selection changes, we cancel the displayed suggestion override that can be in place.
watch(docs, (_) => (overrideDisplayed.value = null)) watch(docs, (_) => (overrideDisplayed.value = undefined))
const displayedId = computed(() => overrideDisplayed.value ?? unwrapOr(docs.value, null)) const displayedId = computed(() => overrideDisplayed.value ?? unwrapOr(docs.value, null))
</script> </script>
@ -30,6 +32,7 @@ const displayedId = computed(() => overrideDisplayed.value ?? unwrapOr(docs.valu
<DocumentationPanel <DocumentationPanel
v-if="displayedId" v-if="displayedId"
:selectedEntry="displayedId" :selectedEntry="displayedId"
:aiMode="props.aiMode"
@update:selectedEntry="overrideDisplayed = $event" @update:selectedEntry="overrideDisplayed = $event"
/> />
<div v-else-if="!displayedId && !docs.ok" class="help-placeholder">{{ docs.error.payload }}.</div> <div v-else-if="!displayedId && !docs.ok" class="help-placeholder">{{ docs.error.payload }}.</div>

View File

@ -1,4 +1,4 @@
<script setup lang="ts"> <script setup lang="ts" generic="Tab extends string">
import { documentationEditorBindings } from '@/bindings' import { documentationEditorBindings } from '@/bindings'
import ResizeHandles from '@/components/ResizeHandles.vue' import ResizeHandles from '@/components/ResizeHandles.vue'
import SizeTransition from '@/components/SizeTransition.vue' import SizeTransition from '@/components/SizeTransition.vue'
@ -6,6 +6,7 @@ import ToggleIcon from '@/components/ToggleIcon.vue'
import { useResizeObserver } from '@/composables/events' import { useResizeObserver } from '@/composables/events'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { TabButton } from '@/util/tabs'
import { tabClipPath } from 'enso-common/src/utilities/style/tabBar' import { tabClipPath } from 'enso-common/src/utilities/style/tabBar'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@ -13,13 +14,13 @@ const TAB_EDGE_MARGIN_PX = 4
const TAB_SIZE_PX = { width: 48 - TAB_EDGE_MARGIN_PX, height: 48 } const TAB_SIZE_PX = { width: 48 - TAB_EDGE_MARGIN_PX, height: 48 }
const TAB_RADIUS_PX = 8 const TAB_RADIUS_PX = 8
type Tab = 'docs' | 'help'
const show = defineModel<boolean>('show', { required: true }) const show = defineModel<boolean>('show', { required: true })
const size = defineModel<number | undefined>('size') const size = defineModel<number | undefined>('size')
const tab = defineModel<Tab>('tab') const currentTab = defineModel<Tab>('tab')
const _props = defineProps<{
const props = defineProps<{
contentFullscreen: boolean contentFullscreen: boolean
tabButtons: TabButton<Tab>[]
}>() }>()
const slideInPanel = ref<HTMLElement>() const slideInPanel = ref<HTMLElement>()
@ -53,30 +54,26 @@ const tabStyle = {
:title="`Documentation Panel (${documentationEditorBindings.bindings.toggle.humanReadable})`" :title="`Documentation Panel (${documentationEditorBindings.bindings.toggle.humanReadable})`"
icon="right_panel" icon="right_panel"
class="toggleDock" class="toggleDock"
:class="{ aboveFullscreen: contentFullscreen }" :class="{ aboveFullscreen: props.contentFullscreen }"
/> />
<SizeTransition width :duration="100"> <SizeTransition width :duration="100">
<div v-if="show" ref="slideInPanel" :style="style" class="panelOuter" data-testid="rightDock"> <div v-if="show" ref="slideInPanel" :style="style" class="panelOuter" data-testid="rightDock">
<div class="panelInner"> <div class="panelInner">
<div class="content"> <div class="content">
<slot v-if="tab == 'docs'" name="docs" /> <slot :name="`tab-${currentTab}`" />
<slot v-else-if="tab == 'help'" name="help" />
</div> </div>
<div class="tabBar"> <div class="tabBar">
<div class="tab" :style="tabStyle"> <div
v-for="{ tab, title, icon } in props.tabButtons"
:key="tab"
class="tab"
:style="tabStyle"
>
<ToggleIcon <ToggleIcon
:modelValue="tab == 'docs'" :modelValue="currentTab == tab"
title="Documentation Editor" :title="title"
icon="text" :icon="icon"
@update:modelValue="tab = 'docs'" @update:modelValue="currentTab = tab"
/>
</div>
<div class="tab" :style="tabStyle">
<ToggleIcon
:modelValue="tab == 'help'"
title="Component Help"
icon="help"
@update:modelValue="tab = 'help'"
/> />
</div> </div>
</div> </div>

View File

@ -65,6 +65,7 @@ const handler = documentationEditorBindings.handler({
<div ref="toolbarElement" class="toolbar"> <div ref="toolbarElement" class="toolbar">
<FullscreenButton v-model="fullscreen" /> <FullscreenButton v-model="fullscreen" />
</div> </div>
<slot name="belowToolbar" />
<div <div
class="scrollArea" class="scrollArea"
@keydown="handler" @keydown="handler"

View File

@ -21,8 +21,8 @@ import type { QualifiedName } from '@/util/qualifiedName'
import { qnSegments, qnSlice } from '@/util/qualifiedName' import { qnSegments, qnSlice } from '@/util/qualifiedName'
import { computed, watch } from 'vue' import { computed, watch } from 'vue'
const props = defineProps<{ selectedEntry: SuggestionId | null; aiMode?: boolean }>() const props = defineProps<{ selectedEntry: SuggestionId | undefined; aiMode?: boolean }>()
const emit = defineEmits<{ 'update:selectedEntry': [value: SuggestionId | null] }>() const emit = defineEmits<{ 'update:selectedEntry': [value: SuggestionId | undefined] }>()
const db = useSuggestionDbStore() const db = useSuggestionDbStore()
const documentation = computed<Docs>(() => { const documentation = computed<Docs>(() => {

View File

@ -0,0 +1,103 @@
<script setup lang="ts">
import { WidgetInput } from '@/providers/widgetRegistry'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { documentationData } from '@/stores/suggestionDatabase/documentation'
import { colorFromString } from '@/util/colors'
import { isQualifiedName } from '@/util/qualifiedName'
import { computed, ref, watchEffect } from 'vue'
import { FunctionDef } from 'ydoc-shared/ast'
import { MethodPointer } from 'ydoc-shared/languageServerTypes'
import type * as Y from 'yjs'
import WidgetTreeRoot from './GraphEditor/WidgetTreeRoot.vue'
import { FunctionInfoKey } from './GraphEditor/widgets/WidgetFunctionDef.vue'
const suggestionDb = useSuggestionDbStore()
const { functionAst, markdownDocs, methodPointer } = defineProps<{
functionAst: FunctionDef
markdownDocs: Y.Text | undefined
methodPointer: MethodPointer | undefined
}>()
const docsString = ref<string>()
function updateDocs() {
docsString.value = markdownDocs?.toJSON()
}
watchEffect((onCleanup) => {
const localMarkdownDocs = markdownDocs
if (localMarkdownDocs != null) {
updateDocs()
localMarkdownDocs.observe(updateDocs)
onCleanup(() => localMarkdownDocs.unobserve(updateDocs))
}
})
const docsData = computed(() => {
const definedIn = methodPointer?.module
return definedIn && isQualifiedName(definedIn) ?
documentationData(docsString.value, definedIn, suggestionDb.groups)
: undefined
})
const treeRootInput = computed((): WidgetInput => {
const input = WidgetInput.FromAst(functionAst)
if (methodPointer) input[FunctionInfoKey] = { methodPointer, docsData }
return input
})
const rootElement = ref<HTMLElement>()
function handleWidgetUpdates() {
return true
}
const groupBasedColor = computed(() => {
const groupIndex = docsData.value?.groupIndex
return groupIndex != null ? suggestionDb.groups[groupIndex]?.color : undefined
})
const returnTypeBasedColor = computed(() => {
const suggestionId =
methodPointer ? suggestionDb.entries.findByMethodPointer(methodPointer) : undefined
const returnType = suggestionId ? suggestionDb.entries.get(suggestionId)?.returnType : undefined
return returnType ? colorFromString(returnType) : undefined
})
const rootStyle = computed(() => {
return {
'--node-group-color':
groupBasedColor.value ?? returnTypeBasedColor.value ?? 'var(--group-color-fallback)',
}
})
</script>
<template>
<div ref="rootElement" :style="rootStyle" class="FunctionSignatureEditor define-node-colors">
<WidgetTreeRoot
:externalId="functionAst.externalId"
:input="treeRootInput"
:rootElement="rootElement"
:extended="true"
:onUpdate="handleWidgetUpdates"
/>
</div>
</template>
<style scoped>
.FunctionSignatureEditor {
margin: 4px 8px;
padding: 4px;
/*
* TODO: Add node coloring.
* Function color cannot be inferred at the moment, as it depends on the output type.
*/
border-radius: var(--node-border-radius);
transition: background-color 0.2s ease;
background-color: var(--color-node-background);
box-sizing: border-box;
}
</style>

View File

@ -11,8 +11,6 @@ import CodeEditor from '@/components/CodeEditor.vue'
import ComponentBrowser from '@/components/ComponentBrowser.vue' import ComponentBrowser from '@/components/ComponentBrowser.vue'
import type { Usage } from '@/components/ComponentBrowser/input' import type { Usage } from '@/components/ComponentBrowser/input'
import { usePlacement } from '@/components/ComponentBrowser/placement' import { usePlacement } from '@/components/ComponentBrowser/placement'
import ComponentDocumentation from '@/components/ComponentDocumentation.vue'
import DockPanel from '@/components/DockPanel.vue'
import DocumentationEditor from '@/components/DocumentationEditor.vue' import DocumentationEditor from '@/components/DocumentationEditor.vue'
import GraphEdges from '@/components/GraphEditor/GraphEdges.vue' import GraphEdges from '@/components/GraphEditor/GraphEdges.vue'
import GraphNodes from '@/components/GraphEditor/GraphNodes.vue' import GraphNodes from '@/components/GraphEditor/GraphNodes.vue'
@ -31,7 +29,6 @@ import { useDoubleClick } from '@/composables/doubleClick'
import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/composables/events' import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/composables/events'
import { groupColorVar } from '@/composables/nodeColors' import { groupColorVar } from '@/composables/nodeColors'
import type { PlacementStrategy } from '@/composables/nodeCreation' import type { PlacementStrategy } from '@/composables/nodeCreation'
import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
import { provideGraphEditorLayers } from '@/providers/graphEditorLayers' import { provideGraphEditorLayers } from '@/providers/graphEditorLayers'
import type { GraphNavigator } from '@/providers/graphNavigator' import type { GraphNavigator } from '@/providers/graphNavigator'
import { provideGraphNavigator } from '@/providers/graphNavigator' import { provideGraphNavigator } from '@/providers/graphNavigator'
@ -42,15 +39,15 @@ import { provideStackNavigator } from '@/providers/graphStackNavigator'
import { provideInteractionHandler } from '@/providers/interactionHandler' import { provideInteractionHandler } from '@/providers/interactionHandler'
import { provideKeyboard } from '@/providers/keyboard' import { provideKeyboard } from '@/providers/keyboard'
import { provideSelectionButtons } from '@/providers/selectionButtons' import { provideSelectionButtons } from '@/providers/selectionButtons'
import { injectVisibility } from '@/providers/visibility'
import { provideWidgetRegistry } from '@/providers/widgetRegistry' import { provideWidgetRegistry } from '@/providers/widgetRegistry'
import type { Node, NodeId } from '@/stores/graph' import type { Node, NodeId } from '@/stores/graph'
import { provideGraphStore } from '@/stores/graph' import { provideGraphStore } from '@/stores/graph'
import { isInputNode, nodeId } from '@/stores/graph/graphDatabase' import { isInputNode, nodeId } from '@/stores/graph/graphDatabase'
import type { RequiredImport } from '@/stores/graph/imports' import type { RequiredImport } from '@/stores/graph/imports'
import { providePersisted } from '@/stores/persisted'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import { provideNodeExecution } from '@/stores/project/nodeExecution' import { provideNodeExecution } from '@/stores/project/nodeExecution'
import { useSettings } from '@/stores/settings' import { provideRightDock, StorageMode } from '@/stores/rightDock'
import { provideSuggestionDbStore } from '@/stores/suggestionDatabase' import { provideSuggestionDbStore } from '@/stores/suggestionDatabase'
import type { SuggestionId, Typename } from '@/stores/suggestionDatabase/entry' import type { SuggestionId, Typename } from '@/stores/suggestionDatabase/entry'
import { suggestionDocumentationUrl } from '@/stores/suggestionDatabase/entry' import { suggestionDocumentationUrl } from '@/stores/suggestionDatabase/entry'
@ -60,12 +57,10 @@ import { Ast } from '@/util/ast'
import { colorFromString } from '@/util/colors' import { colorFromString } from '@/util/colors'
import { partition } from '@/util/data/array' import { partition } from '@/util/data/array'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { Err, Ok } from '@/util/data/result' import { Err, Ok, unwrapOr } from '@/util/data/result'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { computedFallback, useSelectRef } from '@/util/reactivity'
import { until } from '@vueuse/core'
import * as iter from 'enso-common/src/utilities/data/iter' import * as iter from 'enso-common/src/utilities/data/iter'
import { encoding, set } from 'lib0' import { set } from 'lib0'
import { import {
computed, computed,
onMounted, onMounted,
@ -77,10 +72,8 @@ import {
watch, watch,
type ComponentInstance, type ComponentInstance,
} from 'vue' } from 'vue'
import { encodeMethodPointer } from 'ydoc-shared/languageServerTypes'
import { isDevMode } from 'ydoc-shared/util/detect' import { isDevMode } from 'ydoc-shared/util/detect'
import RightDockPanel from './RightDockPanel.vue'
const rootNode = ref<HTMLElement>()
const keyboard = provideKeyboard() const keyboard = provideKeyboard()
const projectStore = useProjectStore() const projectStore = useProjectStore()
@ -88,7 +81,7 @@ const suggestionDb = provideSuggestionDbStore(projectStore)
const graphStore = provideGraphStore(projectStore, suggestionDb) const graphStore = provideGraphStore(projectStore, suggestionDb)
const widgetRegistry = provideWidgetRegistry(graphStore.db) const widgetRegistry = provideWidgetRegistry(graphStore.db)
const _visualizationStore = provideVisualizationStore(projectStore) const _visualizationStore = provideVisualizationStore(projectStore)
const visible = injectVisibility()
provideNodeExecution(projectStore) provideNodeExecution(projectStore)
;(window as any)._mockSuggestion = suggestionDb.mockSuggestion ;(window as any)._mockSuggestion = suggestionDb.mockSuggestion
@ -112,6 +105,7 @@ const graphNavigator: GraphNavigator = provideGraphNavigator(viewportNode, keybo
// === Exposed layers === // === Exposed layers ===
const rootNode = ref<HTMLElement>()
const floatingLayer = ref<HTMLElement>() const floatingLayer = ref<HTMLElement>()
provideGraphEditorLayers({ provideGraphEditorLayers({
fullscreen: rootNode, fullscreen: rootNode,
@ -120,75 +114,16 @@ provideGraphEditorLayers({
// === Client saved state === // === Client saved state ===
const storedShowRightDock = ref() const persisted = providePersisted(
const storedRightDockTab = ref() () => projectStore.id,
const rightDockWidth = ref<number>() graphStore,
graphNavigator,
() => zoomToAll(true),
)
/** const rightDock = provideRightDock(graphStore, persisted)
* JSON serializable representation of graph state saved in localStorage. The names of fields here
* are kept relatively short, because it will be common to store hundreds of them within one big
* JSON object, and serialize it quite often whenever the state is modified. Shorter keys end up
* costing less localStorage space and slightly reduce serialization overhead.
*/
interface GraphStoredState {
/** Navigator position X */
x: number
/** Navigator position Y */
y: number
/** Navigator scale */
s: number
/** Whether or not the documentation panel is open. */
doc: boolean
/** The selected tab in the right-side panel. */
rtab: string
/** Width of the right dock. */
rwidth: number | null
}
const visibleAreasReady = computed(() => { // === Zoom/pan ===
const nodesCount = graphStore.db.nodeIdToNode.size
const visibleNodeAreas = graphStore.visibleNodeAreas
return nodesCount > 0 && visibleNodeAreas.length == nodesCount
})
const { user: userSettings } = useSettings()
useSyncLocalStorage<GraphStoredState>({
storageKey: 'enso-graph-state',
mapKeyEncoder: (enc) => {
// Client graph state needs to be stored separately for:
// - each project
// - each function within the project
encoding.writeVarString(enc, projectStore.id)
const methodPtr = graphStore.currentMethodPointer()
if (methodPtr != null) encodeMethodPointer(enc, methodPtr)
},
debounce: 200,
captureState() {
return {
x: graphNavigator.targetCenter.x,
y: graphNavigator.targetCenter.y,
s: graphNavigator.targetScale,
doc: storedShowRightDock.value,
rtab: storedRightDockTab.value,
rwidth: rightDockWidth.value ?? null,
}
},
async restoreState(restored, abort) {
if (restored) {
const pos = new Vec2(restored.x ?? 0, restored.y ?? 0)
const scale = restored.s ?? 1
graphNavigator.setCenterAndScale(pos, scale)
storedShowRightDock.value = restored.doc ?? undefined
storedRightDockTab.value = restored.rtab ?? undefined
rightDockWidth.value = restored.rwidth ?? undefined
} else {
await until(visibleAreasReady).toBe(true)
await until(visible).toBe(true)
if (!abort.aborted) zoomToAll(true)
}
},
})
function nodesBounds(nodeIds: Iterable<NodeId>) { function nodesBounds(nodeIds: Iterable<NodeId>) {
let bounds = Rect.Bounding() let bounds = Rect.Bounding()
@ -439,32 +374,18 @@ const codeEditorHandler = codeEditorBindings.handler({
// === Documentation Editor === // === Documentation Editor ===
const displayedDocs = ref<SuggestionId | null>(null) const displayedDocs = ref<SuggestionId>()
const aiMode = ref<boolean>(false) const aiMode = ref<boolean>(false)
function toggleRightDockHelpPanel() {
rightDock.toggleVisible('help')
}
const docEditor = shallowRef<ComponentInstance<typeof DocumentationEditor>>() const docEditor = shallowRef<ComponentInstance<typeof DocumentationEditor>>()
const documentationEditorArea = computed(() => unrefElement(docEditor)) const documentationEditorArea = computed(() => unrefElement(docEditor))
const showRightDock = computedFallback(
storedShowRightDock,
// Show documentation editor when documentation exists on first graph visit.
() => (markdownDocs.value?.length ?? 0) > 0,
)
const rightDockTab = computedFallback(storedRightDockTab, () => 'docs')
/* Separate Dock Panel state when Component Browser is opened. */
const rightDockTabForCB = ref('help')
const rightDockVisibleForCB = ref(true)
const documentationEditorHandler = documentationEditorBindings.handler({ const documentationEditorHandler = documentationEditorBindings.handler({
toggle() { toggle: () => rightDock.toggleVisible(),
rightDockVisible.value = !rightDockVisible.value
},
})
const markdownDocs = computed(() => {
const currentMethod = graphStore.methodAst
if (!currentMethod.ok) return
return currentMethod.value.mutableDocumentationMarkdown()
}) })
// === Component Browser === // === Component Browser ===
@ -473,6 +394,10 @@ const componentBrowserVisible = ref(false)
const componentBrowserNodePosition = ref<Vec2>(Vec2.Zero) const componentBrowserNodePosition = ref<Vec2>(Vec2.Zero)
const componentBrowserUsage = ref<Usage>({ type: 'newNode' }) const componentBrowserUsage = ref<Usage>({ type: 'newNode' })
watch(componentBrowserVisible, (v) =>
rightDock.setStorageMode(v ? StorageMode.ComponentBrowser : StorageMode.Default),
)
function openComponentBrowser(usage: Usage, position: Vec2) { function openComponentBrowser(usage: Usage, position: Vec2) {
componentBrowserUsage.value = usage componentBrowserUsage.value = usage
componentBrowserNodePosition.value = position componentBrowserNodePosition.value = position
@ -482,47 +407,7 @@ function openComponentBrowser(usage: Usage, position: Vec2) {
function hideComponentBrowser() { function hideComponentBrowser() {
graphStore.editedNodeInfo = undefined graphStore.editedNodeInfo = undefined
componentBrowserVisible.value = false componentBrowserVisible.value = false
displayedDocs.value = null displayedDocs.value = undefined
}
const rightDockDisplayedTab = useSelectRef(
componentBrowserVisible,
computed({
get() {
if (userSettings.value.showHelpForCB) {
return 'help'
} else {
return showRightDock.value ? rightDockTab.value : rightDockTabForCB.value
}
},
set(tab) {
rightDockTabForCB.value = tab
userSettings.value.showHelpForCB = tab === 'help'
if (showRightDock.value) rightDockTab.value = tab
},
}),
rightDockTab,
)
const rightDockVisible = useSelectRef(
componentBrowserVisible,
computed({
get() {
return userSettings.value.showHelpForCB || rightDockVisibleForCB.value || showRightDock.value
},
set(vis) {
rightDockVisibleForCB.value = vis
userSettings.value.showHelpForCB = vis
if (!vis) showRightDock.value = false
},
}),
showRightDock,
)
/** Show help panel if it is not visible. If it is visible, close the right dock. */
function toggleRightDockHelpPanel() {
rightDockVisible.value = !rightDockVisible.value || rightDockDisplayedTab.value !== 'help'
rightDockDisplayedTab.value = 'help'
} }
function editWithComponentBrowser(node: NodeId, cursorPos: number) { function editWithComponentBrowser(node: NodeId, cursorPos: number) {
@ -654,10 +539,9 @@ function collapseNodes(nodes: Node[]) {
toasts.userActionFailed.show(`Unable to group nodes: ${info.error.payload}.`) toasts.userActionFailed.show(`Unable to group nodes: ${info.error.payload}.`)
return return
} }
const currentMethod = projectStore.executionContext.getStackTop() const currentMethodName = unwrapOr(graphStore.currentMethodPointer, undefined)?.name
const currentMethodName = graphStore.db.stackItemToMethodName(currentMethod)
if (currentMethodName == null) { if (currentMethodName == null) {
bail(`Cannot get the method name for the current execution stack item. ${currentMethod}`) bail(`Cannot get the method name for the current execution stack item.`)
} }
const topLevel = graphStore.moduleRoot const topLevel = graphStore.moduleRoot
if (!topLevel) { if (!topLevel) {
@ -735,8 +619,6 @@ const groupColors = computed(() => {
} }
return styles return styles
}) })
const documentationEditorFullscreen = ref(false)
</script> </script>
<template> <template>
@ -776,12 +658,13 @@ const documentationEditorFullscreen = ref(false)
<TopBar <TopBar
v-model:recordMode="projectStore.recordMode" v-model:recordMode="projectStore.recordMode"
v-model:showCodeEditor="showCodeEditor" v-model:showCodeEditor="showCodeEditor"
v-model:showDocumentationEditor="rightDockVisible" :showDocumentationEditor="rightDock.visible"
:zoomLevel="100.0 * graphNavigator.targetScale" :zoomLevel="100.0 * graphNavigator.targetScale"
:class="{ extraRightSpace: !rightDockVisible }" :class="{ extraRightSpace: !rightDock.visible }"
@fitToAllClicked="zoomToSelected" @fitToAllClicked="zoomToSelected"
@zoomIn="graphNavigator.stepZoom(+1)" @zoomIn="graphNavigator.stepZoom(+1)"
@zoomOut="graphNavigator.stepZoom(-1)" @zoomOut="graphNavigator.stepZoom(-1)"
@update:showDocumentationEditor="rightDock.setVisible"
/> />
<SceneScroller <SceneScroller
:navigator="graphNavigator" :navigator="graphNavigator"
@ -800,25 +683,7 @@ const documentationEditorFullscreen = ref(false)
</Suspense> </Suspense>
</BottomPanel> </BottomPanel>
</div> </div>
<DockPanel <RightDockPanel ref="docPanel" v-model:displayedDocs="displayedDocs" :aiMode="aiMode" />
ref="docPanel"
v-model:show="rightDockVisible"
v-model:size="rightDockWidth"
v-model:tab="rightDockDisplayedTab"
:contentFullscreen="documentationEditorFullscreen"
>
<template #docs>
<DocumentationEditor
v-if="markdownDocs"
ref="docEditor"
:yText="markdownDocs"
@update:fullscreen="documentationEditorFullscreen = $event"
/>
</template>
<template #help>
<ComponentDocumentation v-model="displayedDocs" :aiMode="aiMode" />
</template>
</DockPanel>
</div> </div>
</template> </template>
@ -862,7 +727,6 @@ const documentationEditorFullscreen = ref(false)
contain: layout; contain: layout;
overflow: clip; overflow: clip;
touch-action: none; touch-action: none;
--group-color-fallback: #006b8a;
--node-color-no-type: #596b81; --node-color-no-type: #596b81;
--output-node-color: #006b8a; --output-node-color: #006b8a;
} }

View File

@ -0,0 +1,102 @@
<script setup lang="ts">
import { injectGraphSelection } from '@/providers/graphSelection'
import { WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
import { WidgetEditHandlerParent } from '@/providers/widgetRegistry/editHandler'
import { useGraphStore, type NodeId } from '@/stores/graph'
import type { NodeType } from '@/stores/graph/graphDatabase'
import { Ast } from '@/util/ast'
import { iconOfNode } from '@/util/getIconName'
import { computed } from 'vue'
import { DisplayIcon } from './widgets/WidgetIcon.vue'
import WidgetTreeRoot from './WidgetTreeRoot.vue'
const props = defineProps<{
ast: Ast.Expression
nodeId: NodeId
rootElement: HTMLElement | undefined
nodeType: NodeType
potentialSelfArgumentId: Ast.AstId | undefined
/** Ports that are not targetable by default; see {@link NodeDataFromAst}. */
conditionalPorts: Set<Ast.AstId>
extended: boolean
}>()
const graph = useGraphStore()
const rootPort = computed(() => {
const input = WidgetInput.FromAst(props.ast)
if (
props.ast instanceof Ast.Ident &&
(!graph.db.isKnownFunctionCall(props.ast.id) || graph.db.connections.hasValue(props.ast.id))
) {
input.forcePort = true
}
if (!props.potentialSelfArgumentId && topLevelIcon.value) {
input[DisplayIcon] = {
icon: topLevelIcon.value,
showContents: props.nodeType != 'output',
}
}
return input
})
const selection = injectGraphSelection()
function selectNode() {
selection.setSelection(new Set([props.nodeId]))
}
function handleWidgetUpdates(update: WidgetUpdate) {
selectNode()
const edit = update.edit ?? graph.startEdit()
if (update.portUpdate) {
const { origin } = update.portUpdate
if (Ast.isAstId(origin)) {
if ('value' in update.portUpdate) {
const value = update.portUpdate.value
const ast =
value instanceof Ast.Ast ? value
: value == null ? Ast.Wildcard.new(edit)
: undefined
if (ast) {
edit.replaceValue(origin, ast)
} else if (typeof value === 'string') {
edit.tryGet(origin)?.syncToCode(value)
}
}
if ('metadata' in update.portUpdate) {
const { metadataKey, metadata } = update.portUpdate
edit.tryGet(origin)?.setWidgetMetadata(metadataKey, metadata)
}
} else {
console.error(`[UPDATE ${origin}] Invalid top-level origin. Expected expression ID.`)
}
}
graph.commitEdit(edit)
// This handler is guaranteed to be the last handler in the chain.
return true
}
const topLevelIcon = computed(() => iconOfNode(props.nodeId, graph.db))
function onCurrentEditChange(currentEdit: WidgetEditHandlerParent | undefined) {
if (currentEdit) selectNode()
}
</script>
<script lang="ts">
export const GRAB_HANDLE_X_MARGIN_L = 4
export const GRAB_HANDLE_X_MARGIN_R = 8
export const ICON_WIDTH = 16
</script>
<template>
<WidgetTreeRoot
class="ComponentWidgetTree"
:externalId="nodeId"
:potentialSelfArgumentId="potentialSelfArgumentId"
:input="rootPort"
:rootElement="rootElement"
:conditionalPorts="conditionalPorts"
:extended="extended"
:onUpdate="handleWidgetUpdates"
@currentEditChanged="onCurrentEditChange"
/>
</template>

View File

@ -2,6 +2,11 @@
import { graphBindings, nodeEditBindings } from '@/bindings' import { graphBindings, nodeEditBindings } from '@/bindings'
import ComponentContextMenu from '@/components/ComponentContextMenu.vue' import ComponentContextMenu from '@/components/ComponentContextMenu.vue'
import ComponentMenu from '@/components/ComponentMenu.vue' import ComponentMenu from '@/components/ComponentMenu.vue'
import ComponentWidgetTree, {
GRAB_HANDLE_X_MARGIN_L,
GRAB_HANDLE_X_MARGIN_R,
ICON_WIDTH,
} from '@/components/GraphEditor/ComponentWidgetTree.vue'
import GraphNodeComment from '@/components/GraphEditor/GraphNodeComment.vue' import GraphNodeComment from '@/components/GraphEditor/GraphNodeComment.vue'
import GraphNodeMessage, { import GraphNodeMessage, {
colorForMessageType, colorForMessageType,
@ -11,11 +16,6 @@ import GraphNodeMessage, {
import GraphNodeOutputPorts from '@/components/GraphEditor/GraphNodeOutputPorts.vue' import GraphNodeOutputPorts from '@/components/GraphEditor/GraphNodeOutputPorts.vue'
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue' import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation' import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
import NodeWidgetTree, {
GRAB_HANDLE_X_MARGIN_L,
GRAB_HANDLE_X_MARGIN_R,
ICON_WIDTH,
} from '@/components/GraphEditor/NodeWidgetTree.vue'
import PointFloatingMenu from '@/components/PointFloatingMenu.vue' import PointFloatingMenu from '@/components/PointFloatingMenu.vue'
import SmallPlusButton from '@/components/SmallPlusButton.vue' import SmallPlusButton from '@/components/SmallPlusButton.vue'
import SvgIcon from '@/components/SvgIcon.vue' import SvgIcon from '@/components/SvgIcon.vue'
@ -318,53 +318,13 @@ const nodeEditHandler = nodeEditBindings.handler({
e.target.blur() e.target.blur()
} }
}, },
edit(e) { edit() {
const pos = 'clientX' in e ? new Vec2(e.clientX, e.clientY) : undefined startEditingNode()
startEditingNode(pos)
}, },
}) })
function startEditingNode(position?: Vec2 | undefined) { function startEditingNode() {
let sourceOffset = props.node.rootExpr.code().length emit('update:edited', props.node.rootExpr.code().length)
if (position != null) {
let domNode, domOffset
if ((document as any).caretPositionFromPoint) {
const caret = document.caretPositionFromPoint(position.x, position.y)
domNode = caret?.offsetNode
domOffset = caret?.offset
} else if (document.caretRangeFromPoint) {
const caret = document.caretRangeFromPoint(position.x, position.y)
domNode = caret?.startContainer
domOffset = caret?.startOffset
} else {
console.error(
'Neither `caretPositionFromPoint` nor `caretRangeFromPoint` are supported by this browser',
)
}
if (domNode != null && domOffset != null) {
sourceOffset = getRelatedSpanOffset(domNode, domOffset)
}
}
emit('update:edited', sourceOffset)
}
function getRelatedSpanOffset(domNode: globalThis.Node, domOffset: number): number {
if (domNode instanceof HTMLElement && domOffset == 1) {
const offsetData = domNode.dataset.spanStart
const offset = (offsetData != null && parseInt(offsetData)) || 0
const length = domNode.textContent?.length ?? 0
return offset + length
} else if (domNode instanceof Text) {
const siblingEl = domNode.previousElementSibling
if (siblingEl instanceof HTMLElement) {
const offsetData = siblingEl.dataset.spanStart
if (offsetData != null)
return parseInt(offsetData) + domOffset + (siblingEl.textContent?.length ?? 0)
}
const offsetData = domNode.parentElement?.dataset.spanStart
if (offsetData != null) return parseInt(offsetData) + domOffset
}
return domOffset
} }
const handleNodeClick = useDoubleClick( const handleNodeClick = useDoubleClick(
@ -403,6 +363,16 @@ const dataSource = computed(
() => ({ type: 'node', nodeId: props.node.rootExpr.externalId }) as const, () => ({ type: 'node', nodeId: props.node.rootExpr.externalId }) as const,
) )
const pending = computed(() => {
switch (executionState.value) {
case 'Unknown':
case 'Pending':
return true
default:
return false
}
})
// === Recompute node expression === // === Recompute node expression ===
function useRecomputation() { function useRecomputation() {
@ -421,6 +391,30 @@ function useRecomputation() {
return { recomputeOnce, isBeingRecomputed } return { recomputeOnce, isBeingRecomputed }
} }
const nodeStyle = computed(() => {
return {
transform: transform.value,
minWidth: isVisualizationEnabled.value ? `${visualizationWidth.value ?? 200}px` : undefined,
'--node-group-color': color.value,
...(props.node.zIndex ? { 'z-index': props.node.zIndex } : {}),
'--viz-below-node': `${graphSelectionSize.value.y - nodeSize.value.y}px`,
'--node-size-x': `${nodeSize.value.x}px`,
'--node-size-y': `${nodeSize.value.y}px`,
}
})
const nodeClass = computed(() => {
return {
selected: selected.value,
selectionVisible: selectionVisible.value,
pending: pending.value,
inputNode: props.node.type === 'input',
outputNode: props.node.type === 'output',
menuVisible: menuVisible.value,
menuFull: menuFull.value,
}
})
// === Component actions === // === Component actions ===
const { getNodeColor, getNodeColors } = injectNodeColors() const { getNodeColor, getNodeColors } = injectNodeColors()
@ -476,25 +470,9 @@ const showMenuAt = ref<{ x: number; y: number }>()
<div <div
v-show="!edited" v-show="!edited"
ref="rootNode" ref="rootNode"
class="GraphNode" class="GraphNode define-node-colors"
:style="{ :style="nodeStyle"
transform, :class="nodeClass"
minWidth: isVisualizationEnabled ? `${visualizationWidth ?? 200}px` : undefined,
'--node-group-color': color,
...(node.zIndex ? { 'z-index': node.zIndex } : {}),
'--viz-below-node': `${graphSelectionSize.y - nodeSize.y}px`,
'--node-size-x': `${nodeSize.x}px`,
'--node-size-y': `${nodeSize.y}px`,
}"
:class="{
selected,
selectionVisible,
['executionState-' + executionState]: true,
inputNode: props.node.type === 'input',
outputNode: props.node.type === 'output',
menuVisible,
menuFull,
}"
:data-node-id="nodeId" :data-node-id="nodeId"
@pointerenter="(nodeHovered = true), updateNodeHover($event)" @pointerenter="(nodeHovered = true), updateNodeHover($event)"
@pointerleave="(nodeHovered = false), updateNodeHover(undefined)" @pointerleave="(nodeHovered = false), updateNodeHover(undefined)"
@ -553,12 +531,11 @@ const showMenuAt = ref<{ x: number; y: number }>()
@click="handleNodeClick" @click="handleNodeClick"
@contextmenu.stop.prevent="ensureSelected(), (showMenuAt = $event)" @contextmenu.stop.prevent="ensureSelected(), (showMenuAt = $event)"
> >
<NodeWidgetTree <ComponentWidgetTree
:ast="props.node.innerExpr" :ast="props.node.innerExpr"
:nodeId="nodeId" :nodeId="nodeId"
:nodeElement="rootNode" :rootElement="rootNode"
:nodeType="props.node.type" :nodeType="props.node.type"
:nodeSize="nodeSize"
:potentialSelfArgumentId="potentialSelfArgumentId" :potentialSelfArgumentId="potentialSelfArgumentId"
:conditionalPorts="props.node.conditionalPorts" :conditionalPorts="props.node.conditionalPorts"
:extended="isOnlyOneSelected" :extended="isOnlyOneSelected"
@ -609,7 +586,6 @@ const showMenuAt = ref<{ x: number; y: number }>()
top: 0; top: 0;
left: 0; left: 0;
display: flex; display: flex;
--output-port-transform: translateY(var(--viz-below-node)); --output-port-transform: translateY(var(--viz-below-node));
} }
@ -622,33 +598,11 @@ const showMenuAt = ref<{ x: number; y: number }>()
transition: fill 0.2s ease; transition: fill 0.2s ease;
} }
.GraphNode {
--color-node-text: white;
--color-node-primary: var(--node-group-color);
--color-node-background: var(--node-group-color);
}
.GraphNode.selected {
--color-node-background: color-mix(in oklab, var(--color-node-primary) 30%, white 70%);
--color-node-text: color-mix(in oklab, var(--color-node-primary) 70%, black 30%);
}
.GraphNode { .GraphNode {
position: absolute; position: absolute;
border-radius: var(--node-border-radius); border-radius: var(--node-border-radius);
transition: box-shadow 0.2s ease-in-out; transition: box-shadow 0.2s ease-in-out;
box-sizing: border-box; box-sizing: border-box;
/** Space between node and component above and below, such as comments and errors. */
--node-vertical-gap: 5px;
--color-node-primary: var(--node-group-color);
--node-color-port: color-mix(in oklab, var(--color-node-primary) 85%, white 15%);
--node-color-error: color-mix(in oklab, var(--node-group-color) 30%, rgb(255, 0, 0) 70%);
&.executionState-Unknown,
&.executionState-Pending {
--color-node-primary: color-mix(in oklab, var(--node-group-color) 60%, #aaa 40%);
}
} }
.content { .content {

View File

@ -158,7 +158,7 @@ graph.suggestEdgeFromOutput(outputHovered)
rx: calc(var(--node-border-radius) + var(--output-port-width) / 2); rx: calc(var(--node-border-radius) + var(--output-port-width) / 2);
fill: none; fill: none;
stroke: var(--node-color-port); stroke: var(--color-node-edge);
stroke-width: calc(var(--output-port-width) + var(--output-port-overlap-anim)); stroke-width: calc(var(--output-port-width) + var(--output-port-overlap-anim));
transition: stroke 0.2s ease; transition: stroke 0.2s ease;
--horizontal-line: calc(var(--node-size-x) - var(--node-border-radius) * 2); --horizontal-line: calc(var(--node-size-x) - var(--node-border-radius) * 2);

View File

@ -1,14 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WidgetModule } from '@/providers/widgetRegistry' import type { WidgetModule } from '@/providers/widgetRegistry'
import { injectWidgetRegistry, WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry' import { injectWidgetRegistry, WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree'
import { import {
injectWidgetUsageInfo, injectWidgetUsageInfo,
provideWidgetUsageInfo, provideWidgetUsageInfo,
usageKeyForInput, usageKeyForInput,
} from '@/providers/widgetUsageInfo' } from '@/providers/widgetUsageInfo'
import { useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast'
import { computed, getCurrentInstance, proxyRefs, shallowRef, watchEffect, withCtx } from 'vue' import { computed, getCurrentInstance, proxyRefs, shallowRef, watchEffect, withCtx } from 'vue'
const props = defineProps<{ const props = defineProps<{
@ -28,9 +25,7 @@ defineOptions({
type UpdateHandler = (update: WidgetUpdate) => boolean type UpdateHandler = (update: WidgetUpdate) => boolean
const graph = useGraphStore()
const registry = injectWidgetRegistry() const registry = injectWidgetRegistry()
const tree = injectWidgetTree()
const parentUsageInfo = injectWidgetUsageInfo(true) const parentUsageInfo = injectWidgetUsageInfo(true)
const usageKey = computed(() => usageKeyForInput(props.input)) const usageKey = computed(() => usageKeyForInput(props.input))
const sameInputAsParent = computed(() => parentUsageInfo?.usageKey === usageKey.value) const sameInputAsParent = computed(() => parentUsageInfo?.usageKey === usageKey.value)
@ -82,13 +77,6 @@ provideWidgetUsageInfo(
}), }),
}), }),
) )
const spanStart = computed(() => {
if (!(props.input instanceof Ast.Ast)) return undefined
const span = graph.moduleSource.getSpan(props.input.id)
if (span == null) return undefined
return span[0] - tree.nodeSpanStart
})
</script> </script>
<template> <template>
@ -98,7 +86,6 @@ const spanStart = computed(() => {
v-bind="$attrs" v-bind="$attrs"
:input="props.input" :input="props.input"
:nesting="nesting" :nesting="nesting"
:data-span-start="spanStart"
:data-port="props.input.portId" :data-port="props.input.portId"
@update="updateHandler" @update="updateHandler"
/> />

View File

@ -1,111 +1,52 @@
<script setup lang="ts"> <script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue' import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { useTransitioning } from '@/composables/animation' import { useTransitioning } from '@/composables/animation'
import { injectGraphSelection } from '@/providers/graphSelection'
import { WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry' import { WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
import { WidgetEditHandlerParent } from '@/providers/widgetRegistry/editHandler'
import { provideWidgetTree } from '@/providers/widgetTree' import { provideWidgetTree } from '@/providers/widgetTree'
import { useGraphStore, type NodeId } from '@/stores/graph'
import type { NodeType } from '@/stores/graph/graphDatabase'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import type { Vec2 } from '@/util/data/vec2' import { toRef, watch } from 'vue'
import { iconOfNode } from '@/util/getIconName' import { AstId } from 'ydoc-shared/ast'
import { computed, toRef, watch } from 'vue' import { ExternalId } from 'ydoc-shared/yjsModel'
import { DisplayIcon } from './widgets/WidgetIcon.vue'
const props = defineProps<{ const props = defineProps<{
ast: Ast.Expression externalId: string & ExternalId
nodeId: NodeId input: WidgetInput
nodeElement: HTMLElement | undefined rootElement: HTMLElement | undefined
nodeType: NodeType potentialSelfArgumentId?: AstId | undefined
nodeSize: Vec2
potentialSelfArgumentId: Ast.AstId | undefined
/** Ports that are not targetable by default; see {@link NodeDataFromAst}. */ /** Ports that are not targetable by default; see {@link NodeDataFromAst}. */
conditionalPorts: Set<Ast.AstId> conditionalPorts?: Set<Ast.AstId> | undefined
extended: boolean extended: boolean
onUpdate: (update: WidgetUpdate) => boolean
}>()
const emit = defineEmits<{
currentEditChanged: [WidgetEditHandlerParent | undefined]
}>() }>()
const graph = useGraphStore()
const rootPort = computed(() => {
const input = WidgetInput.FromAst(props.ast)
if (
props.ast instanceof Ast.Ident &&
(!graph.db.isKnownFunctionCall(props.ast.id) || graph.db.connections.hasValue(props.ast.id))
) {
input.forcePort = true
}
if (!props.potentialSelfArgumentId && topLevelIcon.value) { const layoutTransitions = useTransitioning(
input[DisplayIcon] = { new Set([
icon: topLevelIcon.value, 'margin-left',
showContents: props.nodeType != 'output', 'margin-right',
} 'margin-top',
} 'margin-bottom',
return input 'padding-left',
}) 'padding-right',
const selection = injectGraphSelection() 'padding-top',
'padding-bottom',
'width',
'height',
]),
)
const observedLayoutTransitions = new Set([ const tree = provideWidgetTree(
'margin-left', toRef(props, 'externalId'),
'margin-right', toRef(props, 'rootElement'),
'margin-top',
'margin-bottom',
'padding-left',
'padding-right',
'padding-top',
'padding-bottom',
'width',
'height',
])
function selectNode() {
selection.setSelection(new Set([props.nodeId]))
}
function handleWidgetUpdates(update: WidgetUpdate) {
selectNode()
const edit = update.edit ?? graph.startEdit()
if (update.portUpdate) {
const { origin } = update.portUpdate
if (Ast.isAstId(origin)) {
if ('value' in update.portUpdate) {
const value = update.portUpdate.value
const ast =
value instanceof Ast.Ast ? value
: value == null ? Ast.Wildcard.new(edit)
: undefined
if (ast) {
edit.replaceValue(origin, ast)
} else if (typeof value === 'string') {
edit.tryGet(origin)?.syncToCode(value)
}
}
if ('metadata' in update.portUpdate) {
const { metadataKey, metadata } = update.portUpdate
edit.tryGet(origin)?.setWidgetMetadata(metadataKey, metadata)
}
} else {
console.error(`[UPDATE ${origin}] Invalid top-level origin. Expected expression ID.`)
}
}
graph.commitEdit(edit)
// This handler is guaranteed to be the last handler in the chain.
return true
}
const layoutTransitions = useTransitioning(observedLayoutTransitions)
const widgetTree = provideWidgetTree(
toRef(props, 'ast'),
toRef(props, 'nodeId'),
toRef(props, 'nodeElement'),
toRef(props, 'nodeSize'),
toRef(props, 'potentialSelfArgumentId'),
toRef(props, 'conditionalPorts'), toRef(props, 'conditionalPorts'),
toRef(props, 'extended'), toRef(props, 'extended'),
layoutTransitions.active, layoutTransitions.active,
toRef(props, 'potentialSelfArgumentId'),
) )
watch(toRef(tree, 'currentEdit'), (edit) => emit('currentEditChanged', edit))
const topLevelIcon = computed(() => iconOfNode(props.nodeId, graph.db))
watch(toRef(widgetTree, 'currentEdit'), (edit) => edit && selectNode())
</script> </script>
<script lang="ts"> <script lang="ts">
export const GRAB_HANDLE_X_MARGIN_L = 4 export const GRAB_HANDLE_X_MARGIN_L = 4
@ -114,13 +55,13 @@ export const ICON_WIDTH = 16
</script> </script>
<template> <template>
<div class="NodeWidgetTree widgetRounded" spellcheck="false" v-on="layoutTransitions.events"> <div class="WidgetTreeRoot widgetRounded" spellcheck="false" v-on="layoutTransitions.events">
<NodeWidget :input="rootPort" @update="handleWidgetUpdates" /> <NodeWidget :input="input" :onUpdate="onUpdate" />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.NodeWidgetTree { .WidgetTreeRoot {
color: var(--color-node-text); color: var(--color-node-text);
outline: none; outline: none;
@ -161,7 +102,7 @@ export const ICON_WIDTH = 16
* at the end of the widget template, so it doesn't prevent tokens around * at the end of the widget template, so it doesn't prevent tokens around
* them from being properly padded. * them from being properly padded.
*/ */
.NodeWidgetTree { .WidgetTreeRoot {
/* /*
* Core of the propagation logic. Prevent left/right margin from propagating to non-first non-last * Core of the propagation logic. Prevent left/right margin from propagating to non-first non-last
* children of a widget. That way, only the innermost left/right deep child of a rounded widget will * children of a widget. That way, only the innermost left/right deep child of a rounded widget will

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue' import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { FunctionName } from '@/components/GraphEditor/widgets/WidgetFunctionName.vue'
import SizeTransition from '@/components/SizeTransition.vue' import SizeTransition from '@/components/SizeTransition.vue'
import { WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry' import { WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree' import { injectWidgetTree } from '@/providers/widgetTree'
@ -25,11 +24,6 @@ const targetMaybePort = computed(() => {
if (!ptr) return input if (!ptr) return input
const definition = graph.getMethodAst(ptr) const definition = graph.getMethodAst(ptr)
if (!definition.ok) return input if (!definition.ok) return input
if (input.value instanceof Ast.PropertyAccess || input.value instanceof Ast.Ident) {
input[FunctionName] = {
editableName: definition.value.name.externalId,
}
}
return input return input
} else { } else {
return { ...target.toWidgetInput(), forcePort: !(target instanceof ArgumentApplication) } return { ...target.toWidgetInput(), forcePort: !(target instanceof ArgumentApplication) }

View File

@ -33,7 +33,7 @@ export const widgetDefinition = defineWidget(
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
bottom: 0; bottom: 0;
background-color: var(--node-color-port); background-color: var(--color-node-port);
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
} }
} }

View File

@ -36,9 +36,10 @@ const value = computed({
set(value) { set(value) {
const edit = graph.startEdit() const edit = graph.startEdit()
const theImport = value ? trueImport.value : falseImport.value const theImport = value ? trueImport.value : falseImport.value
if (props.input.value instanceof Ast.Ast) { const inputValue: Ast.Expression | string | undefined = props.input.value
if (inputValue instanceof Ast.Ast) {
const { requiresImport } = setBoolNode( const { requiresImport } = setBoolNode(
edit.getVersion(props.input.value), edit.getVersion(inputValue),
value ? ('True' as Identifier) : ('False' as Identifier), value ? ('True' as Identifier) : ('False' as Identifier),
) )
if (requiresImport) graph.addMissingImports(edit, theImport) if (requiresImport) graph.addMissingImports(edit, theImport)
@ -64,7 +65,7 @@ const argumentName = computed(() => {
</script> </script>
<script lang="ts"> <script lang="ts">
function isBoolNode(ast: Ast.Expression) { function isBoolNode(ast: Ast.Ast) {
const candidate = const candidate =
ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean' ? ast.rhs ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean' ? ast.rhs
: ast instanceof Ast.Ident ? ast.token : ast instanceof Ast.Ident ? ast.token

View File

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue' import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { useWidgetFunctionCallInfo } from '@/components/GraphEditor/widgets/WidgetFunction/widgetFunctionCallInfo' import { useWidgetFunctionCallInfo } from '@/components/GraphEditor/widgets/WidgetFunction/widgetFunctionCallInfo'
import { FunctionName } from '@/components/GraphEditor/widgets/WidgetFunctionName.vue'
import { injectFunctionInfo, provideFunctionInfo } from '@/providers/functionInfo' import { injectFunctionInfo, provideFunctionInfo } from '@/providers/functionInfo'
import { import {
Score, Score,
@ -65,13 +64,7 @@ const innerInput = computed(() => {
input = { ...props.input } input = { ...props.input }
} }
const callInfo = methodCallInfo.value const callInfo = methodCallInfo.value
if (callInfo) { if (callInfo) input[CallInfo] = callInfo
input[CallInfo] = callInfo
if (input.value instanceof Ast.PropertyAccess || input.value instanceof Ast.Ident) {
const definition = graph.getMethodAst(callInfo.methodCall.methodPointer)
if (definition.ok) input[FunctionName] = { editableName: definition.value.name.externalId }
}
}
return input return input
}) })

View File

@ -0,0 +1,75 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { FunctionName } from '@/components/GraphEditor/widgets/WidgetFunctionName.vue'
import { DisplayIcon } from '@/components/GraphEditor/widgets/WidgetIcon.vue'
import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry'
import { DocumentationData } from '@/stores/suggestionDatabase/documentation'
import { Ast } from '@/util/ast'
import { computed, Ref } from 'vue'
import { MethodPointer } from 'ydoc-shared/languageServerTypes'
import ArgumentRow from './WidgetFunctionDef/ArgumentRow.vue'
const { input } = defineProps(widgetProps(widgetDefinition))
const funcIcon = computed(() => {
return input[FunctionInfoKey]?.docsData.value?.iconName ?? 'enso_logo'
})
const funcNameInput = computed(() => {
const nameAst = input.value.name
const widgetInput = WidgetInput.FromAst(nameAst)
widgetInput[DisplayIcon] = {
icon: funcIcon.value,
allowChoice: true,
showContents: true,
}
const methodPointer = input[FunctionInfoKey]?.methodPointer
if (nameAst.code() !== 'main' && methodPointer != null) {
widgetInput[FunctionName] = {
editableNameExpression: nameAst.externalId,
methodPointer,
}
}
return widgetInput
})
</script>
<template>
<div class="WidgetFunctionDef">
<NodeWidget :input="funcNameInput" />
<ArgumentRow
v-for="(definition, i) in input.value.argumentDefinitions"
:key="i"
:definition="definition"
/>
</div>
</template>
<script lang="ts">
export const FunctionInfoKey: unique symbol = Symbol.for('WidgetInput:FunctionInfoKey')
declare module '@/providers/widgetRegistry' {
export interface WidgetInput {
[FunctionInfoKey]?: {
methodPointer: MethodPointer
docsData: Ref<DocumentationData | undefined>
}
}
}
export const widgetDefinition = defineWidget(
WidgetInput.astMatcher(Ast.FunctionDef),
{
priority: 999,
score: Score.Perfect,
},
import.meta.hot,
)
</script>
<style scoped>
.WidgetFunctionDef {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { WidgetInput } from '@/providers/widgetRegistry'
import { computed } from 'vue'
import { ArgumentDefinition, ConcreteRefs } from 'ydoc-shared/ast'
import { isSome, mapOrUndefined } from 'ydoc-shared/util/data/opt'
const { definition } = defineProps<{
definition: ArgumentDefinition<ConcreteRefs>
}>()
const allWidgets = computed(() =>
[
mapOrUndefined(definition.open?.node, WidgetInput.FromAst),
mapOrUndefined(definition.open2?.node, WidgetInput.FromAst),
mapOrUndefined(definition.suspension?.node, WidgetInput.FromAst),
mapOrUndefined(definition.pattern?.node, WidgetInput.FromAst),
mapOrUndefined(definition.type?.operator.node, WidgetInput.FromAst),
mapOrUndefined(definition.type?.type.node, WidgetInput.FromAst),
mapOrUndefined(definition.close2?.node, WidgetInput.FromAst),
mapOrUndefined(definition.defaultValue?.equals.node, WidgetInput.FromAst),
mapOrUndefined(definition.defaultValue?.expression.node, WidgetInput.FromAst),
mapOrUndefined(definition.close?.node, WidgetInput.FromAst),
].flatMap((v, key) => (isSome(v) ? ([[key, v]] as const) : [])),
)
</script>
<template>
<div class="ArgumentRow widgetResetPadding widgetRounded">
<SvgIcon name="grab" />
<NodeWidget v-for="[key, widget] of allWidgets" :key="key" :input="widget" />
</div>
</template>
<style scoped>
.ArgumentRow {
display: flex;
flex-direction: row;
place-items: center;
overflow-x: clip;
margin-left: 24px;
.SvgIcon {
color: color-mix(in srgb, currentColor, transparent 50%);
margin-right: 4px;
}
}
</style>

View File

@ -1,16 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import AutoSizedInput from '@/components/widgets/AutoSizedInput.vue' import AutoSizedInput from '@/components/widgets/AutoSizedInput.vue'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry' import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry'
import { useGraphStore } from '@/stores/graph'
import { usePersisted } from '@/stores/persisted'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import { Err, Ok, type Result } from '@/util/data/result' import { Err, Ok, type Result } from '@/util/data/result'
import { useToast } from '@/util/toast' import { useToast } from '@/util/toast'
import { computed, ref, watchEffect } from 'vue' import { computed, ref, watch } from 'vue'
import { PropertyAccess } from 'ydoc-shared/ast' import { PropertyAccess } from 'ydoc-shared/ast'
import type { ExpressionId } from 'ydoc-shared/languageServerTypes' import type { ExpressionId, MethodPointer } from 'ydoc-shared/languageServerTypes'
import NodeWidget from '../NodeWidget.vue' import NodeWidget from '../NodeWidget.vue'
const props = defineProps(widgetProps(widgetDefinition)) const props = defineProps(widgetProps(widgetDefinition))
const graph = useGraphStore(true)
const persisted = usePersisted(true)
const displayedName = ref(props.input.value.code()) const displayedName = ref(props.input.value.code())
const project = useProjectStore() const project = useProjectStore()
@ -25,24 +29,37 @@ const operator = computed(() =>
const name = computed(() => const name = computed(() =>
props.input.value instanceof PropertyAccess ? props.input.value.rhs : props.input.value, props.input.value instanceof PropertyAccess ? props.input.value.rhs : props.input.value,
) )
watchEffect(() => (displayedName.value = name.value.code()))
function newNameAccepted(newName: string | undefined) { const nameCode = computed(() => name.value.code())
watch(nameCode, (newValue) => (displayedName.value = newValue))
async function newNameAccepted(newName: string | undefined) {
if (!newName) { if (!newName) {
displayedName.value = name.value.code() displayedName.value = name.value.code()
} else { } else {
renameFunction(newName).then((result) => result.ok || renameError.reportError(result.error)) const result = await renameFunction(newName)
if (!result.ok) {
renameError.reportError(result.error)
displayedName.value = name.value.code()
}
} }
} }
async function renameFunction(newName: string): Promise<Result> { async function renameFunction(newName: string): Promise<Result> {
if (!project.modulePath?.ok) return project.modulePath ?? Err('Unknown module Path') if (!project.modulePath?.ok) return project.modulePath ?? Err('Unknown module Path')
const refactorResult = await project.lsRpcConnection.renameSymbol( const modPath = project.modulePath.value
project.modulePath.value, const editedName = props.input[FunctionName].editableNameExpression
props.input[FunctionName].editableName, const oldMethodPointer = props.input[FunctionName].methodPointer
newName, const refactorResult = await project.lsRpcConnection.renameSymbol(modPath, editedName, newName)
)
if (!refactorResult.ok) return refactorResult if (!refactorResult.ok) return refactorResult
if (oldMethodPointer) {
const newMethodPointer = {
...oldMethodPointer,
name: refactorResult.value.newName,
}
graph?.db.insertSyntheticMethodPointerUpdate(oldMethodPointer, newMethodPointer)
persisted?.handleModifiedMethodPointer(oldMethodPointer, newMethodPointer)
}
return Ok() return Ok()
} }
</script> </script>
@ -56,21 +73,23 @@ declare module '@/providers/widgetRegistry' {
* Id of expression which is accepted by Language Server's * Id of expression which is accepted by Language Server's
* [`refactoring/renameSymbol` method](https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-language-server.md#refactoringrenamesymbol) * [`refactoring/renameSymbol` method](https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-language-server.md#refactoringrenamesymbol)
*/ */
editableName: ExpressionId editableNameExpression: ExpressionId
methodPointer: MethodPointer
} }
} }
} }
function isFunctionName( function isFunctionName(input: WidgetInput): input is WidgetInput & {
input: WidgetInput, value: Ast.Ast
): input is WidgetInput & { value: Ast.Ast; [FunctionName]: { editableName: ExpressionId } } { [FunctionName]: { editableNameExpression: ExpressionId }
} {
return WidgetInput.isAst(input) && FunctionName in input return WidgetInput.isAst(input) && FunctionName in input
} }
export const widgetDefinition = defineWidget( export const widgetDefinition = defineWidget(
isFunctionName, isFunctionName,
{ {
priority: -20, priority: 2,
score: Score.Perfect, score: Score.Perfect,
}, },
import.meta.hot, import.meta.hot,

View File

@ -14,6 +14,7 @@ declare module '@/providers/widgetRegistry' {
export interface WidgetInput { export interface WidgetInput {
[DisplayIcon]?: { [DisplayIcon]?: {
icon: Icon | URLString icon: Icon | URLString
allowChoice?: boolean
showContents?: boolean showContents?: boolean
} }
} }

View File

@ -40,28 +40,22 @@ const isCurrentEdgeHoverTarget = computed(
() => () =>
graph.mouseEditedEdge?.source != null && graph.mouseEditedEdge?.source != null &&
selection?.hoveredPort === portId.value && selection?.hoveredPort === portId.value &&
graph.db.getPatternExpressionNodeId(graph.mouseEditedEdge.source) !== tree.nodeId, graph.db.getPatternExpressionNodeId(graph.mouseEditedEdge.source) !== tree.externalId,
) )
const isCurrentDisconnectedEdgeTarget = computed( const isCurrentDisconnectedEdgeTarget = computed(
() => () =>
graph.mouseEditedEdge?.disconnectedEdgeTarget === portId.value && graph.mouseEditedEdge?.disconnectedEdgeTarget === portId.value &&
graph.mouseEditedEdge?.target !== portId.value, graph.mouseEditedEdge?.target !== portId.value,
) )
const isSelfArgument = computed( const connected = computed(() => hasConnection.value || isCurrentEdgeHoverTarget.value)
() =>
props.input.value instanceof Ast.Ast && props.input.value.id === tree.potentialSelfArgumentId,
)
const connected = computed(
() => (!isSelfArgument.value && hasConnection.value) || isCurrentEdgeHoverTarget.value,
)
const isTarget = computed( const isTarget = computed(
() => () =>
(hasConnection.value && !isCurrentDisconnectedEdgeTarget.value) || (hasConnection.value && !isCurrentDisconnectedEdgeTarget.value) ||
isCurrentEdgeHoverTarget.value, isCurrentEdgeHoverTarget.value,
) )
const rootNode = shallowRef<HTMLElement>() const portRoot = shallowRef<HTMLElement>()
const nodeSize = useResizeObserver(rootNode) const portSize = useResizeObserver(portRoot)
// Compute the scene-space bounding rectangle of the expression's widget. Those bounds are later // Compute the scene-space bounding rectangle of the expression's widget. Those bounds are later
// used for edge positioning. Querying and updating those bounds is relatively expensive, so we only // used for edge positioning. Querying and updating those bounds is relatively expensive, so we only
@ -87,20 +81,22 @@ providePortInfo(proxyRefs({ portId, connected: hasConnection }))
watchEffect( watchEffect(
(onCleanup) => { (onCleanup) => {
const externalId = tree.externalId
if (!graph.db.isNodeId(externalId)) return
const id = portId.value const id = portId.value
const instance = new PortViewInstance(portRect, tree.nodeId, props.onUpdate) const instance = new PortViewInstance(portRect, externalId, props.onUpdate)
graph.addPortInstance(id, instance) graph.addPortInstance(id, instance)
onCleanup(() => graph.removePortInstance(id, instance)) onCleanup(() => graph.removePortInstance(id, instance))
}, },
{ flush: 'post' }, { flush: 'post' },
) )
const keyboard = injectKeyboard() const keyboard = injectKeyboard(true)
const enabled = computed(() => { const enabled = computed(() => {
const input = props.input.value const input = props.input.value
const isConditional = input instanceof Ast.Ast && tree.conditionalPorts.has(input.id) const isConditional = input instanceof Ast.Ast && (tree.conditionalPorts?.has(input.id) ?? false)
return !isConditional || keyboard.mod return !isConditional || (keyboard?.mod ?? false)
}) })
/** /**
@ -121,8 +117,8 @@ function updateRect() {
} }
function relativePortSceneRect(): Rect | undefined { function relativePortSceneRect(): Rect | undefined {
const domNode = rootNode.value const domNode = portRoot.value
const rootDomNode = tree.nodeElement const rootDomNode = tree.rootElement
if (domNode == null || rootDomNode == null) return if (domNode == null || rootDomNode == null) return
if (!enabled.value) return if (!enabled.value) return
const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect()) const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect())
@ -133,10 +129,7 @@ function relativePortSceneRect(): Rect | undefined {
return rect.isFinite() ? rect : undefined return rect.isFinite() ? rect : undefined
} }
watch( watch(() => [portSize.value, portRoot.value, tree.rootElement, enabled.value], updateRect)
() => [nodeSize.value, rootNode.value, tree.nodeElement, tree.nodeSize, enabled.value],
updateRect,
)
onUpdated(() => nextTick(updateRect)) onUpdated(() => nextTick(updateRect))
onMounted(() => nextTick(updateRect)) onMounted(() => nextTick(updateRect))
useRaf(toRef(tree, 'hasActiveAnimations'), updateRect) useRaf(toRef(tree, 'hasActiveAnimations'), updateRect)
@ -183,7 +176,7 @@ export const widgetDefinition = defineWidget(
<template> <template>
<div <div
ref="rootNode" ref="portRoot"
class="WidgetPort" class="WidgetPort"
:class="{ :class="{
enabled, enabled,
@ -210,11 +203,12 @@ export const widgetDefinition = defineWidget(
min-height: var(--node-port-height); min-height: var(--node-port-height);
min-width: var(--node-port-height); min-width: var(--node-port-height);
box-sizing: border-box; box-sizing: border-box;
transition: background-color 0.2s ease;
} }
.WidgetPort.connected { .WidgetPort.connected {
background-color: var(--node-color-port); background-color: var(--color-node-port);
color: white; color: var(--color-node-text);
} }
.GraphEditor.draggingEdge .WidgetPort { .GraphEditor.draggingEdge .WidgetPort {

View File

@ -53,7 +53,7 @@ const activity = shallowRef<VNode>()
const MAX_DROPDOWN_OVERSIZE_PX = 390 const MAX_DROPDOWN_OVERSIZE_PX = 390
const floatReference = computed( const floatReference = computed(
() => enclosingTopLevelArgument(widgetRoot.value, tree) ?? widgetRoot.value, () => enclosingTopLevelArgument(widgetRoot.value, tree.rootElement) ?? widgetRoot.value,
) )
function dropdownStyles(dropdownElement: Ref<HTMLElement | undefined>, limitWidth: boolean) { function dropdownStyles(dropdownElement: Ref<HTMLElement | undefined>, limitWidth: boolean) {
@ -85,7 +85,7 @@ function dropdownStyles(dropdownElement: Ref<HTMLElement | undefined>, limitWidt
}, },
})), })),
// Try to keep the dropdown within node's bounds. // Try to keep the dropdown within node's bounds.
shift(() => (tree.nodeElement ? { boundary: tree.nodeElement } : {})), shift(() => (tree.rootElement ? { boundary: tree.rootElement } : {})),
shift(), // Always keep within screen bounds, overriding node bounds. shift(), // Always keep within screen bounds, overriding node bounds.
] ]
}), }),
@ -471,7 +471,7 @@ declare module '@/providers/widgetRegistry' {
:class="{ hovered: isHovered }" :class="{ hovered: isHovered }"
/> />
</ConditionalTeleport> </ConditionalTeleport>
<Teleport v-if="tree.nodeElement" :to="tree.nodeElement"> <Teleport v-if="tree.rootElement" :to="tree.rootElement">
<div ref="dropdownElement" :style="floatingStyles" class="widgetOutOfLayout floatingElement"> <div ref="dropdownElement" :style="floatingStyles" class="widgetOutOfLayout floatingElement">
<SizeTransition height :duration="100"> <SizeTransition height :duration="100">
<DropdownWidget <DropdownWidget

View File

@ -10,7 +10,7 @@ import {
import ResizeHandles from '@/components/ResizeHandles.vue' import ResizeHandles from '@/components/ResizeHandles.vue'
import AgGridTableView from '@/components/shared/AgGridTableView.vue' import AgGridTableView from '@/components/shared/AgGridTableView.vue'
import { injectGraphNavigator } from '@/providers/graphNavigator' import { injectGraphNavigator } from '@/providers/graphNavigator'
import { useTooltipRegistry } from '@/providers/tooltipState' import { useTooltipRegistry } from '@/providers/tooltipRegistry'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry' import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler' import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'

View File

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import SvgButton from '@/components/SvgButton.vue' import SvgButton from '@/components/SvgButton.vue'
import type { TooltipRegistry } from '@/providers/tooltipState' import { provideTooltipRegistry, type TooltipRegistry } from '@/providers/tooltipRegistry'
import { provideTooltipRegistry } from '@/providers/tooltipState'
import type { IHeaderParams } from 'ag-grid-community' import type { IHeaderParams } from 'ag-grid-community'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'

View File

@ -22,17 +22,13 @@ export const widgetDefinition = defineWidget(
/** If the element is the recursively-first-child of a top-level argument, return the top-level argument element. */ /** If the element is the recursively-first-child of a top-level argument, return the top-level argument element. */
export function enclosingTopLevelArgument( export function enclosingTopLevelArgument(
element: HTMLElement | undefined, element: HTMLElement | undefined,
tree: { nodeElement: HTMLElement | undefined }, rootElement: HTMLElement | undefined,
): HTMLElement | undefined { ): HTMLElement | undefined {
return ( return (
element?.dataset.topLevelArgument !== undefined ? element element?.dataset.topLevelArgument !== undefined ? element
: ( : !element || element === rootElement || element.parentElement?.firstElementChild !== element ?
!element ||
element === tree.nodeElement ||
element.parentElement?.firstElementChild !== element
) ?
undefined undefined
: enclosingTopLevelArgument(element.parentElement, tree) : enclosingTopLevelArgument(element.parentElement, rootElement)
) )
} }
</script> </script>

View File

@ -19,16 +19,13 @@ export type TransformUrlResult = Result<{
}> }>
export type UrlTransformer = (url: string) => Promise<TransformUrlResult> export type UrlTransformer = (url: string) => Promise<TransformUrlResult>
export { export const [provideDocumentationImageUrlTransformer, injectDocumentationImageUrlTransformer] =
injectFn as injectDocumentationImageUrlTransformer, createContextStore(
provideFn as provideDocumentationImageUrlTransformer, 'Documentation image URL transformer',
} (transformUrl: ToValue<UrlTransformer | undefined>) => ({
const { provideFn, injectFn } = createContextStore( transformUrl: (url: string) => toValue(transformUrl)?.(url),
'Documentation image URL transformer', }),
(transformUrl: ToValue<UrlTransformer | undefined>) => ({ )
transformUrl: (url: string) => toValue(transformUrl)?.(url),
}),
)
type ResourceId = string type ResourceId = string
type Url = string type Url = string

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import ComponentDocumentation from '@/components/ComponentDocumentation.vue'
import DockPanel from '@/components/DockPanel.vue'
import DocumentationEditor from '@/components/DocumentationEditor.vue'
import FunctionSignatureEditor from '@/components/FunctionSignatureEditor.vue'
import { tabButtons, useRightDock } from '@/stores/rightDock'
import { ref } from 'vue'
import { SuggestionId } from 'ydoc-shared/languageServerTypes/suggestions'
const dockStore = useRightDock()
const displayedDocsSuggestion = defineModel<SuggestionId | undefined>('displayedDocs')
const props = defineProps<{ aiMode: boolean }>()
const isFullscreen = ref(false)
</script>
<template>
<DockPanel
v-model:size="dockStore.width"
:show="dockStore.visible"
:tab="dockStore.displayedTab"
:tabButtons="tabButtons"
:contentFullscreen="isFullscreen"
@update:show="dockStore.setVisible"
@update:tab="dockStore.switchToTab"
>
<template #tab-docs>
<DocumentationEditor
v-if="dockStore.markdownDocs"
ref="docEditor"
:yText="dockStore.markdownDocs"
@update:fullscreen="isFullscreen = $event"
>
<template #belowToolbar>
<FunctionSignatureEditor
v-if="dockStore.inspectedAst"
:functionAst="dockStore.inspectedAst"
:methodPointer="dockStore.inspectedMethodPointer"
:markdownDocs="dockStore.markdownDocs"
/>
</template>
</DocumentationEditor>
</template>
<template #tab-help>
<ComponentDocumentation v-model="displayedDocsSuggestion" :aiMode="props.aiMode" />
</template>
</DockPanel>
</template>

View File

@ -112,7 +112,7 @@ function runAnimation(e: HTMLElement, done: Done, isEnter: boolean) {
} }
if (props.leftGap && e.parentElement) { if (props.leftGap && e.parentElement) {
const parentStyle = getComputedStyle(e.parentElement) const parentStyle = getComputedStyle(e.parentElement)
const negativeGap = `calc(${parentStyle.gap} * -1)` const negativeGap = `calc(${parentStyle.gap || '0'} * -1)`
start.marginLeft = lastSnapshot?.marginLeft || negativeGap start.marginLeft = lastSnapshot?.marginLeft || negativeGap
end.marginLeft = isEnter ? current.marginLeft : negativeGap end.marginLeft = isEnter ? current.marginLeft : negativeGap
} }

View File

@ -13,7 +13,7 @@ const props = defineProps<{
<template> <template>
<div class="StandaloneButton"> <div class="StandaloneButton">
<SvgButton v-bind="props" :name="icon" /> <SvgButton v-bind="{ ...$attrs, ...props }" :name="icon" />
</div> </div>
</template> </template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { type TooltipRegistry } from '@/providers/tooltipState' import { type TooltipRegistry } from '@/providers/tooltipRegistry'
import { debouncedGetter } from '@/util/reactivity' import { debouncedGetter } from '@/util/reactivity'
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue' import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTooltipRegistry } from '@/providers/tooltipState' import { useTooltipRegistry } from '@/providers/tooltipRegistry'
import { usePropagateScopesToAllRoots } from '@/util/patching' import { usePropagateScopesToAllRoots } from '@/util/patching'
import { toRef, useSlots } from 'vue' import { toRef, useSlots } from 'vue'

View File

@ -5,6 +5,8 @@ import { useGraphEditorLayers } from '@/providers/graphEditorLayers'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { computed, ref, toRef, watch } from 'vue' import { computed, ref, toRef, watch } from 'vue'
export type SavedSize = Keyframe
const props = defineProps<{ const props = defineProps<{
fullscreen: boolean fullscreen: boolean
}>() }>()
@ -91,10 +93,6 @@ watch(
const active = computed(() => props.fullscreen || animating.value) const active = computed(() => props.fullscreen || animating.value)
</script> </script>
<script lang="ts">
export type SavedSize = Keyframe
</script>
<!-- The outer `div` is to avoid having a dynamic root. A component whose root may change cannot be passed to a `slot`, <!-- The outer `div` is to avoid having a dynamic root. A component whose root may change cannot be passed to a `slot`,
or used with `unrefElement`. --> or used with `unrefElement`. -->
<template> <template>

View File

@ -138,7 +138,7 @@ const displayedChildren = computed(() => {
const rootNode = ref<HTMLElement>() const rootNode = ref<HTMLElement>()
const cssPropsToCopy = ['--color-node-primary', '--node-color-port', '--node-border-radius'] const cssPropsToCopy = ['--color-node-primary', '--color-node-port', '--node-border-radius']
function onDragStart(event: DragEvent, index: number) { function onDragStart(event: DragEvent, index: number) {
if (!event.dataTransfer) return if (!event.dataTransfer) return

View File

@ -1,10 +1,13 @@
import { useAbortScope } from '@/util/net' import { useAbortScope } from '@/util/net'
import { debouncedWatch, useLocalStorage } from '@vueuse/core' import { debouncedWatch, useLocalStorage } from '@vueuse/core'
import { encoding } from 'lib0' import { encoding } from 'lib0'
import { Encoder } from 'lib0/encoding.js'
import { computed, getCurrentInstance, ref, watch, withCtx } from 'vue' import { computed, getCurrentInstance, ref, watch, withCtx } from 'vue'
import { xxHash128 } from 'ydoc-shared/ast/ffi' import { xxHash128 } from 'ydoc-shared/ast/ffi'
import { AbortScope } from 'ydoc-shared/util/net' import { AbortScope } from 'ydoc-shared/util/net'
export type EncodeFn = (enc: encoding.Encoder) => void
export interface SyncLocalStorageOptions<StoredState> { export interface SyncLocalStorageOptions<StoredState> {
/** The main localStorage key under which a map of saved states will be stored. */ /** The main localStorage key under which a map of saved states will be stored. */
storageKey: string storageKey: string
@ -15,7 +18,7 @@ export interface SyncLocalStorageOptions<StoredState> {
* that is encoded in this function dictates the effective identity of stored state. Whenever the * that is encoded in this function dictates the effective identity of stored state. Whenever the
* encoded key changes, the current state is saved and state stored under new key is restored. * encoded key changes, the current state is saved and state stored under new key is restored.
*/ */
mapKeyEncoder: (enc: encoding.Encoder) => void mapKeyEncoder: EncodeFn
/** /**
* **Reactive** current state serializer. Captures the environment data that will be stored in * **Reactive** current state serializer. Captures the environment data that will be stored in
* localStorage. Returned object must be JSON-encodable. State will not be captured while async * localStorage. Returned object must be JSON-encodable. State will not be captured while async
@ -42,7 +45,9 @@ export interface SyncLocalStorageOptions<StoredState> {
export function useSyncLocalStorage<StoredState extends object>( export function useSyncLocalStorage<StoredState extends object>(
options: SyncLocalStorageOptions<StoredState>, options: SyncLocalStorageOptions<StoredState>,
) { ) {
const graphViewportStorageKey = computed(() => xxHash128(encoding.encode(options.mapKeyEncoder))) const encodeKey = (encoder: EncodeFn) => xxHash128(encoding.encode(encoder))
const graphViewportStorageKey = computed(() => encodeKey(options.mapKeyEncoder))
// Ensure that restoreState function is run within component's context, allowing for temporary // Ensure that restoreState function is run within component's context, allowing for temporary
// watchers to be created for async/await purposes. // watchers to be created for async/await purposes.
@ -129,4 +134,19 @@ export function useSyncLocalStorage<StoredState extends object>(
if (restoreIdInProgress.value === thisRestoreId) restoreIdInProgress.value = undefined if (restoreIdInProgress.value === thisRestoreId) restoreIdInProgress.value = undefined
} }
} }
return {
/**
* Move saved state from under one key to another. Does nothing if `oldKey` does not have any
* associated saved state.
*/
moveToNewKey(oldKeyEncoder: EncodeFn, newKeyEncoder: EncodeFn) {
const oldKey = encodeKey(oldKeyEncoder)
const stateBlob = storageMap.value.get(oldKey)
if (stateBlob != null) {
const newKey = encodeKey(newKeyEncoder)
storageMap.value.set(newKey, stateBlob)
}
},
}
} }

View File

@ -2,8 +2,8 @@ import { createContextStore } from '@/providers'
import type { Opt } from '@/util/data/opt' import type { Opt } from '@/util/data/opt'
import { reactive, watch, type WatchSource } from 'vue' import { reactive, watch, type WatchSource } from 'vue'
export { provideFn as provideAppClassSet } export { provideAppClassSet }
const { provideFn, injectFn: injectAppClassSet } = createContextStore('App Class Set', () => { const [provideAppClassSet, injectAppClassSet] = createContextStore('App Class Set', () => {
return reactive(new Map<string, number>()) return reactive(new Map<string, number>())
}) })

View File

@ -3,8 +3,7 @@ import type { ToValue } from '@/util/reactivity'
import type Backend from 'enso-common/src/services/Backend' import type Backend from 'enso-common/src/services/Backend'
import { proxyRefs, toRef } from 'vue' import { proxyRefs, toRef } from 'vue'
export { injectFn as injectBackend, provideFn as provideBackend } export const [provideBackend, injectBackend] = createContextStore(
const { provideFn, injectFn } = createContextStore(
'backend', 'backend',
({ project, remote }: { project: ToValue<Backend | null>; remote: ToValue<Backend | null> }) => ({ project, remote }: { project: ToValue<Backend | null>; remote: ToValue<Backend | null> }) =>
proxyRefs({ proxyRefs({

View File

@ -116,5 +116,7 @@ function useComponentButtons(
} }
} }
export { injectFn as injectComponentButtons, provideFn as provideComponentButtons } export const [provideComponentButtons, injectComponentButtons] = createContextStore(
const { provideFn, injectFn } = createContextStore('Component buttons', useComponentButtons) 'Component buttons',
useComponentButtons,
)

View File

@ -1,22 +1,22 @@
import { computed, type Ref } from 'vue' import { computed, type Ref } from 'vue'
import { createContextStore } from '.' import { createContextStore } from '.'
export type EventLogger = ReturnType<typeof injectFn> export type EventLogger = ReturnType<typeof injectEventLogger>
export { injectFn as injectEventLogger, provideFn as provideEventLogger } export const [provideEventLogger, injectEventLogger] = createContextStore(
const { provideFn, injectFn } = createContextStore('event logger', eventLogger) 'event logger',
(logEvent: Ref<LogEvent>, projectId: Ref<string>) => {
const logProjectId = computed(() => {
const id = projectId.value
if (!id) return undefined
const prefix = 'project-'
const projectUuid = id.startsWith(prefix) ? id.substring(prefix.length) : id
return `${prefix}${projectUuid.replace(/-/g, '')}`
})
function eventLogger(logEvent: Ref<LogEvent>, projectId: Ref<string>) { return {
const logProjectId = computed(() => { async send(message: string) {
const id = projectId.value logEvent.value(message, logProjectId.value)
if (!id) return undefined },
const prefix = 'project-' }
const projectUuid = id.startsWith(prefix) ? id.substring(prefix.length) : id },
return `${prefix}${projectUuid.replace(/-/g, '')}` )
})
return {
async send(message: string) {
logEvent.value(message, logProjectId.value)
},
}
}

View File

@ -10,5 +10,7 @@ interface FunctionInfo {
outputType: string | undefined outputType: string | undefined
} }
export { injectFn as injectFunctionInfo, provideFn as provideFunctionInfo } export const [provideFunctionInfo, injectFunctionInfo] = createContextStore(
const { provideFn, injectFn } = createContextStore('Function info', identity<FunctionInfo>) 'Function info',
identity<FunctionInfo>,
)

View File

@ -8,8 +8,7 @@ export interface GraphEditorLayers {
floating: Readonly<Ref<HTMLElement | undefined>> floating: Readonly<Ref<HTMLElement | undefined>>
} }
export { provideFn as provideGraphEditorLayers, injectFn as useGraphEditorLayers } export const [provideGraphEditorLayers, useGraphEditorLayers] = createContextStore(
const { provideFn, injectFn } = createContextStore(
'Graph editor layers', 'Graph editor layers',
identity<GraphEditorLayers>, identity<GraphEditorLayers>,
) )

View File

@ -1,6 +1,8 @@
import { useNavigator } from '@/composables/navigator' import { useNavigator } from '@/composables/navigator'
import { createContextStore } from '@/providers' import { createContextStore } from '@/providers'
export type GraphNavigator = ReturnType<typeof injectFn> export type GraphNavigator = ReturnType<typeof injectGraphNavigator>
export { injectFn as injectGraphNavigator, provideFn as provideGraphNavigator } export const [provideGraphNavigator, injectGraphNavigator] = createContextStore(
const { provideFn, injectFn } = createContextStore('graph navigator', useNavigator) 'graph navigator',
useNavigator,
)

View File

@ -1,5 +1,7 @@
import { useNodeColors } from '@/composables/nodeColors' import { useNodeColors } from '@/composables/nodeColors'
import { createContextStore } from '@/providers' import { createContextStore } from '@/providers'
export { injectFn as injectNodeColors, provideFn as provideNodeColors } export const [provideNodeColors, injectNodeColors] = createContextStore(
const { provideFn, injectFn } = createContextStore('node colors', useNodeColors) 'node colors',
useNodeColors,
)

View File

@ -1,5 +1,7 @@
import { useNodeCreation } from '@/composables/nodeCreation' import { useNodeCreation } from '@/composables/nodeCreation'
import { createContextStore } from '@/providers' import { createContextStore } from '@/providers'
export { injectFn as injectNodeCreation, provideFn as provideNodeCreation } export const [provideNodeCreation, injectNodeCreation] = createContextStore(
const { provideFn, injectFn } = createContextStore('node creation', useNodeCreation) 'node creation',
useNodeCreation,
)

View File

@ -8,8 +8,7 @@ import type { ExternalId } from 'ydoc-shared/yjsModel'
const SELECTION_BRUSH_MARGIN_PX = 6 const SELECTION_BRUSH_MARGIN_PX = 6
export { injectFn as injectGraphSelection, provideFn as provideGraphSelection } export const [provideGraphSelection, injectGraphSelection] = createContextStore(
const { provideFn, injectFn } = createContextStore(
'graph selection', 'graph selection',
( (
navigator: NavigatorComposable, navigator: NavigatorComposable,

View File

@ -1,5 +1,7 @@
import { useStackNavigator } from '@/composables/stackNavigator' import { useStackNavigator } from '@/composables/stackNavigator'
import { createContextStore } from '@/providers' import { createContextStore } from '@/providers'
export { injectFn as injectStackNavigator, provideFn as provideStackNavigator } export const [provideStackNavigator, injectStackNavigator] = createContextStore(
const { provideFn, injectFn } = createContextStore('graph stack navigator', useStackNavigator) 'graph stack navigator',
useStackNavigator,
)

View File

@ -5,8 +5,7 @@ import { type Ref } from 'vue'
export type GuiConfig = ApplicationConfigValue export type GuiConfig = ApplicationConfigValue
export { injectFn as injectGuiConfig, provideFn as provideGuiConfig } export const [provideGuiConfig, injectGuiConfig] = createContextStore(
const { provideFn, injectFn } = createContextStore(
'GUI config', 'GUI config',
identity<Ref<ApplicationConfigValue>>, identity<Ref<ApplicationConfigValue>>,
) )

View File

@ -13,8 +13,7 @@ const MISSING = Symbol('MISSING')
* When creating a store, you usually want to reexport the `provideFn` and `injectFn` as renamed * When creating a store, you usually want to reexport the `provideFn` and `injectFn` as renamed
* functions to make it easier to use the store in components without any name collisions. * functions to make it easier to use the store in components without any name collisions.
* ```ts * ```ts
* export { injectFn as injectSpecificThing, provideFn as provideSpecificThing } * export const [provideThing, useThing] = createContextStore('specific thing', thatThingFactory)
* const { provideFn, injectFn } = createContextStore('specific thing', thatThingFactory)
* ``` * ```
* *
* Under the hood, this uses Vue's [Context API], therefore it can only be used within a component's * Under the hood, this uses Vue's [Context API], therefore it can only be used within a component's
@ -93,5 +92,5 @@ export function createContextStore<F extends (...args: any[]) => any>(name: stri
return injected return injected
} }
return { provideFn, injectFn } as const return [provideFn, injectFn] as const
} }

View File

@ -1,8 +1,7 @@
import { createContextStore } from '@/providers' import { createContextStore } from '@/providers'
import { shallowRef, watch, type WatchSource } from 'vue' import { shallowRef, watch, type WatchSource } from 'vue'
export { injectFn as injectInteractionHandler, provideFn as provideInteractionHandler } export const [provideInteractionHandler, injectInteractionHandler] = createContextStore(
const { provideFn, injectFn } = createContextStore(
'Interaction handler', 'Interaction handler',
() => new InteractionHandler(), () => new InteractionHandler(),
) )

View File

@ -1,6 +1,6 @@
import { useKeyboard } from '@/composables/keyboard' import { useKeyboard } from '@/composables/keyboard'
import { createContextStore } from '@/providers' import { createContextStore } from '@/providers'
export { injectFn as injectKeyboard, provideFn as provideKeyboard } export const [provideKeyboard, injectKeyboard] = createContextStore('Keyboard watcher', () =>
useKeyboard(),
const { provideFn, injectFn } = createContextStore('Keyboard watcher', () => useKeyboard()) )

View File

@ -14,5 +14,4 @@ interface PortInfo {
connected: boolean connected: boolean
} }
export { injectFn as injectPortInfo, provideFn as providePortInfo } export const [providePortInfo, injectPortInfo] = createContextStore('Port info', identity<PortInfo>)
const { provideFn, injectFn } = createContextStore('Port info', identity<PortInfo>)

View File

@ -23,8 +23,7 @@ interface SelectionArrowInfo {
suppressArrow: boolean suppressArrow: boolean
} }
export { injectFn as injectSelectionArrow, provideFn as provideSelectionArrow } export const [provideSelectionArrow, injectSelectionArrow] = createContextStore(
const { provideFn, injectFn } = createContextStore(
'Selection arrow info', 'Selection arrow info',
identity<SelectionArrowInfo>, identity<SelectionArrowInfo>,
) )

View File

@ -71,7 +71,7 @@ function useSelectionButtons(
} }
export { injectFn as injectSelectionButtons, provideFn as provideSelectionButtons } export { injectFn as injectSelectionButtons, provideFn as provideSelectionButtons }
const { provideFn, injectFn } = createContextStore('Selection buttons', useSelectionButtons) const [provideFn, injectFn] = createContextStore('Selection buttons', useSelectionButtons)
export type ComponentAndSelectionButtons = DisjointKeysUnion<ComponentButtons, SelectionButtons> export type ComponentAndSelectionButtons = DisjointKeysUnion<ComponentButtons, SelectionButtons>

View File

@ -0,0 +1,68 @@
import { createContextStore } from '@/providers'
import * as iter from 'enso-common/src/utilities/data/iter'
import {
computed,
onUnmounted,
shallowReactive,
type Ref,
type ShallowReactive,
type Slot,
} from 'vue'
interface TooltipEntry {
contents: Ref<Slot | undefined>
key: symbol
}
export type TooltipRegistry = ReturnType<typeof useTooltipRegistry>
export const [provideTooltipRegistry, useTooltipRegistry] = createContextStore(
'tooltip registry',
() => {
type EntriesSet = ShallowReactive<Set<TooltipEntry>>
const hoveredElements = shallowReactive<Map<HTMLElement, EntriesSet>>(new Map())
const lastHoveredElement = computed(() => {
return iter.last(hoveredElements.keys())
})
return {
lastHoveredElement,
getElementEntry(el: HTMLElement | undefined): TooltipEntry | undefined {
const set = el && hoveredElements.get(el)
return set ? iter.last(set) : undefined
},
registerTooltip(slot: Ref<Slot | undefined>) {
const entry: TooltipEntry = {
contents: slot,
key: Symbol(),
}
const registeredElements = new Set<HTMLElement>()
onUnmounted(() => {
for (const el of registeredElements) {
methods.onTargetLeave(el)
}
})
const methods = {
onTargetEnter(target: HTMLElement) {
const entriesSet: EntriesSet = hoveredElements.get(target) ?? shallowReactive(new Set())
entriesSet.add(entry)
// make sure that the newly entered target is on top of the map
hoveredElements.delete(target)
hoveredElements.set(target, entriesSet)
registeredElements.add(target)
},
onTargetLeave(target: HTMLElement) {
const entriesSet = hoveredElements.get(target)
entriesSet?.delete(entry)
registeredElements.delete(target)
if (entriesSet?.size === 0) {
hoveredElements.delete(target)
}
},
}
return methods
},
}
},
)

View File

@ -1,66 +0,0 @@
import { createContextStore } from '@/providers'
import * as iter from 'enso-common/src/utilities/data/iter'
import {
computed,
onUnmounted,
shallowReactive,
type Ref,
type ShallowReactive,
type Slot,
} from 'vue'
interface TooltipEntry {
contents: Ref<Slot | undefined>
key: symbol
}
export type TooltipRegistry = ReturnType<typeof injectFn>
export { provideFn as provideTooltipRegistry, injectFn as useTooltipRegistry }
const { provideFn, injectFn } = createContextStore('tooltip registry', () => {
type EntriesSet = ShallowReactive<Set<TooltipEntry>>
const hoveredElements = shallowReactive<Map<HTMLElement, EntriesSet>>(new Map())
const lastHoveredElement = computed(() => {
return iter.last(hoveredElements.keys())
})
return {
lastHoveredElement,
getElementEntry(el: HTMLElement | undefined): TooltipEntry | undefined {
const set = el && hoveredElements.get(el)
return set ? iter.last(set) : undefined
},
registerTooltip(slot: Ref<Slot | undefined>) {
const entry: TooltipEntry = {
contents: slot,
key: Symbol(),
}
const registeredElements = new Set<HTMLElement>()
onUnmounted(() => {
for (const el of registeredElements) {
methods.onTargetLeave(el)
}
})
const methods = {
onTargetEnter(target: HTMLElement) {
const entriesSet: EntriesSet = hoveredElements.get(target) ?? shallowReactive(new Set())
entriesSet.add(entry)
// make sure that the newly entered target is on top of the map
hoveredElements.delete(target)
hoveredElements.set(target, entriesSet)
registeredElements.add(target)
},
onTargetLeave(target: HTMLElement) {
const entriesSet = hoveredElements.get(target)
entriesSet?.delete(entry)
registeredElements.delete(target)
if (entriesSet?.size === 0) {
hoveredElements.delete(target)
}
},
}
return methods
},
}
})

View File

@ -2,5 +2,7 @@ import { createContextStore } from '@/providers'
import { identity } from '@vueuse/core' import { identity } from '@vueuse/core'
import { type Ref } from 'vue' import { type Ref } from 'vue'
export { injectFn as injectVisibility, provideFn as provideVisibility } export const [provideVisibility, injectVisibility] = createContextStore(
const { provideFn, injectFn } = createContextStore('Visibility', identity<Ref<boolean>>) 'Visibility',
identity<Ref<boolean>>,
)

View File

@ -27,8 +27,8 @@ export interface VisualizationConfig {
setToolbarOverlay: (enableOverlay: boolean) => void setToolbarOverlay: (enableOverlay: boolean) => void
} }
export { provideFn as provideVisualizationConfig } export { provideVisualizationConfig }
const { provideFn, injectFn } = createContextStore( const [provideVisualizationConfig, injectVisualizationConfig] = createContextStore(
'Visualization config', 'Visualization config',
reactive<VisualizationConfig>, reactive<VisualizationConfig>,
) )
@ -38,5 +38,5 @@ const { provideFn, injectFn } = createContextStore(
/** TODO: Add docs */ /** TODO: Add docs */
export function useVisualizationConfig() { export function useVisualizationConfig() {
return injectFn() return injectVisualizationConfig()
} }

View File

@ -11,10 +11,8 @@ import type { WidgetEditHandlerParent } from './widgetRegistry/editHandler'
export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>> export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
export namespace WidgetInput { export namespace WidgetInput {
/** Returns widget-input data for the given AST expression or token. */ /** Returns widget-input data for the given AST tree or token. */
export function FromAst<A extends Ast.Expression | Ast.Token>( export function FromAst<A extends Ast.Ast | Ast.Token>(ast: A): WidgetInput & { value: A } {
ast: A,
): WidgetInput & { value: A } {
return { return {
portId: ast.id, portId: ast.id,
value: ast, value: ast,
@ -117,10 +115,10 @@ export interface WidgetInput {
*/ */
portId: PortId portId: PortId
/** /**
* An expected widget value. If Ast.Expression or Ast.Token, the widget represents an existing part of * An expected widget value. If Ast.Ast or Ast.Token, the widget represents an existing part of
* code. If string, it may be e.g. a default value of an argument. * code. If string, it may be e.g. a default value of an argument.
*/ */
value: Ast.Expression | Ast.Token | string | undefined value: Ast.Ast | Ast.Token | string | undefined
/** An expected type which widget should set. */ /** An expected type which widget should set. */
expectedType?: Typename | undefined expectedType?: Typename | undefined
/** Configuration provided by engine. */ /** Configuration provided by engine. */
@ -165,7 +163,7 @@ export interface WidgetProps<T> {
* Every widget type should set it's name as `metadataKey`. * Every widget type should set it's name as `metadataKey`.
* *
* The handlers interested in a specific port update should apply it using received edit. The edit * The handlers interested in a specific port update should apply it using received edit. The edit
* is committed in {@link NodeWidgetTree}. * is committed in {@link ComponentWidgetTree}.
*/ */
export interface WidgetUpdate { export interface WidgetUpdate {
edit?: Ast.MutableModule | undefined edit?: Ast.MutableModule | undefined
@ -334,8 +332,7 @@ function makeInputMatcher<T extends WidgetInput>(
} }
} }
export { injectFn as injectWidgetRegistry, provideFn as provideWidgetRegistry } export const [provideWidgetRegistry, injectWidgetRegistry] = createContextStore(
const { provideFn, injectFn } = createContextStore(
'Widget registry', 'Widget registry',
(db: GraphDb) => new WidgetRegistry(db), (db: GraphDb) => new WidgetRegistry(db),
) )

View File

@ -1,37 +1,28 @@
import { createContextStore } from '@/providers' import { createContextStore } from '@/providers'
import { type WidgetEditHandlerRoot } from '@/providers/widgetRegistry/editHandler' import { type WidgetEditHandlerRoot } from '@/providers/widgetRegistry/editHandler'
import { useGraphStore } from '@/stores/graph'
import { type NodeId } from '@/stores/graph/graphDatabase'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import type { Vec2 } from '@/util/data/vec2'
import { computed, proxyRefs, shallowRef, type Ref, type ShallowUnwrapRef } from 'vue' import { computed, proxyRefs, shallowRef, type Ref, type ShallowUnwrapRef } from 'vue'
import { AstId } from 'ydoc-shared/ast'
import { ExternalId } from 'ydoc-shared/yjsModel'
export { injectFn as injectWidgetTree, provideFn as provideWidgetTree } export const [provideWidgetTree, injectWidgetTree] = createContextStore(
const { provideFn, injectFn } = createContextStore(
'Widget tree', 'Widget tree',
( (
astRoot: Ref<Ast.Expression>, externalId: Ref<ExternalId>,
nodeId: Ref<NodeId>, rootElement: Ref<HTMLElement | undefined>,
nodeElement: Ref<HTMLElement | undefined>, conditionalPorts: Ref<Set<Ast.AstId> | undefined>,
nodeSize: Ref<Vec2>,
potentialSelfArgumentId: Ref<Ast.AstId | undefined>,
conditionalPorts: Ref<Set<Ast.AstId>>,
extended: Ref<boolean>, extended: Ref<boolean>,
hasActiveAnimations: Ref<boolean>, hasActiveAnimations: Ref<boolean>,
potentialSelfArgumentId: Ref<AstId | undefined>,
) => { ) => {
const graph = useGraphStore()
const nodeSpanStart = computed(() => graph.moduleSource.getSpan(astRoot.value.id)![0])
const { setCurrentEditRoot, currentEdit } = useCurrentEdit() const { setCurrentEditRoot, currentEdit } = useCurrentEdit()
return proxyRefs({ return proxyRefs({
astRoot, externalId,
nodeId, rootElement,
nodeElement,
nodeSize,
potentialSelfArgumentId,
conditionalPorts, conditionalPorts,
extended, extended,
nodeSpanStart,
hasActiveAnimations, hasActiveAnimations,
potentialSelfArgumentId,
setCurrentEditRoot, setCurrentEditRoot,
currentEdit, currentEdit,
}) })

View File

@ -2,8 +2,10 @@ import { createContextStore } from '@/providers'
import type { WidgetComponent, WidgetInput, WidgetUpdate } from '@/providers/widgetRegistry' import type { WidgetComponent, WidgetInput, WidgetUpdate } from '@/providers/widgetRegistry'
import { identity } from '@vueuse/core' import { identity } from '@vueuse/core'
export { injectFn as injectWidgetUsageInfo, provideFn as provideWidgetUsageInfo } export const [provideWidgetUsageInfo, injectWidgetUsageInfo] = createContextStore(
const { provideFn, injectFn } = createContextStore('Widget usage info', identity<WidgetUsageInfo>) 'Widget usage info',
identity<WidgetUsageInfo>,
)
/** /**
* Information about a widget that can be accessed in its child views. Currently this is used during * Information about a widget that can be accessed in its child views. Currently this is used during

View File

@ -2,6 +2,7 @@ import { computeNodeColor } from '@/composables/nodeColors'
import { ComputedValueRegistry, type ExpressionInfo } from '@/stores/project/computedValueRegistry' import { ComputedValueRegistry, type ExpressionInfo } from '@/stores/project/computedValueRegistry'
import { SuggestionDb, type Group } from '@/stores/suggestionDatabase' import { SuggestionDb, type Group } from '@/stores/suggestionDatabase'
import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry' import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry'
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import type { AstId, NodeMetadata } from '@/util/ast/abstract' import type { AstId, NodeMetadata } from '@/util/ast/abstract'
import { MutableModule } from '@/util/ast/abstract' import { MutableModule } from '@/util/ast/abstract'
@ -12,7 +13,12 @@ import { recordEqual } from '@/util/data/object'
import { unwrap } from '@/util/data/result' import { unwrap } from '@/util/data/result'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb' import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
import { tryIdentifier } from '@/util/qualifiedName' import {
isIdentifierOrOperatorIdentifier,
isQualifiedName,
normalizeQualifiedName,
tryIdentifier,
} from '@/util/qualifiedName'
import { import {
nonReactiveView, nonReactiveView,
resumeReactivity, resumeReactivity,
@ -23,7 +29,12 @@ import * as objects from 'enso-common/src/utilities/data/object'
import * as set from 'lib0/set' import * as set from 'lib0/set'
import { reactive, ref, shallowReactive, type Ref, type WatchStopHandle } from 'vue' import { reactive, ref, shallowReactive, type Ref, type WatchStopHandle } from 'vue'
import { type SourceDocument } from 'ydoc-shared/ast/sourceDocument' import { type SourceDocument } from 'ydoc-shared/ast/sourceDocument'
import type { MethodCall, StackItem } from 'ydoc-shared/languageServerTypes' import {
methodPointerEquals,
type MethodCall,
type MethodPointer,
type StackItem,
} from 'ydoc-shared/languageServerTypes'
import type { Opt } from 'ydoc-shared/util/data/opt' import type { Opt } from 'ydoc-shared/util/data/opt'
import type { ExternalId, VisualizationMetadata } from 'ydoc-shared/yjsModel' import type { ExternalId, VisualizationMetadata } from 'ydoc-shared/yjsModel'
import { isUuid, visMetadataEquals } from 'ydoc-shared/yjsModel' import { isUuid, visMetadataEquals } from 'ydoc-shared/yjsModel'
@ -181,7 +192,7 @@ export class GraphDb {
} }
/** TODO: Add docs */ /** TODO: Add docs */
isNodeId(externalId: ExternalId): boolean { isNodeId(externalId: ExternalId): externalId is NodeId {
return this.nodeIdToNode.has(asNodeId(externalId)) return this.nodeIdToNode.has(asNodeId(externalId))
} }
@ -443,6 +454,42 @@ export class GraphDb {
return id ? this.idToExternalMap.get(id) : undefined return id ? this.idToExternalMap.get(id) : undefined
} }
/**
* Synchronously replace all instances of specific method pointer usage within the value registry and
* suggestion database.
*
* FIXME: This is a hack in order to make function renaming from within that function work correctly.
* Execution contexts don't send expression updates about their parent frames, so we end up with an
* outdated methodPointer on the parent frame's expression. We have to update the valueRegistry and
* suggestionDb entries to keep it working correctly. Both need to be updated synchronously to avoid
* flashing.
*/
insertSyntheticMethodPointerUpdate(
oldMethodPointer: MethodPointer,
newMethodPointer: MethodPointer,
) {
for (const value of this.valuesRegistry.db.values()) {
if (
value.methodCall != null &&
methodPointerEquals(value.methodCall.methodPointer, oldMethodPointer)
) {
value.methodCall.methodPointer = newMethodPointer
}
}
const suggestion = this.suggestionDb.findByMethodPointer(oldMethodPointer)
const suggestionEntry = suggestion != null ? this.suggestionDb.get(suggestion) : null
if (suggestionEntry != null) {
DEV: assert(isQualifiedName(newMethodPointer.module))
DEV: assert(isQualifiedName(newMethodPointer.definedOnType))
DEV: assert(isIdentifierOrOperatorIdentifier(newMethodPointer.name))
Object.assign(suggestionEntry, {
definedIn: normalizeQualifiedName(newMethodPointer.module),
memberOf: normalizeQualifiedName(newMethodPointer.definedOnType),
name: newMethodPointer.name,
})
}
}
/** TODO: Add docs */ /** TODO: Add docs */
static Mock(registry = ComputedValueRegistry.Mock(), db = new SuggestionDb()): GraphDb { static Mock(registry = ComputedValueRegistry.Mock(), db = new SuggestionDb()): GraphDb {
return new GraphDb(db, ref([]), registry) return new GraphDb(db, ref([]), registry)

View File

@ -15,7 +15,7 @@ import {
import { useUnconnectedEdges, type UnconnectedEdge } from '@/stores/graph/unconnectedEdges' import { useUnconnectedEdges, type UnconnectedEdge } from '@/stores/graph/unconnectedEdges'
import { type ProjectStore } from '@/stores/project' import { type ProjectStore } from '@/stores/project'
import { type SuggestionDbStore } from '@/stores/suggestionDatabase' import { type SuggestionDbStore } from '@/stores/suggestionDatabase'
import { assert, bail } from '@/util/assert' import { assert, assertNever, bail } from '@/util/assert'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import type { AstId, Identifier, MutableModule } from '@/util/ast/abstract' import type { AstId, Identifier, MutableModule } from '@/util/ast/abstract'
import { isAstId, isIdentifier } from '@/util/ast/abstract' import { isAstId, isIdentifier } from '@/util/ast/abstract'
@ -23,9 +23,9 @@ import { reactiveModule } from '@/util/ast/reactive'
import { partition } from '@/util/data/array' import { partition } from '@/util/data/array'
import { stringUnionToArray, type Events } from '@/util/data/observable' import { stringUnionToArray, type Events } from '@/util/data/observable'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { Err, mapOk, Ok, unwrap, type Result } from '@/util/data/result' import { andThen, Err, mapOk, Ok, unwrap, type Result } from '@/util/data/result'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { normalizeQualifiedName, qnLastSegment, tryQualifiedName } from '@/util/qualifiedName' import { normalizeQualifiedName, tryQualifiedName } from '@/util/qualifiedName'
import { useWatchContext } from '@/util/reactivity' import { useWatchContext } from '@/util/reactivity'
import { computedAsync } from '@vueuse/core' import { computedAsync } from '@vueuse/core'
import * as iter from 'enso-common/src/utilities/data/iter' import * as iter from 'enso-common/src/utilities/data/iter'
@ -82,7 +82,7 @@ export class PortViewInstance {
} }
export type GraphStore = ReturnType<typeof useGraphStore> export type GraphStore = ReturnType<typeof useGraphStore>
export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createContextStore( export const [provideGraphStore, useGraphStore] = createContextStore(
'graph', 'graph',
(proj: ProjectStore, suggestionDb: SuggestionDbStore) => { (proj: ProjectStore, suggestionDb: SuggestionDbStore) => {
proj.setObservedFileName('Main.enso') proj.setObservedFileName('Main.enso')
@ -140,10 +140,32 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
}, },
) )
const methodAst = computed<Result<Ast.FunctionDef>>(() => const immediateMethodAst = computed<Result<Ast.FunctionDef>>(() =>
syncModule.value ? getExecutedMethodAst(syncModule.value) : Err('AST not yet initialized'), syncModule.value ? getExecutedMethodAst(syncModule.value) : Err('AST not yet initialized'),
) )
// When renaming a function, we temporarily lose track of edited function AST. Ensure that we
// still resolve it before the refactor code change is received.
const lastKnownResolvedMethodAstId = ref<AstId>()
watch(immediateMethodAst, (ast) => {
if (ast.ok) lastKnownResolvedMethodAstId.value = ast.value.id
})
const fallbackMethodAst = computed(() => {
const id = lastKnownResolvedMethodAstId.value
const ast = id != null ? syncModule.value?.get(id) : undefined
if (ast instanceof Ast.FunctionDef) return ast
return undefined
})
const methodAst = computed(() => {
const imm = immediateMethodAst.value
if (imm.ok) return imm
const flb = fallbackMethodAst.value
if (flb) return Ok(flb)
return imm
})
const watchContext = useWatchContext() const watchContext = useWatchContext()
const afterUpdate: (() => void)[] = [] const afterUpdate: (() => void)[] = []
@ -167,20 +189,26 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
db.updateBindings(methodAst.value.value, moduleSource) db.updateBindings(methodAst.value.value, moduleSource)
}) })
function getExecutedMethodAst(module?: Ast.Module): Result<Ast.FunctionDef> { const currentMethodPointer = computed((): Result<MethodPointer> => {
const executionStackTop = proj.executionContext.getStackTop() const executionStackTop = proj.executionContext.getStackTop()
switch (executionStackTop.type) { switch (executionStackTop.type) {
case 'ExplicitCall': { case 'ExplicitCall': {
return getMethodAst(executionStackTop.methodPointer, module) return Ok(executionStackTop.methodPointer)
} }
case 'LocalCall': { case 'LocalCall': {
const exprId = executionStackTop.expressionId const exprId = executionStackTop.expressionId
const info = db.getExpressionInfo(exprId) const info = db.getExpressionInfo(exprId)
const ptr = info?.methodCall?.methodPointer const ptr = info?.methodCall?.methodPointer
if (!ptr) return Err("Unknown method pointer of execution stack's top frame") if (!ptr) return Err("Unknown method pointer of execution stack's top frame")
return getMethodAst(ptr, module) return Ok(ptr)
} }
default:
return assertNever(executionStackTop)
} }
})
function getExecutedMethodAst(module?: Ast.Module): Result<Ast.FunctionDef> {
return andThen(currentMethodPointer.value, (ptr) => getMethodAst(ptr, module))
} }
function getMethodAst(ptr: MethodPointer, edit?: Ast.Module): Result<Ast.FunctionDef> { function getMethodAst(ptr: MethodPointer, edit?: Ast.Module): Result<Ast.FunctionDef> {
@ -748,8 +776,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
if (expressionInfo?.methodCall == null) return false if (expressionInfo?.methodCall == null) return false
const definedOnType = tryQualifiedName(expressionInfo.methodCall.methodPointer.definedOnType) const definedOnType = tryQualifiedName(expressionInfo.methodCall.methodPointer.definedOnType)
const openModuleName = qnLastSegment(proj.modulePath.value) if (definedOnType.ok && definedOnType.value !== proj.modulePath.value) {
if (definedOnType.ok && qnLastSegment(definedOnType.value) !== openModuleName) {
// Cannot enter node that is not defined on current module. // Cannot enter node that is not defined on current module.
// TODO: Support entering nodes in other modules within the same project. // TODO: Support entering nodes in other modules within the same project.
return false return false
@ -813,11 +840,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
addMissingImportsDisregardConflicts, addMissingImportsDisregardConflicts,
isConnectedTarget, isConnectedTarget,
nodeCanBeEntered, nodeCanBeEntered,
currentMethodPointer() { currentMethodPointer,
const currentMethod = proj.executionContext.getStackTop()
if (currentMethod.type === 'ExplicitCall') return currentMethod.methodPointer
return db.getExpressionInfo(currentMethod.expressionId)?.methodCall?.methodPointer
},
modulePath, modulePath,
connectedEdges, connectedEdges,
...unconnectedEdges, ...unconnectedEdges,

View File

@ -0,0 +1,111 @@
import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
import { createContextStore } from '@/providers'
import { GraphNavigator } from '@/providers/graphNavigator'
import { injectVisibility } from '@/providers/visibility'
import { Ok, Result } from '@/util/data/result'
import { Vec2 } from '@/util/data/vec2'
import { ToValue } from '@/util/reactivity'
import { until } from '@vueuse/core'
import { encoding } from 'lib0'
import { computed, proxyRefs, ref, toValue } from 'vue'
import { encodeMethodPointer, MethodPointer } from 'ydoc-shared/languageServerTypes'
import { GraphStore } from './graph'
export type PersistedStore = ReturnType<typeof usePersisted>
export const [providePersisted, usePersisted] = createContextStore(
'persisted',
(
projectId: ToValue<string>,
graphStore: GraphStore,
graphNavigator: GraphNavigator,
onRestore: () => void,
) => {
const graphRightDock = ref<boolean>()
const graphRightDockTab = ref<string>()
const graphRightDockWidth = ref<number>()
/**
* JSON serializable representation of graph state saved in localStorage. The names of fields here
* are kept relatively short, because it will be common to store hundreds of them within one big
* JSON object, and serialize it quite often whenever the state is modified. Shorter keys end up
* costing less localStorage space and slightly reduce serialization overhead.
*/
interface GraphStoredState {
/** Navigator position X */
x?: number | undefined
/** Navigator position Y */
y?: number | undefined
/** Navigator scale */
s?: number | undefined
/** Whether or not the documentation panel is open. */
doc?: boolean | undefined
/** The selected tab in the right-side panel. */
rtab?: string | undefined
/** Width of the right dock. */
rwidth?: number | undefined
}
const visible = injectVisibility()
const visibleAreasReady = computed(() => {
const nodesCount = graphStore.db.nodeIdToNode.size
const visibleNodeAreas = graphStore.visibleNodeAreas
return nodesCount > 0 && visibleNodeAreas.length == nodesCount
})
// Client graph state needs to be stored separately for:
// - each project
// - each function within the project
function encodeKey(enc: encoding.Encoder, methodPointer: Result<MethodPointer>) {
encoding.writeVarString(enc, toValue(projectId))
if (methodPointer.ok) encodeMethodPointer(enc, methodPointer.value)
}
const storageOps = useSyncLocalStorage<GraphStoredState>({
storageKey: 'enso-graph-state',
mapKeyEncoder: (enc) => encodeKey(enc, graphStore.currentMethodPointer),
debounce: 200,
captureState() {
return {
x: graphNavigator.targetCenter.x,
y: graphNavigator.targetCenter.y,
s: graphNavigator.targetScale,
doc: graphRightDock.value,
rtab: graphRightDockTab.value,
rwidth: graphRightDockWidth.value ?? undefined,
} satisfies GraphStoredState
},
async restoreState(restored, abort) {
if (restored) {
const pos = new Vec2(restored.x ?? 0, restored.y ?? 0)
const scale = restored.s ?? 1
graphNavigator.setCenterAndScale(pos, scale)
graphRightDock.value = restored.doc ?? undefined
graphRightDockTab.value = restored.rtab ?? undefined
graphRightDockWidth.value = restored.rwidth ?? undefined
} else {
await until(visibleAreasReady).toBe(true)
await until(visible).toBe(true)
if (!abort.aborted) onRestore()
}
},
})
function handleModifiedMethodPointer(
oldMethodPointer: MethodPointer,
newMethodPointer: MethodPointer,
) {
storageOps.moveToNewKey(
(enc) => encodeKey(enc, Ok(oldMethodPointer)),
(enc) => encodeKey(enc, Ok(newMethodPointer)),
)
}
return proxyRefs({
graphRightDock,
graphRightDockTab,
graphRightDockWidth,
handleModifiedMethodPointer,
})
},
)

View File

@ -1,5 +1,5 @@
import { assert } from '@/util/assert' import { assert } from '@/util/assert'
import { findIndexOpt } from '@/util/data/array' import { findDifferenceIndex } from '@/util/data/array'
import { isSome, type Opt } from '@/util/data/opt' import { isSome, type Opt } from '@/util/data/opt'
import { Err, Ok, ResultError, type Result } from '@/util/data/result' import { Err, Ok, ResultError, type Result } from '@/util/data/result'
import { AsyncQueue, type AbortScope } from '@/util/net' import { AsyncQueue, type AbortScope } from '@/util/net'
@ -240,12 +240,18 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
} }
} }
/** TODO: Add docs */ /**
* The stack of execution frames that we want to currently inspect. The actual stack
* state in the language server can differ, since it is updated asynchronously.
*/
get desiredStack() { get desiredStack() {
return this._desiredStack return this._desiredStack
} }
/** TODO: Add docs */ /**
* Set the currently desired stack of excution frames. This will cause appropriate
* stack push/pop operations to be sent to the language server.
*/
set desiredStack(stack: StackItem[]) { set desiredStack(stack: StackItem[]) {
this._desiredStack.length = 0 this._desiredStack.length = 0
this._desiredStack.push(...stack) this._desiredStack.push(...stack)
@ -400,28 +406,36 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
const state = newState const state = newState
if (state.status !== 'created') if (state.status !== 'created')
return Err('Cannot sync stack when execution context is not created') return Err('Cannot sync stack when execution context is not created')
const firstDifferent = while (true) {
findIndexOpt(this._desiredStack, (item, index) => { // Since this is an async function, the desired state can change inbetween individual API calls.
const stateStack = state.stack[index] // We need to compare the desired stack state against current state on every loop iteration.
return stateStack == null || !stackItemsEqual(item, stateStack)
}) ?? this._desiredStack.length const firstDifferent = findDifferenceIndex(
for (let i = state.stack.length; i > firstDifferent; --i) { this._desiredStack,
const popResult = await this.withBackoff( state.stack,
() => this.lsRpc.popExecutionContextItem(this.id), stackItemsEqual,
'Failed to pop execution stack frame',
) )
if (popResult.ok) state.stack.pop()
else return popResult if (state.stack.length > firstDifferent) {
} // Found a difference within currently set stack context. We need to pop our way up to it.
for (let i = state.stack.length; i < this._desiredStack.length; ++i) { const popResult = await this.withBackoff(
const newItem = this._desiredStack[i]! () => this.lsRpc.popExecutionContextItem(this.id),
const pushResult = await this.withBackoff( 'Failed to pop execution stack frame',
() => this.lsRpc.pushExecutionContextItem(this.id, newItem), )
'Failed to push execution stack frame', if (popResult.ok) state.stack.pop()
) else return popResult
if (pushResult.ok) state.stack.push(newItem) } else if (state.stack.length < this._desiredStack.length) {
else return pushResult // Desired stack is matching current state, but it is longer. We need to push the next item.
const newItem = this._desiredStack[state.stack.length]!
const pushResult = await this.withBackoff(
() => this.lsRpc.pushExecutionContextItem(this.id, newItem),
'Failed to push execution stack frame',
)
if (pushResult.ok) state.stack.push(newItem)
else return pushResult
} else break
} }
return Ok() return Ok()
} }

View File

@ -101,7 +101,7 @@ export type ProjectStore = ReturnType<typeof useProjectStore>
* performed using a CRDT data types from Yjs. Once the data is synchronized with a "LS bridge" * performed using a CRDT data types from Yjs. Once the data is synchronized with a "LS bridge"
* client, it is submitted to the language server as a document update. * client, it is submitted to the language server as a document update.
*/ */
export const { provideFn: provideProjectStore, injectFn: useProjectStore } = createContextStore( export const [provideProjectStore, useProjectStore] = createContextStore(
'project', 'project',
(props: { projectId: string; renameProject: (newName: string) => void }) => { (props: { projectId: string; renameProject: (newName: string) => void }) => {
const { projectId, renameProject: renameProjectBackend } = props const { projectId, renameProject: renameProjectBackend } = props

View File

@ -6,7 +6,7 @@ import { ExecutionEnvironment } from 'ydoc-shared/languageServerTypes'
import { ExternalId } from 'ydoc-shared/yjsModel' import { ExternalId } from 'ydoc-shared/yjsModel'
/** Allows to recompute certain expressions (usually nodes). */ /** Allows to recompute certain expressions (usually nodes). */
export const { provideFn: provideNodeExecution, injectFn: useNodeExecution } = createContextStore( export const [provideNodeExecution, useNodeExecution] = createContextStore(
'nodeExecution', 'nodeExecution',
(projectStore: ProjectStore) => { (projectStore: ProjectStore) => {
const recomputationInProgress = reactive(new Set<ExternalId>()) const recomputationInProgress = reactive(new Set<ExternalId>())

View File

@ -0,0 +1,133 @@
import { createContextStore } from '@/providers'
import { computedFallback } from '@/util/reactivity'
import { defineTabButtons, ExtractTabs } from '@/util/tabs'
import { computed, proxyRefs, ref, toRef } from 'vue'
import { assertNever } from 'ydoc-shared/util/assert'
import { unwrapOr } from 'ydoc-shared/util/data/result'
import { GraphStore } from './graph'
import { PersistedStore } from './persisted'
import { useSettings } from './settings'
export type RightDockStore = ReturnType<typeof useRightDock>
export type RightDockTab = ExtractTabs<typeof tabButtons>
export const { buttons: tabButtons, isValidTab } = defineTabButtons([
{ tab: 'docs', icon: 'text', title: 'Documentation Editor' },
{ tab: 'help', icon: 'help', title: 'Component Help' },
])
export enum StorageMode {
Default,
ComponentBrowser,
}
export const [provideRightDock, useRightDock] = createContextStore(
'rightDock',
(graph: GraphStore, persisted: PersistedStore) => {
const inspectedAst = computed(() => unwrapOr(graph.methodAst, undefined))
const inspectedMethodPointer = computed(() => unwrapOr(graph.currentMethodPointer, undefined))
const { user: userSettings } = useSettings()
const storageMode = ref(StorageMode.Default)
const markdownDocs = computed(() => inspectedAst.value?.mutableDocumentationMarkdown())
const defaultVisible = computedFallback(
toRef(persisted, 'graphRightDock'),
() => (markdownDocs.value?.length ?? 0) > 0,
)
const defaultTab = computed<RightDockTab>({
get: () => {
const fromStorage = persisted.graphRightDockTab
return fromStorage && isValidTab(fromStorage) ? fromStorage : 'docs'
},
set: (value) => (persisted.graphRightDockTab = value),
})
const width = toRef(persisted, 'graphRightDockWidth')
const cbVisible = ref(true)
const cbTab = ref<RightDockTab>('help')
const displayedTab = computed<RightDockTab>(() => {
switch (storageMode.value) {
case StorageMode.Default:
return defaultTab.value
case StorageMode.ComponentBrowser:
return (
userSettings.value.showHelpForCB ? 'help'
: defaultVisible.value ? defaultTab.value
: cbTab.value
)
default:
return assertNever(storageMode.value)
}
})
function switchToTab(tab: RightDockTab) {
switch (storageMode.value) {
case StorageMode.Default:
defaultTab.value = tab
break
case StorageMode.ComponentBrowser:
cbTab.value = tab
userSettings.value.showHelpForCB = tab === 'help'
if (defaultVisible.value) defaultTab.value = tab
break
default:
return assertNever(storageMode.value)
}
}
const visible = computed(() => {
switch (storageMode.value) {
case StorageMode.Default:
return defaultVisible.value
case StorageMode.ComponentBrowser:
return userSettings.value.showHelpForCB || cbVisible.value || defaultVisible.value
default:
return assertNever(storageMode.value)
}
})
function setVisible(newVisible: boolean) {
switch (storageMode.value) {
case StorageMode.Default:
defaultVisible.value = newVisible
break
case StorageMode.ComponentBrowser:
cbVisible.value = newVisible
userSettings.value.showHelpForCB = newVisible
if (!newVisible) defaultVisible.value = false
break
default:
return assertNever(storageMode.value)
}
}
/** Show specific tab if it is not visible. Otherwise, close the right dock. */
function toggleVisible(specificTab?: RightDockTab | undefined) {
if (specificTab == null || displayedTab.value == specificTab) {
setVisible(!visible.value)
} else {
switchToTab(specificTab)
setVisible(true)
}
}
return proxyRefs({
markdownDocs,
displayedTab,
inspectedAst,
inspectedMethodPointer,
width,
visible,
setStorageMode(mode: StorageMode) {
storageMode.value = mode
},
switchToTab,
setVisible,
toggleVisible,
})
},
)

View File

@ -12,25 +12,22 @@ const defaultUserSettings = {
showHelpForCB: true, showHelpForCB: true,
} }
export const { injectFn: useSettings, provideFn: provideSettings } = createContextStore( export const [provideSettings, useSettings] = createContextStore('settings', () => {
'settings', const user = ref<UserSettings>(defaultUserSettings)
() => {
const user = ref<UserSettings>(defaultUserSettings)
useSyncLocalStorage<UserSettings>({ useSyncLocalStorage<UserSettings>({
storageKey: 'enso-user-settings', storageKey: 'enso-user-settings',
mapKeyEncoder: () => {}, mapKeyEncoder: () => {},
debounce: 200, debounce: 200,
captureState() { captureState() {
return user.value ?? defaultUserSettings return user.value ?? defaultUserSettings
}, },
async restoreState(restored) { async restoreState(restored) {
if (restored) { if (restored) {
user.value = { ...defaultUserSettings, ...restored } user.value = { ...defaultUserSettings, ...restored }
} }
}, },
}) })
return { user } return { user }
}, })
)

View File

@ -67,3 +67,14 @@ export function documentationData(
isUnstable: isSome(tagValue(parsed, 'Unstable')) || isSome(tagValue(parsed, 'Advanced')), isUnstable: isSome(tagValue(parsed, 'Unstable')) || isSome(tagValue(parsed, 'Advanced')),
} }
} }
/**
* Get the ICON tag value from the documentation block. Only use this function
* if all you need is icon, since the docs parsing is an expensive operation.
* @param documentation String representation of documentation block.
* @returns Value of icon tag within the docs.
*/
export function getDocsIcon(documentation: Opt<string>): Opt<Icon> {
const parsed = documentation != null ? parseDocs(documentation) : []
return tagValue(parsed, 'Icon') as Opt<Icon>
}

View File

@ -171,8 +171,9 @@ class Synchronizer {
/** {@link useSuggestionDbStore} composable object */ /** {@link useSuggestionDbStore} composable object */
export type SuggestionDbStore = ReturnType<typeof useSuggestionDbStore> export type SuggestionDbStore = ReturnType<typeof useSuggestionDbStore>
export const { provideFn: provideSuggestionDbStore, injectFn: useSuggestionDbStore } = export const [provideSuggestionDbStore, useSuggestionDbStore] = createContextStore(
createContextStore('suggestionDatabase', (projectStore: ProjectStore) => { 'suggestionDatabase',
(projectStore: ProjectStore) => {
const entries = new SuggestionDb() const entries = new SuggestionDb()
const groups = ref<Group[]>([]) const groups = ref<Group[]>([])
@ -194,4 +195,5 @@ export const { provideFn: provideSuggestionDbStore, injectFn: useSuggestionDbSto
const _synchronizer = new Synchronizer(projectStore, entries, groups) const _synchronizer = new Synchronizer(projectStore, entries, groups)
return proxyRefs({ entries: markRaw(entries), groups, _synchronizer, mockSuggestion }) return proxyRefs({ entries: markRaw(entries), groups, _synchronizer, mockSuggestion })
}) },
)

View File

@ -78,8 +78,9 @@ const builtinVisualizationsByName = Object.fromEntries(
builtinVisualizations.map((viz) => [viz.name, viz]), builtinVisualizations.map((viz) => [viz.name, viz]),
) )
export const { provideFn: provideVisualizationStore, injectFn: useVisualizationStore } = export const [provideVisualizationStore, useVisualizationStore] = createContextStore(
createContextStore('visualization', (proj: ProjectStore) => { 'visualization',
(proj: ProjectStore) => {
const cache = reactive(new Map<VisualizationId, Promise<VisualizationModule>>()) const cache = reactive(new Map<VisualizationId, Promise<VisualizationModule>>())
/** /**
* A map from file path to {@link AbortController}, so that a file change event can stop previous * A map from file path to {@link AbortController}, so that a file change event can stop previous
@ -283,4 +284,5 @@ export const { provideFn: provideVisualizationStore, injectFn: useVisualizationS
} }
return { types, get, icon } return { types, get, icon }
}) },
)

View File

@ -1,4 +1,4 @@
import { partitionPoint } from '@/util/data/array' import { findDifferenceIndex, partitionPoint } from '@/util/data/array'
import { fc, test } from '@fast-check/vitest' import { fc, test } from '@fast-check/vitest'
import { expect } from 'vitest' import { expect } from 'vitest'
@ -38,3 +38,34 @@ test.prop({
const target = arr[i]! const target = arr[i]!
expect(partitionPoint(arr, (n) => n > target)).toEqual(i) expect(partitionPoint(arr, (n) => n > target)).toEqual(i)
}) })
test.prop({
array: fc.array(fc.anything()),
})('findDifferenceIndex (same array)', ({ array }) => {
expect(findDifferenceIndex(array, array)).toEqual(array.length)
})
test.prop({
array: fc.array(fc.anything()),
})('findDifferenceIndex (empty)', ({ array }) => {
expect(findDifferenceIndex(array, [])).toEqual(0)
})
test.prop({
arr1: fc.array(fc.integer()),
arr2: fc.array(fc.integer()),
returnedIndex: fc.context(),
})('findDifferenceIndex (arbitrary arrays)', ({ arr1, arr2, returnedIndex }) => {
const differenceIndex = findDifferenceIndex(arr1, arr2)
const differenceIndexInverse = findDifferenceIndex(arr2, arr1)
returnedIndex.log(`${differenceIndex}`)
expect(differenceIndex).toEqual(differenceIndexInverse)
const shorterArrayLen = Math.min(arr1.length, arr2.length)
expect(differenceIndex).toBeLessThanOrEqual(shorterArrayLen)
expect(arr1.slice(0, differenceIndex)).toEqual(arr2.slice(0, differenceIndex))
if (differenceIndex < shorterArrayLen) {
expect(arr1.slice(differenceIndex)).not.toEqual(arr2.slice(differenceIndex))
expect(arr1[differenceIndex]).not.toEqual(arr2[differenceIndex])
}
})

View File

@ -88,3 +88,17 @@ export function partition<T>(array: Iterable<T>, pred: (elem: T) => boolean): [T
return [truthy, falsy] return [truthy, falsy]
} }
/**
* Find smallest index at which two arrays differ. Returns an index past the array (i.e. array length) when both arrays are equal.
*/
export function findDifferenceIndex<T>(
lhs: T[],
rhs: T[],
equals = (a: T, b: T) => a === b,
): number {
return (
findIndexOpt(lhs, (item, index) => index >= rhs.length || !equals(item, rhs[index]!)) ??
lhs.length
)
}

View File

@ -0,0 +1,20 @@
import { assert } from './assert'
import { Icon } from './iconName'
export type TabButton<T> = { tab: T; title: string; icon: Icon }
export type ExtractTabs<Buttons> = Buttons extends TabButton<infer T>[] ? T : never
/**
* Define type-safe tab button list. Additionally generates a tab name validator funciton.
*/
export function defineTabButtons<T extends string>(buttons: TabButton<T>[]) {
const tabs = new Set<T>(buttons.map((b) => b.tab))
assert(tabs.size == buttons.length, 'Provided tab buttons are not unique.')
return {
isValidTab(tab: string): tab is T {
return tabs.has(tab as T)
},
buttons,
}
}

View File

@ -31,3 +31,11 @@ export function isNone(value: Opt<any>): value is null | undefined {
export function mapOr<T, R>(optional: Opt<T>, fallback: R, mapper: (value: T) => R): R { export function mapOr<T, R>(optional: Opt<T>, fallback: R, mapper: (value: T) => R): R {
return isSome(optional) ? mapper(optional) : fallback return isSome(optional) ? mapper(optional) : fallback
} }
/**
* Map the value inside the given {@link Opt} if it is not nullish,
* else return undefined.
*/
export function mapOrUndefined<T, R>(optional: Opt<T>, mapper: (value: T) => Opt<R>): Opt<R> {
return mapOr(optional, undefined, mapper)
}

View File

@ -78,6 +78,15 @@ export function mapOk<T, U, E>(result: Result<T, E>, f: (value: T) => U): Result
else return result else return result
} }
/** Maps the {@link Result} value with a function that returns a result. */
export function andThen<T, U, E>(
result: Result<T, E>,
f: (value: T) => Result<U, E>,
): Result<U, E> {
if (result.ok) return f(result.value)
else return result
}
/** If the value is nullish, returns {@link Ok} with it. */ /** If the value is nullish, returns {@link Ok} with it. */
export function transposeResult<T, E>(value: Opt<Result<T, E>>): Result<Opt<T>, E> export function transposeResult<T, E>(value: Opt<Result<T, E>>): Result<Opt<T>, E>
/** If any of the values is an error, the first error is returned. */ /** If any of the values is an error, the first error is returned. */