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.new. Values must be string literals, and their content is parsed to the
suitable type][11612].
- [Added dedicated function signature viewer and editor in the right-side
panel][11655].
- [Visualizations on components are slightly transparent when not
focused][11582].
- [New design for vector-editing widget][11620]
@ -71,6 +73,8 @@
[11582]: https://github.com/enso-org/enso/pull/11582
[11597]: https://github.com/enso-org/enso/pull/11597
[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
[11666]: https://github.com/enso-org/enso/pull/11666
[11690]: https://github.com/enso-org/enso/pull/11690

View File

@ -96,9 +96,10 @@ test('Collapsing nodes', async ({ page }) => {
annotations: [],
})
const collapsedNode = locate.graphNodeByBinding(page, 'prod')
await expect(collapsedNode.locator('.WidgetFunctionName')).toExist()
await expect(collapsedNode.locator('.WidgetFunctionName .WidgetToken')).toHaveText(['Main', '.'])
await expect(collapsedNode.locator('.WidgetFunctionName input')).toHaveValue('collapsed')
await expect(collapsedNode.locator('.WidgetApplication.prefix > .WidgetPort')).toExist()
await expect(collapsedNode.locator('.WidgetApplication.prefix > .WidgetPort')).toHaveText(
'Main.collapsed',
)
await expect(collapsedNode.locator('.WidgetTopLevelArgument')).toHaveText('five')
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 { computed, markRaw, toRaw, toRef, watch } from 'vue'
import TooltipDisplayer from './components/TooltipDisplayer.vue'
import { provideTooltipRegistry } from './providers/tooltipState'
import { provideTooltipRegistry } from './providers/tooltipRegistry'
import { provideVisibility } from './providers/visibility'
import { urlParams } from './util/urlParams'

View File

@ -55,6 +55,34 @@
--node-border-radius: calc(var(--node-base-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,
]
canceled: []
selectedSuggestionId: [id: SuggestionId | null]
selectedSuggestionId: [id: SuggestionId | undefined]
isAiPrompt: [boolean]
}>()
@ -296,7 +296,7 @@ const isVisualizationVisible = ref(true)
// === Documentation Panel ===
watch(selectedSuggestionId, (id) => emit('selectedSuggestionId', id ?? null))
watch(selectedSuggestionId, (id) => emit('selectedSuggestionId', id))
watch(
() => input.mode,
(mode) => emit('isAiPrompt', mode.mode === 'aiPrompt'),

View File

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

View File

@ -7,7 +7,9 @@ import type { SuggestionId } from 'ydoc-shared/languageServerTypes/suggestions'
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.
const overrideDisplayed = defineModel<SuggestionId | null>({ default: null })
const overrideDisplayed = defineModel<SuggestionId | undefined>()
const props = defineProps<{ aiMode?: boolean }>()
const selection = injectGraphSelection()
const graphStore = useGraphStore()
@ -21,7 +23,7 @@ function docsForSelection() {
const docs = computed(() => docsForSelection())
// 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))
</script>
@ -30,6 +32,7 @@ const displayedId = computed(() => overrideDisplayed.value ?? unwrapOr(docs.valu
<DocumentationPanel
v-if="displayedId"
:selectedEntry="displayedId"
:aiMode="props.aiMode"
@update:selectedEntry="overrideDisplayed = $event"
/>
<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 ResizeHandles from '@/components/ResizeHandles.vue'
import SizeTransition from '@/components/SizeTransition.vue'
@ -6,6 +6,7 @@ import ToggleIcon from '@/components/ToggleIcon.vue'
import { useResizeObserver } from '@/composables/events'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { TabButton } from '@/util/tabs'
import { tabClipPath } from 'enso-common/src/utilities/style/tabBar'
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_RADIUS_PX = 8
type Tab = 'docs' | 'help'
const show = defineModel<boolean>('show', { required: true })
const size = defineModel<number | undefined>('size')
const tab = defineModel<Tab>('tab')
const _props = defineProps<{
const currentTab = defineModel<Tab>('tab')
const props = defineProps<{
contentFullscreen: boolean
tabButtons: TabButton<Tab>[]
}>()
const slideInPanel = ref<HTMLElement>()
@ -53,30 +54,26 @@ const tabStyle = {
:title="`Documentation Panel (${documentationEditorBindings.bindings.toggle.humanReadable})`"
icon="right_panel"
class="toggleDock"
:class="{ aboveFullscreen: contentFullscreen }"
:class="{ aboveFullscreen: props.contentFullscreen }"
/>
<SizeTransition width :duration="100">
<div v-if="show" ref="slideInPanel" :style="style" class="panelOuter" data-testid="rightDock">
<div class="panelInner">
<div class="content">
<slot v-if="tab == 'docs'" name="docs" />
<slot v-else-if="tab == 'help'" name="help" />
<slot :name="`tab-${currentTab}`" />
</div>
<div class="tabBar">
<div class="tab" :style="tabStyle">
<div
v-for="{ tab, title, icon } in props.tabButtons"
:key="tab"
class="tab"
:style="tabStyle"
>
<ToggleIcon
:modelValue="tab == 'docs'"
title="Documentation Editor"
icon="text"
@update:modelValue="tab = 'docs'"
/>
</div>
<div class="tab" :style="tabStyle">
<ToggleIcon
:modelValue="tab == 'help'"
title="Component Help"
icon="help"
@update:modelValue="tab = 'help'"
:modelValue="currentTab == tab"
:title="title"
:icon="icon"
@update:modelValue="currentTab = tab"
/>
</div>
</div>

View File

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

View File

@ -21,8 +21,8 @@ import type { QualifiedName } from '@/util/qualifiedName'
import { qnSegments, qnSlice } from '@/util/qualifiedName'
import { computed, watch } from 'vue'
const props = defineProps<{ selectedEntry: SuggestionId | null; aiMode?: boolean }>()
const emit = defineEmits<{ 'update:selectedEntry': [value: SuggestionId | null] }>()
const props = defineProps<{ selectedEntry: SuggestionId | undefined; aiMode?: boolean }>()
const emit = defineEmits<{ 'update:selectedEntry': [value: SuggestionId | undefined] }>()
const db = useSuggestionDbStore()
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 type { Usage } from '@/components/ComponentBrowser/input'
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 GraphEdges from '@/components/GraphEditor/GraphEdges.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 { groupColorVar } from '@/composables/nodeColors'
import type { PlacementStrategy } from '@/composables/nodeCreation'
import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
import { provideGraphEditorLayers } from '@/providers/graphEditorLayers'
import type { GraphNavigator } from '@/providers/graphNavigator'
import { provideGraphNavigator } from '@/providers/graphNavigator'
@ -42,15 +39,15 @@ import { provideStackNavigator } from '@/providers/graphStackNavigator'
import { provideInteractionHandler } from '@/providers/interactionHandler'
import { provideKeyboard } from '@/providers/keyboard'
import { provideSelectionButtons } from '@/providers/selectionButtons'
import { injectVisibility } from '@/providers/visibility'
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
import type { Node, NodeId } from '@/stores/graph'
import { provideGraphStore } from '@/stores/graph'
import { isInputNode, nodeId } from '@/stores/graph/graphDatabase'
import type { RequiredImport } from '@/stores/graph/imports'
import { providePersisted } from '@/stores/persisted'
import { useProjectStore } from '@/stores/project'
import { provideNodeExecution } from '@/stores/project/nodeExecution'
import { useSettings } from '@/stores/settings'
import { provideRightDock, StorageMode } from '@/stores/rightDock'
import { provideSuggestionDbStore } from '@/stores/suggestionDatabase'
import type { SuggestionId, Typename } 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 { partition } from '@/util/data/array'
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 { computedFallback, useSelectRef } from '@/util/reactivity'
import { until } from '@vueuse/core'
import * as iter from 'enso-common/src/utilities/data/iter'
import { encoding, set } from 'lib0'
import { set } from 'lib0'
import {
computed,
onMounted,
@ -77,10 +72,8 @@ import {
watch,
type ComponentInstance,
} from 'vue'
import { encodeMethodPointer } from 'ydoc-shared/languageServerTypes'
import { isDevMode } from 'ydoc-shared/util/detect'
const rootNode = ref<HTMLElement>()
import RightDockPanel from './RightDockPanel.vue'
const keyboard = provideKeyboard()
const projectStore = useProjectStore()
@ -88,7 +81,7 @@ const suggestionDb = provideSuggestionDbStore(projectStore)
const graphStore = provideGraphStore(projectStore, suggestionDb)
const widgetRegistry = provideWidgetRegistry(graphStore.db)
const _visualizationStore = provideVisualizationStore(projectStore)
const visible = injectVisibility()
provideNodeExecution(projectStore)
;(window as any)._mockSuggestion = suggestionDb.mockSuggestion
@ -112,6 +105,7 @@ const graphNavigator: GraphNavigator = provideGraphNavigator(viewportNode, keybo
// === Exposed layers ===
const rootNode = ref<HTMLElement>()
const floatingLayer = ref<HTMLElement>()
provideGraphEditorLayers({
fullscreen: rootNode,
@ -120,75 +114,16 @@ provideGraphEditorLayers({
// === Client saved state ===
const storedShowRightDock = ref()
const storedRightDockTab = ref()
const rightDockWidth = ref<number>()
const persisted = providePersisted(
() => projectStore.id,
graphStore,
graphNavigator,
() => zoomToAll(true),
)
/**
* 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 rightDock = provideRightDock(graphStore, persisted)
const visibleAreasReady = computed(() => {
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)
}
},
})
// === Zoom/pan ===
function nodesBounds(nodeIds: Iterable<NodeId>) {
let bounds = Rect.Bounding()
@ -439,32 +374,18 @@ const codeEditorHandler = codeEditorBindings.handler({
// === Documentation Editor ===
const displayedDocs = ref<SuggestionId | null>(null)
const displayedDocs = ref<SuggestionId>()
const aiMode = ref<boolean>(false)
function toggleRightDockHelpPanel() {
rightDock.toggleVisible('help')
}
const docEditor = shallowRef<ComponentInstance<typeof DocumentationEditor>>()
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({
toggle() {
rightDockVisible.value = !rightDockVisible.value
},
})
const markdownDocs = computed(() => {
const currentMethod = graphStore.methodAst
if (!currentMethod.ok) return
return currentMethod.value.mutableDocumentationMarkdown()
toggle: () => rightDock.toggleVisible(),
})
// === Component Browser ===
@ -473,6 +394,10 @@ const componentBrowserVisible = ref(false)
const componentBrowserNodePosition = ref<Vec2>(Vec2.Zero)
const componentBrowserUsage = ref<Usage>({ type: 'newNode' })
watch(componentBrowserVisible, (v) =>
rightDock.setStorageMode(v ? StorageMode.ComponentBrowser : StorageMode.Default),
)
function openComponentBrowser(usage: Usage, position: Vec2) {
componentBrowserUsage.value = usage
componentBrowserNodePosition.value = position
@ -482,47 +407,7 @@ function openComponentBrowser(usage: Usage, position: Vec2) {
function hideComponentBrowser() {
graphStore.editedNodeInfo = undefined
componentBrowserVisible.value = false
displayedDocs.value = null
}
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'
displayedDocs.value = undefined
}
function editWithComponentBrowser(node: NodeId, cursorPos: number) {
@ -654,10 +539,9 @@ function collapseNodes(nodes: Node[]) {
toasts.userActionFailed.show(`Unable to group nodes: ${info.error.payload}.`)
return
}
const currentMethod = projectStore.executionContext.getStackTop()
const currentMethodName = graphStore.db.stackItemToMethodName(currentMethod)
const currentMethodName = unwrapOr(graphStore.currentMethodPointer, undefined)?.name
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
if (!topLevel) {
@ -735,8 +619,6 @@ const groupColors = computed(() => {
}
return styles
})
const documentationEditorFullscreen = ref(false)
</script>
<template>
@ -776,12 +658,13 @@ const documentationEditorFullscreen = ref(false)
<TopBar
v-model:recordMode="projectStore.recordMode"
v-model:showCodeEditor="showCodeEditor"
v-model:showDocumentationEditor="rightDockVisible"
:showDocumentationEditor="rightDock.visible"
:zoomLevel="100.0 * graphNavigator.targetScale"
:class="{ extraRightSpace: !rightDockVisible }"
:class="{ extraRightSpace: !rightDock.visible }"
@fitToAllClicked="zoomToSelected"
@zoomIn="graphNavigator.stepZoom(+1)"
@zoomOut="graphNavigator.stepZoom(-1)"
@update:showDocumentationEditor="rightDock.setVisible"
/>
<SceneScroller
:navigator="graphNavigator"
@ -800,25 +683,7 @@ const documentationEditorFullscreen = ref(false)
</Suspense>
</BottomPanel>
</div>
<DockPanel
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>
<RightDockPanel ref="docPanel" v-model:displayedDocs="displayedDocs" :aiMode="aiMode" />
</div>
</template>
@ -862,7 +727,6 @@ const documentationEditorFullscreen = ref(false)
contain: layout;
overflow: clip;
touch-action: none;
--group-color-fallback: #006b8a;
--node-color-no-type: #596b81;
--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 ComponentContextMenu from '@/components/ComponentContextMenu.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 GraphNodeMessage, {
colorForMessageType,
@ -11,11 +16,6 @@ import GraphNodeMessage, {
import GraphNodeOutputPorts from '@/components/GraphEditor/GraphNodeOutputPorts.vue'
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
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 SmallPlusButton from '@/components/SmallPlusButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
@ -318,53 +318,13 @@ const nodeEditHandler = nodeEditBindings.handler({
e.target.blur()
}
},
edit(e) {
const pos = 'clientX' in e ? new Vec2(e.clientX, e.clientY) : undefined
startEditingNode(pos)
edit() {
startEditingNode()
},
})
function startEditingNode(position?: Vec2 | undefined) {
let sourceOffset = 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
function startEditingNode() {
emit('update:edited', props.node.rootExpr.code().length)
}
const handleNodeClick = useDoubleClick(
@ -403,6 +363,16 @@ const dataSource = computed(
() => ({ 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 ===
function useRecomputation() {
@ -421,6 +391,30 @@ function useRecomputation() {
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 ===
const { getNodeColor, getNodeColors } = injectNodeColors()
@ -476,25 +470,9 @@ const showMenuAt = ref<{ x: number; y: number }>()
<div
v-show="!edited"
ref="rootNode"
class="GraphNode"
:style="{
transform,
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,
}"
class="GraphNode define-node-colors"
:style="nodeStyle"
:class="nodeClass"
:data-node-id="nodeId"
@pointerenter="(nodeHovered = true), updateNodeHover($event)"
@pointerleave="(nodeHovered = false), updateNodeHover(undefined)"
@ -553,12 +531,11 @@ const showMenuAt = ref<{ x: number; y: number }>()
@click="handleNodeClick"
@contextmenu.stop.prevent="ensureSelected(), (showMenuAt = $event)"
>
<NodeWidgetTree
<ComponentWidgetTree
:ast="props.node.innerExpr"
:nodeId="nodeId"
:nodeElement="rootNode"
:rootElement="rootNode"
:nodeType="props.node.type"
:nodeSize="nodeSize"
:potentialSelfArgumentId="potentialSelfArgumentId"
:conditionalPorts="props.node.conditionalPorts"
:extended="isOnlyOneSelected"
@ -609,7 +586,6 @@ const showMenuAt = ref<{ x: number; y: number }>()
top: 0;
left: 0;
display: flex;
--output-port-transform: translateY(var(--viz-below-node));
}
@ -622,33 +598,11 @@ const showMenuAt = ref<{ x: number; y: number }>()
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 {
position: absolute;
border-radius: var(--node-border-radius);
transition: box-shadow 0.2s ease-in-out;
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 {

View File

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

View File

@ -1,14 +1,11 @@
<script setup lang="ts">
import type { WidgetModule } from '@/providers/widgetRegistry'
import { injectWidgetRegistry, WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree'
import {
injectWidgetUsageInfo,
provideWidgetUsageInfo,
usageKeyForInput,
} from '@/providers/widgetUsageInfo'
import { useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast'
import { computed, getCurrentInstance, proxyRefs, shallowRef, watchEffect, withCtx } from 'vue'
const props = defineProps<{
@ -28,9 +25,7 @@ defineOptions({
type UpdateHandler = (update: WidgetUpdate) => boolean
const graph = useGraphStore()
const registry = injectWidgetRegistry()
const tree = injectWidgetTree()
const parentUsageInfo = injectWidgetUsageInfo(true)
const usageKey = computed(() => usageKeyForInput(props.input))
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>
<template>
@ -98,7 +86,6 @@ const spanStart = computed(() => {
v-bind="$attrs"
:input="props.input"
:nesting="nesting"
:data-span-start="spanStart"
:data-port="props.input.portId"
@update="updateHandler"
/>

View File

@ -1,111 +1,52 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { useTransitioning } from '@/composables/animation'
import { injectGraphSelection } from '@/providers/graphSelection'
import { WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
import { WidgetEditHandlerParent } from '@/providers/widgetRegistry/editHandler'
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 type { Vec2 } from '@/util/data/vec2'
import { iconOfNode } from '@/util/getIconName'
import { computed, toRef, watch } from 'vue'
import { DisplayIcon } from './widgets/WidgetIcon.vue'
import { toRef, watch } from 'vue'
import { AstId } from 'ydoc-shared/ast'
import { ExternalId } from 'ydoc-shared/yjsModel'
const props = defineProps<{
ast: Ast.Expression
nodeId: NodeId
nodeElement: HTMLElement | undefined
nodeType: NodeType
nodeSize: Vec2
potentialSelfArgumentId: Ast.AstId | undefined
externalId: string & ExternalId
input: WidgetInput
rootElement: HTMLElement | undefined
potentialSelfArgumentId?: AstId | undefined
/** Ports that are not targetable by default; see {@link NodeDataFromAst}. */
conditionalPorts: Set<Ast.AstId>
conditionalPorts?: Set<Ast.AstId> | undefined
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) {
input[DisplayIcon] = {
icon: topLevelIcon.value,
showContents: props.nodeType != 'output',
}
}
return input
})
const selection = injectGraphSelection()
const layoutTransitions = useTransitioning(
new Set([
'margin-left',
'margin-right',
'margin-top',
'margin-bottom',
'padding-left',
'padding-right',
'padding-top',
'padding-bottom',
'width',
'height',
]),
)
const observedLayoutTransitions = new Set([
'margin-left',
'margin-right',
'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'),
const tree = provideWidgetTree(
toRef(props, 'externalId'),
toRef(props, 'rootElement'),
toRef(props, 'conditionalPorts'),
toRef(props, 'extended'),
layoutTransitions.active,
toRef(props, 'potentialSelfArgumentId'),
)
const topLevelIcon = computed(() => iconOfNode(props.nodeId, graph.db))
watch(toRef(widgetTree, 'currentEdit'), (edit) => edit && selectNode())
watch(toRef(tree, 'currentEdit'), (edit) => emit('currentEditChanged', edit))
</script>
<script lang="ts">
export const GRAB_HANDLE_X_MARGIN_L = 4
@ -114,13 +55,13 @@ export const ICON_WIDTH = 16
</script>
<template>
<div class="NodeWidgetTree widgetRounded" spellcheck="false" v-on="layoutTransitions.events">
<NodeWidget :input="rootPort" @update="handleWidgetUpdates" />
<div class="WidgetTreeRoot widgetRounded" spellcheck="false" v-on="layoutTransitions.events">
<NodeWidget :input="input" :onUpdate="onUpdate" />
</div>
</template>
<style scoped>
.NodeWidgetTree {
.WidgetTreeRoot {
color: var(--color-node-text);
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
* them from being properly padded.
*/
.NodeWidgetTree {
.WidgetTreeRoot {
/*
* 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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { useWidgetFunctionCallInfo } from '@/components/GraphEditor/widgets/WidgetFunction/widgetFunctionCallInfo'
import { FunctionName } from '@/components/GraphEditor/widgets/WidgetFunctionName.vue'
import { injectFunctionInfo, provideFunctionInfo } from '@/providers/functionInfo'
import {
Score,
@ -65,13 +64,7 @@ const innerInput = computed(() => {
input = { ...props.input }
}
const callInfo = methodCallInfo.value
if (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 }
}
}
if (callInfo) input[CallInfo] = callInfo
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">
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 { Ast } from '@/util/ast'
import { Err, Ok, type Result } from '@/util/data/result'
import { useToast } from '@/util/toast'
import { computed, ref, watchEffect } from 'vue'
import { computed, ref, watch } from 'vue'
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'
const props = defineProps(widgetProps(widgetDefinition))
const graph = useGraphStore(true)
const persisted = usePersisted(true)
const displayedName = ref(props.input.value.code())
const project = useProjectStore()
@ -25,24 +29,37 @@ const operator = computed(() =>
const name = computed(() =>
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) {
displayedName.value = name.value.code()
} 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> {
if (!project.modulePath?.ok) return project.modulePath ?? Err('Unknown module Path')
const refactorResult = await project.lsRpcConnection.renameSymbol(
project.modulePath.value,
props.input[FunctionName].editableName,
newName,
)
const modPath = project.modulePath.value
const editedName = props.input[FunctionName].editableNameExpression
const oldMethodPointer = props.input[FunctionName].methodPointer
const refactorResult = await project.lsRpcConnection.renameSymbol(modPath, editedName, newName)
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()
}
</script>
@ -56,21 +73,23 @@ declare module '@/providers/widgetRegistry' {
* 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)
*/
editableName: ExpressionId
editableNameExpression: ExpressionId
methodPointer: MethodPointer
}
}
}
function isFunctionName(
input: WidgetInput,
): input is WidgetInput & { value: Ast.Ast; [FunctionName]: { editableName: ExpressionId } } {
function isFunctionName(input: WidgetInput): input is WidgetInput & {
value: Ast.Ast
[FunctionName]: { editableNameExpression: ExpressionId }
} {
return WidgetInput.isAst(input) && FunctionName in input
}
export const widgetDefinition = defineWidget(
isFunctionName,
{
priority: -20,
priority: 2,
score: Score.Perfect,
},
import.meta.hot,

View File

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

View File

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

View File

@ -53,7 +53,7 @@ const activity = shallowRef<VNode>()
const MAX_DROPDOWN_OVERSIZE_PX = 390
const floatReference = computed(
() => enclosingTopLevelArgument(widgetRoot.value, tree) ?? widgetRoot.value,
() => enclosingTopLevelArgument(widgetRoot.value, tree.rootElement) ?? widgetRoot.value,
)
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.
shift(() => (tree.nodeElement ? { boundary: tree.nodeElement } : {})),
shift(() => (tree.rootElement ? { boundary: tree.rootElement } : {})),
shift(), // Always keep within screen bounds, overriding node bounds.
]
}),
@ -471,7 +471,7 @@ declare module '@/providers/widgetRegistry' {
:class="{ hovered: isHovered }"
/>
</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">
<SizeTransition height :duration="100">
<DropdownWidget

View File

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

View File

@ -1,7 +1,6 @@
<script lang="ts">
import SvgButton from '@/components/SvgButton.vue'
import type { TooltipRegistry } from '@/providers/tooltipState'
import { provideTooltipRegistry } from '@/providers/tooltipState'
import { provideTooltipRegistry, type TooltipRegistry } from '@/providers/tooltipRegistry'
import type { IHeaderParams } from 'ag-grid-community'
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. */
export function enclosingTopLevelArgument(
element: HTMLElement | undefined,
tree: { nodeElement: HTMLElement | undefined },
rootElement: HTMLElement | undefined,
): HTMLElement | undefined {
return (
element?.dataset.topLevelArgument !== undefined ? element
: (
!element ||
element === tree.nodeElement ||
element.parentElement?.firstElementChild !== element
) ?
: !element || element === rootElement || element.parentElement?.firstElementChild !== element ?
undefined
: enclosingTopLevelArgument(element.parentElement, tree)
: enclosingTopLevelArgument(element.parentElement, rootElement)
)
}
</script>

View File

@ -19,16 +19,13 @@ export type TransformUrlResult = Result<{
}>
export type UrlTransformer = (url: string) => Promise<TransformUrlResult>
export {
injectFn as injectDocumentationImageUrlTransformer,
provideFn as provideDocumentationImageUrlTransformer,
}
const { provideFn, injectFn } = createContextStore(
'Documentation image URL transformer',
(transformUrl: ToValue<UrlTransformer | undefined>) => ({
transformUrl: (url: string) => toValue(transformUrl)?.(url),
}),
)
export const [provideDocumentationImageUrlTransformer, injectDocumentationImageUrlTransformer] =
createContextStore(
'Documentation image URL transformer',
(transformUrl: ToValue<UrlTransformer | undefined>) => ({
transformUrl: (url: string) => toValue(transformUrl)?.(url),
}),
)
type ResourceId = 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) {
const parentStyle = getComputedStyle(e.parentElement)
const negativeGap = `calc(${parentStyle.gap} * -1)`
const negativeGap = `calc(${parentStyle.gap || '0'} * -1)`
start.marginLeft = lastSnapshot?.marginLeft || negativeGap
end.marginLeft = isEnter ? current.marginLeft : negativeGap
}

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@ import { useGraphEditorLayers } from '@/providers/graphEditorLayers'
import { Rect } from '@/util/data/rect'
import { computed, ref, toRef, watch } from 'vue'
export type SavedSize = Keyframe
const props = defineProps<{
fullscreen: boolean
}>()
@ -91,10 +93,6 @@ watch(
const active = computed(() => props.fullscreen || animating.value)
</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`,
or used with `unrefElement`. -->
<template>

View File

@ -138,7 +138,7 @@ const displayedChildren = computed(() => {
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) {
if (!event.dataTransfer) return

View File

@ -1,10 +1,13 @@
import { useAbortScope } from '@/util/net'
import { debouncedWatch, useLocalStorage } from '@vueuse/core'
import { encoding } from 'lib0'
import { Encoder } from 'lib0/encoding.js'
import { computed, getCurrentInstance, ref, watch, withCtx } from 'vue'
import { xxHash128 } from 'ydoc-shared/ast/ffi'
import { AbortScope } from 'ydoc-shared/util/net'
export type EncodeFn = (enc: encoding.Encoder) => void
export interface SyncLocalStorageOptions<StoredState> {
/** The main localStorage key under which a map of saved states will be stored. */
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
* 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
* 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>(
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
// 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
}
}
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 { reactive, watch, type WatchSource } from 'vue'
export { provideFn as provideAppClassSet }
const { provideFn, injectFn: injectAppClassSet } = createContextStore('App Class Set', () => {
export { provideAppClassSet }
const [provideAppClassSet, injectAppClassSet] = createContextStore('App Class Set', () => {
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 { proxyRefs, toRef } from 'vue'
export { injectFn as injectBackend, provideFn as provideBackend }
const { provideFn, injectFn } = createContextStore(
export const [provideBackend, injectBackend] = createContextStore(
'backend',
({ project, remote }: { project: ToValue<Backend | null>; remote: ToValue<Backend | null> }) =>
proxyRefs({

View File

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

View File

@ -1,22 +1,22 @@
import { computed, type Ref } from 'vue'
import { createContextStore } from '.'
export type EventLogger = ReturnType<typeof injectFn>
export { injectFn as injectEventLogger, provideFn as provideEventLogger }
const { provideFn, injectFn } = createContextStore('event logger', eventLogger)
export type EventLogger = ReturnType<typeof injectEventLogger>
export const [provideEventLogger, injectEventLogger] = createContextStore(
'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>) {
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, '')}`
})
return {
async send(message: string) {
logEvent.value(message, logProjectId.value)
},
}
}
return {
async send(message: string) {
logEvent.value(message, logProjectId.value)
},
}
},
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,8 +5,7 @@ import { type Ref } from 'vue'
export type GuiConfig = ApplicationConfigValue
export { injectFn as injectGuiConfig, provideFn as provideGuiConfig }
const { provideFn, injectFn } = createContextStore(
export const [provideGuiConfig, injectGuiConfig] = createContextStore(
'GUI config',
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
* functions to make it easier to use the store in components without any name collisions.
* ```ts
* export { injectFn as injectSpecificThing, provideFn as provideSpecificThing }
* const { provideFn, injectFn } = createContextStore('specific thing', thatThingFactory)
* export const [provideThing, useThing] = createContextStore('specific thing', thatThingFactory)
* ```
*
* 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 { provideFn, injectFn } as const
return [provideFn, injectFn] as const
}

View File

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

View File

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

View File

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

View File

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

View File

@ -71,7 +71,7 @@ function useSelectionButtons(
}
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>

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 { type Ref } from 'vue'
export { injectFn as injectVisibility, provideFn as provideVisibility }
const { provideFn, injectFn } = createContextStore('Visibility', identity<Ref<boolean>>)
export const [provideVisibility, injectVisibility] = createContextStore(
'Visibility',
identity<Ref<boolean>>,
)

View File

@ -27,8 +27,8 @@ export interface VisualizationConfig {
setToolbarOverlay: (enableOverlay: boolean) => void
}
export { provideFn as provideVisualizationConfig }
const { provideFn, injectFn } = createContextStore(
export { provideVisualizationConfig }
const [provideVisualizationConfig, injectVisualizationConfig] = createContextStore(
'Visualization config',
reactive<VisualizationConfig>,
)
@ -38,5 +38,5 @@ const { provideFn, injectFn } = createContextStore(
/** TODO: Add docs */
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 namespace WidgetInput {
/** Returns widget-input data for the given AST expression or token. */
export function FromAst<A extends Ast.Expression | Ast.Token>(
ast: A,
): WidgetInput & { value: A } {
/** Returns widget-input data for the given AST tree or token. */
export function FromAst<A extends Ast.Ast | Ast.Token>(ast: A): WidgetInput & { value: A } {
return {
portId: ast.id,
value: ast,
@ -117,10 +115,10 @@ export interface WidgetInput {
*/
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.
*/
value: Ast.Expression | Ast.Token | string | undefined
value: Ast.Ast | Ast.Token | string | undefined
/** An expected type which widget should set. */
expectedType?: Typename | undefined
/** Configuration provided by engine. */
@ -165,7 +163,7 @@ export interface WidgetProps<T> {
* 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
* is committed in {@link NodeWidgetTree}.
* is committed in {@link ComponentWidgetTree}.
*/
export interface WidgetUpdate {
edit?: Ast.MutableModule | undefined
@ -334,8 +332,7 @@ function makeInputMatcher<T extends WidgetInput>(
}
}
export { injectFn as injectWidgetRegistry, provideFn as provideWidgetRegistry }
const { provideFn, injectFn } = createContextStore(
export const [provideWidgetRegistry, injectWidgetRegistry] = createContextStore(
'Widget registry',
(db: GraphDb) => new WidgetRegistry(db),
)

View File

@ -1,37 +1,28 @@
import { createContextStore } from '@/providers'
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 type { Vec2 } from '@/util/data/vec2'
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 }
const { provideFn, injectFn } = createContextStore(
export const [provideWidgetTree, injectWidgetTree] = createContextStore(
'Widget tree',
(
astRoot: Ref<Ast.Expression>,
nodeId: Ref<NodeId>,
nodeElement: Ref<HTMLElement | undefined>,
nodeSize: Ref<Vec2>,
potentialSelfArgumentId: Ref<Ast.AstId | undefined>,
conditionalPorts: Ref<Set<Ast.AstId>>,
externalId: Ref<ExternalId>,
rootElement: Ref<HTMLElement | undefined>,
conditionalPorts: Ref<Set<Ast.AstId> | undefined>,
extended: 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()
return proxyRefs({
astRoot,
nodeId,
nodeElement,
nodeSize,
potentialSelfArgumentId,
externalId,
rootElement,
conditionalPorts,
extended,
nodeSpanStart,
hasActiveAnimations,
potentialSelfArgumentId,
setCurrentEditRoot,
currentEdit,
})

View File

@ -2,8 +2,10 @@ import { createContextStore } from '@/providers'
import type { WidgetComponent, WidgetInput, WidgetUpdate } from '@/providers/widgetRegistry'
import { identity } from '@vueuse/core'
export { injectFn as injectWidgetUsageInfo, provideFn as provideWidgetUsageInfo }
const { provideFn, injectFn } = createContextStore('Widget usage info', identity<WidgetUsageInfo>)
export const [provideWidgetUsageInfo, injectWidgetUsageInfo] = createContextStore(
'Widget usage info',
identity<WidgetUsageInfo>,
)
/**
* 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 { SuggestionDb, type Group } from '@/stores/suggestionDatabase'
import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry'
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import type { AstId, NodeMetadata } 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 { Vec2 } from '@/util/data/vec2'
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
import { tryIdentifier } from '@/util/qualifiedName'
import {
isIdentifierOrOperatorIdentifier,
isQualifiedName,
normalizeQualifiedName,
tryIdentifier,
} from '@/util/qualifiedName'
import {
nonReactiveView,
resumeReactivity,
@ -23,7 +29,12 @@ import * as objects from 'enso-common/src/utilities/data/object'
import * as set from 'lib0/set'
import { reactive, ref, shallowReactive, type Ref, type WatchStopHandle } from 'vue'
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 { ExternalId, VisualizationMetadata } from 'ydoc-shared/yjsModel'
import { isUuid, visMetadataEquals } from 'ydoc-shared/yjsModel'
@ -181,7 +192,7 @@ export class GraphDb {
}
/** TODO: Add docs */
isNodeId(externalId: ExternalId): boolean {
isNodeId(externalId: ExternalId): externalId is NodeId {
return this.nodeIdToNode.has(asNodeId(externalId))
}
@ -443,6 +454,42 @@ export class GraphDb {
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 */
static Mock(registry = ComputedValueRegistry.Mock(), db = new SuggestionDb()): GraphDb {
return new GraphDb(db, ref([]), registry)

View File

@ -15,7 +15,7 @@ import {
import { useUnconnectedEdges, type UnconnectedEdge } from '@/stores/graph/unconnectedEdges'
import { type ProjectStore } from '@/stores/project'
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 type { AstId, Identifier, MutableModule } 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 { stringUnionToArray, type Events } from '@/util/data/observable'
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 { normalizeQualifiedName, qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import { normalizeQualifiedName, tryQualifiedName } from '@/util/qualifiedName'
import { useWatchContext } from '@/util/reactivity'
import { computedAsync } from '@vueuse/core'
import * as iter from 'enso-common/src/utilities/data/iter'
@ -82,7 +82,7 @@ export class PortViewInstance {
}
export type GraphStore = ReturnType<typeof useGraphStore>
export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createContextStore(
export const [provideGraphStore, useGraphStore] = createContextStore(
'graph',
(proj: ProjectStore, suggestionDb: SuggestionDbStore) => {
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'),
)
// 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 afterUpdate: (() => void)[] = []
@ -167,20 +189,26 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
db.updateBindings(methodAst.value.value, moduleSource)
})
function getExecutedMethodAst(module?: Ast.Module): Result<Ast.FunctionDef> {
const currentMethodPointer = computed((): Result<MethodPointer> => {
const executionStackTop = proj.executionContext.getStackTop()
switch (executionStackTop.type) {
case 'ExplicitCall': {
return getMethodAst(executionStackTop.methodPointer, module)
return Ok(executionStackTop.methodPointer)
}
case 'LocalCall': {
const exprId = executionStackTop.expressionId
const info = db.getExpressionInfo(exprId)
const ptr = info?.methodCall?.methodPointer
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> {
@ -748,8 +776,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
if (expressionInfo?.methodCall == null) return false
const definedOnType = tryQualifiedName(expressionInfo.methodCall.methodPointer.definedOnType)
const openModuleName = qnLastSegment(proj.modulePath.value)
if (definedOnType.ok && qnLastSegment(definedOnType.value) !== openModuleName) {
if (definedOnType.ok && definedOnType.value !== proj.modulePath.value) {
// Cannot enter node that is not defined on current module.
// TODO: Support entering nodes in other modules within the same project.
return false
@ -813,11 +840,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
addMissingImportsDisregardConflicts,
isConnectedTarget,
nodeCanBeEntered,
currentMethodPointer() {
const currentMethod = proj.executionContext.getStackTop()
if (currentMethod.type === 'ExplicitCall') return currentMethod.methodPointer
return db.getExpressionInfo(currentMethod.expressionId)?.methodCall?.methodPointer
},
currentMethodPointer,
modulePath,
connectedEdges,
...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 { findIndexOpt } from '@/util/data/array'
import { findDifferenceIndex } from '@/util/data/array'
import { isSome, type Opt } from '@/util/data/opt'
import { Err, Ok, ResultError, type Result } from '@/util/data/result'
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() {
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[]) {
this._desiredStack.length = 0
this._desiredStack.push(...stack)
@ -400,28 +406,36 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
const state = newState
if (state.status !== 'created')
return Err('Cannot sync stack when execution context is not created')
const firstDifferent =
findIndexOpt(this._desiredStack, (item, index) => {
const stateStack = state.stack[index]
return stateStack == null || !stackItemsEqual(item, stateStack)
}) ?? this._desiredStack.length
for (let i = state.stack.length; i > firstDifferent; --i) {
const popResult = await this.withBackoff(
() => this.lsRpc.popExecutionContextItem(this.id),
'Failed to pop execution stack frame',
while (true) {
// Since this is an async function, the desired state can change inbetween individual API calls.
// We need to compare the desired stack state against current state on every loop iteration.
const firstDifferent = findDifferenceIndex(
this._desiredStack,
state.stack,
stackItemsEqual,
)
if (popResult.ok) state.stack.pop()
else return popResult
}
for (let i = state.stack.length; i < this._desiredStack.length; ++i) {
const newItem = this._desiredStack[i]!
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
if (state.stack.length > firstDifferent) {
// Found a difference within currently set stack context. We need to pop our way up to it.
const popResult = await this.withBackoff(
() => this.lsRpc.popExecutionContextItem(this.id),
'Failed to pop execution stack frame',
)
if (popResult.ok) state.stack.pop()
else return popResult
} else if (state.stack.length < this._desiredStack.length) {
// 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()
}

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"
* 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',
(props: { projectId: string; renameProject: (newName: string) => void }) => {
const { projectId, renameProject: renameProjectBackend } = props

View File

@ -6,7 +6,7 @@ import { ExecutionEnvironment } from 'ydoc-shared/languageServerTypes'
import { ExternalId } from 'ydoc-shared/yjsModel'
/** Allows to recompute certain expressions (usually nodes). */
export const { provideFn: provideNodeExecution, injectFn: useNodeExecution } = createContextStore(
export const [provideNodeExecution, useNodeExecution] = createContextStore(
'nodeExecution',
(projectStore: ProjectStore) => {
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,
}
export const { injectFn: useSettings, provideFn: provideSettings } = createContextStore(
'settings',
() => {
const user = ref<UserSettings>(defaultUserSettings)
export const [provideSettings, useSettings] = createContextStore('settings', () => {
const user = ref<UserSettings>(defaultUserSettings)
useSyncLocalStorage<UserSettings>({
storageKey: 'enso-user-settings',
mapKeyEncoder: () => {},
debounce: 200,
captureState() {
return user.value ?? defaultUserSettings
},
async restoreState(restored) {
if (restored) {
user.value = { ...defaultUserSettings, ...restored }
}
},
})
useSyncLocalStorage<UserSettings>({
storageKey: 'enso-user-settings',
mapKeyEncoder: () => {},
debounce: 200,
captureState() {
return user.value ?? defaultUserSettings
},
async restoreState(restored) {
if (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')),
}
}
/**
* 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 */
export type SuggestionDbStore = ReturnType<typeof useSuggestionDbStore>
export const { provideFn: provideSuggestionDbStore, injectFn: useSuggestionDbStore } =
createContextStore('suggestionDatabase', (projectStore: ProjectStore) => {
export const [provideSuggestionDbStore, useSuggestionDbStore] = createContextStore(
'suggestionDatabase',
(projectStore: ProjectStore) => {
const entries = new SuggestionDb()
const groups = ref<Group[]>([])
@ -194,4 +195,5 @@ export const { provideFn: provideSuggestionDbStore, injectFn: useSuggestionDbSto
const _synchronizer = new Synchronizer(projectStore, entries, groups)
return proxyRefs({ entries: markRaw(entries), groups, _synchronizer, mockSuggestion })
})
},
)

View File

@ -78,8 +78,9 @@ const builtinVisualizationsByName = Object.fromEntries(
builtinVisualizations.map((viz) => [viz.name, viz]),
)
export const { provideFn: provideVisualizationStore, injectFn: useVisualizationStore } =
createContextStore('visualization', (proj: ProjectStore) => {
export const [provideVisualizationStore, useVisualizationStore] = createContextStore(
'visualization',
(proj: ProjectStore) => {
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
@ -283,4 +284,5 @@ export const { provideFn: provideVisualizationStore, injectFn: useVisualizationS
}
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 { expect } from 'vitest'
@ -38,3 +38,34 @@ test.prop({
const target = arr[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]
}
/**
* 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 {
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
}
/** 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. */
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. */