mirror of
https://github.com/enso-org/enso.git
synced 2024-12-19 00:31:39 +03:00
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:
parent
88fdfb452a
commit
be1b706d0a
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
/*********************************
|
||||
|
@ -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'),
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>(() => {
|
||||
|
103
app/gui/src/project-view/components/FunctionSignatureEditor.vue
Normal file
103
app/gui/src/project-view/components/FunctionSignatureEditor.vue
Normal 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>
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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
|
@ -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) }
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
})
|
||||
|
||||
|
@ -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>
|
@ -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>
|
@ -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,
|
||||
|
@ -14,6 +14,7 @@ declare module '@/providers/widgetRegistry' {
|
||||
export interface WidgetInput {
|
||||
[DisplayIcon]?: {
|
||||
icon: Icon | URLString
|
||||
allowChoice?: boolean
|
||||
showContents?: boolean
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
50
app/gui/src/project-view/components/RightDockPanel.vue
Normal file
50
app/gui/src/project-view/components/RightDockPanel.vue
Normal 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>
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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>())
|
||||
})
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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)
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
@ -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>,
|
||||
)
|
||||
|
@ -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>,
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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>>,
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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(),
|
||||
)
|
||||
|
@ -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(),
|
||||
)
|
||||
|
@ -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>)
|
||||
|
@ -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>,
|
||||
)
|
||||
|
@ -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>
|
||||
|
||||
|
68
app/gui/src/project-view/providers/tooltipRegistry.ts
Normal file
68
app/gui/src/project-view/providers/tooltipRegistry.ts
Normal 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
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
@ -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
|
||||
},
|
||||
}
|
||||
})
|
@ -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>>,
|
||||
)
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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),
|
||||
)
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
111
app/gui/src/project-view/stores/persisted.ts
Normal file
111
app/gui/src/project-view/stores/persisted.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
)
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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>())
|
||||
|
133
app/gui/src/project-view/stores/rightDock.ts
Normal file
133
app/gui/src/project-view/stores/rightDock.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
)
|
@ -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 }
|
||||
})
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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 })
|
||||
})
|
||||
},
|
||||
)
|
||||
|
@ -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 }
|
||||
})
|
||||
},
|
||||
)
|
||||
|
@ -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])
|
||||
}
|
||||
})
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
20
app/gui/src/project-view/util/tabs.ts
Normal file
20
app/gui/src/project-view/util/tabs.ts
Normal 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,
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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. */
|
||||
|
Loading…
Reference in New Issue
Block a user