mirror of
https://github.com/enso-org/enso.git
synced 2024-12-24 10:53:34 +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 Input Widget is now matched for Table.input method instead of
|
||||||
Table.new. Values must be string literals, and their content is parsed to the
|
Table.new. Values must be string literals, and their content is parsed to the
|
||||||
suitable type][11612].
|
suitable type][11612].
|
||||||
|
- [Added dedicated function signature viewer and editor in the right-side
|
||||||
|
panel][11655].
|
||||||
- [Visualizations on components are slightly transparent when not
|
- [Visualizations on components are slightly transparent when not
|
||||||
focused][11582].
|
focused][11582].
|
||||||
- [New design for vector-editing widget][11620]
|
- [New design for vector-editing widget][11620]
|
||||||
@ -71,6 +73,8 @@
|
|||||||
[11582]: https://github.com/enso-org/enso/pull/11582
|
[11582]: https://github.com/enso-org/enso/pull/11582
|
||||||
[11597]: https://github.com/enso-org/enso/pull/11597
|
[11597]: https://github.com/enso-org/enso/pull/11597
|
||||||
[11612]: https://github.com/enso-org/enso/pull/11612
|
[11612]: https://github.com/enso-org/enso/pull/11612
|
||||||
|
[11655]: https://github.com/enso-org/enso/pull/11655
|
||||||
|
[11582]: https://github.com/enso-org/enso/pull/11582
|
||||||
[11620]: https://github.com/enso-org/enso/pull/11620
|
[11620]: https://github.com/enso-org/enso/pull/11620
|
||||||
[11666]: https://github.com/enso-org/enso/pull/11666
|
[11666]: https://github.com/enso-org/enso/pull/11666
|
||||||
[11690]: https://github.com/enso-org/enso/pull/11690
|
[11690]: https://github.com/enso-org/enso/pull/11690
|
||||||
|
@ -96,9 +96,10 @@ test('Collapsing nodes', async ({ page }) => {
|
|||||||
annotations: [],
|
annotations: [],
|
||||||
})
|
})
|
||||||
const collapsedNode = locate.graphNodeByBinding(page, 'prod')
|
const collapsedNode = locate.graphNodeByBinding(page, 'prod')
|
||||||
await expect(collapsedNode.locator('.WidgetFunctionName')).toExist()
|
await expect(collapsedNode.locator('.WidgetApplication.prefix > .WidgetPort')).toExist()
|
||||||
await expect(collapsedNode.locator('.WidgetFunctionName .WidgetToken')).toHaveText(['Main', '.'])
|
await expect(collapsedNode.locator('.WidgetApplication.prefix > .WidgetPort')).toHaveText(
|
||||||
await expect(collapsedNode.locator('.WidgetFunctionName input')).toHaveValue('collapsed')
|
'Main.collapsed',
|
||||||
|
)
|
||||||
await expect(collapsedNode.locator('.WidgetTopLevelArgument')).toHaveText('five')
|
await expect(collapsedNode.locator('.WidgetTopLevelArgument')).toHaveText('five')
|
||||||
|
|
||||||
await locate.graphNodeIcon(collapsedNode).dblclick()
|
await locate.graphNodeIcon(collapsedNode).dblclick()
|
||||||
|
@ -17,7 +17,7 @@ import { useEventListener } from '@vueuse/core'
|
|||||||
import type Backend from 'enso-common/src/services/Backend'
|
import type Backend from 'enso-common/src/services/Backend'
|
||||||
import { computed, markRaw, toRaw, toRef, watch } from 'vue'
|
import { computed, markRaw, toRaw, toRef, watch } from 'vue'
|
||||||
import TooltipDisplayer from './components/TooltipDisplayer.vue'
|
import TooltipDisplayer from './components/TooltipDisplayer.vue'
|
||||||
import { provideTooltipRegistry } from './providers/tooltipState'
|
import { provideTooltipRegistry } from './providers/tooltipRegistry'
|
||||||
import { provideVisibility } from './providers/visibility'
|
import { provideVisibility } from './providers/visibility'
|
||||||
import { urlParams } from './util/urlParams'
|
import { urlParams } from './util/urlParams'
|
||||||
|
|
||||||
|
@ -55,6 +55,34 @@
|
|||||||
|
|
||||||
--node-border-radius: calc(var(--node-base-height) / 2);
|
--node-border-radius: calc(var(--node-base-height) / 2);
|
||||||
--node-port-border-radius: calc(var(--node-port-height) / 2);
|
--node-port-border-radius: calc(var(--node-port-height) / 2);
|
||||||
|
|
||||||
|
/** Space between node and component above and below, such as comments and errors. */
|
||||||
|
--node-vertical-gap: 5px;
|
||||||
|
--group-color-fallback: #006b8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used on containers that need access to computed node color variables.
|
||||||
|
* Expects variable `--node-group-color` to be defined on the element to which this class is applied.
|
||||||
|
*/
|
||||||
|
.define-node-colors {
|
||||||
|
--color-node-text: white;
|
||||||
|
--color-node-background: var(--node-group-color);
|
||||||
|
--color-node-primary: var(--node-group-color);
|
||||||
|
--color-node-edge: color-mix(in oklab, var(--node-group-color) 85%, white 15%);
|
||||||
|
--color-node-port: var(--color-node-edge);
|
||||||
|
--color-node-error: color-mix(in oklab, var(--node-group-color) 30%, rgb(255, 0, 0) 70%);
|
||||||
|
--color-node-pending: color-mix(in oklab, var(--node-group-color) 60%, #aaa 40%);
|
||||||
|
|
||||||
|
&.pending {
|
||||||
|
--color-node-primary: var(--color-node-pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
--color-node-background: color-mix(in oklab, var(--color-node-primary) 30%, white 70%);
|
||||||
|
--color-node-port: color-mix(in oklab, var(--color-node-background) 40%, white 60%);
|
||||||
|
--color-node-text: color-mix(in oklab, var(--color-node-primary) 70%, black 30%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*********************************
|
/*********************************
|
||||||
|
@ -65,7 +65,7 @@ const emit = defineEmits<{
|
|||||||
firstAppliedReturnType: Typename | undefined,
|
firstAppliedReturnType: Typename | undefined,
|
||||||
]
|
]
|
||||||
canceled: []
|
canceled: []
|
||||||
selectedSuggestionId: [id: SuggestionId | null]
|
selectedSuggestionId: [id: SuggestionId | undefined]
|
||||||
isAiPrompt: [boolean]
|
isAiPrompt: [boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@ -296,7 +296,7 @@ const isVisualizationVisible = ref(true)
|
|||||||
|
|
||||||
// === Documentation Panel ===
|
// === Documentation Panel ===
|
||||||
|
|
||||||
watch(selectedSuggestionId, (id) => emit('selectedSuggestionId', id ?? null))
|
watch(selectedSuggestionId, (id) => emit('selectedSuggestionId', id))
|
||||||
watch(
|
watch(
|
||||||
() => input.mode,
|
() => input.mode,
|
||||||
(mode) => emit('isAiPrompt', mode.mode === 'aiPrompt'),
|
(mode) => emit('isAiPrompt', mode.mode === 'aiPrompt'),
|
||||||
|
@ -49,7 +49,7 @@ const rootStyle = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ComponentEditor" :style="rootStyle">
|
<div class="ComponentEditor define-node-colors" :style="rootStyle">
|
||||||
<div v-if="props.icon" class="iconPort">
|
<div v-if="props.icon" class="iconPort">
|
||||||
<SvgIcon :name="props.icon" class="nodeIcon" />
|
<SvgIcon :name="props.icon" class="nodeIcon" />
|
||||||
</div>
|
</div>
|
||||||
@ -72,7 +72,6 @@ const rootStyle = computed(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ComponentEditor {
|
.ComponentEditor {
|
||||||
--node-color-port: color-mix(in oklab, var(--color-node-primary) 85%, white 15%);
|
|
||||||
--port-padding: 6px;
|
--port-padding: 6px;
|
||||||
--icon-height: 16px;
|
--icon-height: 16px;
|
||||||
--icon-text-gap: 6px;
|
--icon-text-gap: 6px;
|
||||||
@ -101,7 +100,7 @@ const rootStyle = computed(() => {
|
|||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
padding: var(--port-padding);
|
padding: var(--port-padding);
|
||||||
margin: 0 var(--icon-text-gap) 0 calc(0px - var(--port-padding));
|
margin: 0 var(--icon-text-gap) 0 calc(0px - var(--port-padding));
|
||||||
background-color: var(--node-color-port);
|
background-color: var(--color-node-port);
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,9 @@ import type { SuggestionId } from 'ydoc-shared/languageServerTypes/suggestions'
|
|||||||
import { Err, Ok, unwrapOr } from 'ydoc-shared/util/data/result'
|
import { Err, Ok, unwrapOr } from 'ydoc-shared/util/data/result'
|
||||||
|
|
||||||
// A displayed component can be overridren by this model, e.g. when the user clicks links in the documenation.
|
// A displayed component can be overridren by this model, e.g. when the user clicks links in the documenation.
|
||||||
const overrideDisplayed = defineModel<SuggestionId | null>({ default: null })
|
const overrideDisplayed = defineModel<SuggestionId | undefined>()
|
||||||
|
const props = defineProps<{ aiMode?: boolean }>()
|
||||||
|
|
||||||
const selection = injectGraphSelection()
|
const selection = injectGraphSelection()
|
||||||
const graphStore = useGraphStore()
|
const graphStore = useGraphStore()
|
||||||
|
|
||||||
@ -21,7 +23,7 @@ function docsForSelection() {
|
|||||||
|
|
||||||
const docs = computed(() => docsForSelection())
|
const docs = computed(() => docsForSelection())
|
||||||
// When the selection changes, we cancel the displayed suggestion override that can be in place.
|
// When the selection changes, we cancel the displayed suggestion override that can be in place.
|
||||||
watch(docs, (_) => (overrideDisplayed.value = null))
|
watch(docs, (_) => (overrideDisplayed.value = undefined))
|
||||||
|
|
||||||
const displayedId = computed(() => overrideDisplayed.value ?? unwrapOr(docs.value, null))
|
const displayedId = computed(() => overrideDisplayed.value ?? unwrapOr(docs.value, null))
|
||||||
</script>
|
</script>
|
||||||
@ -30,6 +32,7 @@ const displayedId = computed(() => overrideDisplayed.value ?? unwrapOr(docs.valu
|
|||||||
<DocumentationPanel
|
<DocumentationPanel
|
||||||
v-if="displayedId"
|
v-if="displayedId"
|
||||||
:selectedEntry="displayedId"
|
:selectedEntry="displayedId"
|
||||||
|
:aiMode="props.aiMode"
|
||||||
@update:selectedEntry="overrideDisplayed = $event"
|
@update:selectedEntry="overrideDisplayed = $event"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="!displayedId && !docs.ok" class="help-placeholder">{{ docs.error.payload }}.</div>
|
<div v-else-if="!displayedId && !docs.ok" class="help-placeholder">{{ docs.error.payload }}.</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts" generic="Tab extends string">
|
||||||
import { documentationEditorBindings } from '@/bindings'
|
import { documentationEditorBindings } from '@/bindings'
|
||||||
import ResizeHandles from '@/components/ResizeHandles.vue'
|
import ResizeHandles from '@/components/ResizeHandles.vue'
|
||||||
import SizeTransition from '@/components/SizeTransition.vue'
|
import SizeTransition from '@/components/SizeTransition.vue'
|
||||||
@ -6,6 +6,7 @@ import ToggleIcon from '@/components/ToggleIcon.vue'
|
|||||||
import { useResizeObserver } from '@/composables/events'
|
import { useResizeObserver } from '@/composables/events'
|
||||||
import { Rect } from '@/util/data/rect'
|
import { Rect } from '@/util/data/rect'
|
||||||
import { Vec2 } from '@/util/data/vec2'
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
|
import { TabButton } from '@/util/tabs'
|
||||||
import { tabClipPath } from 'enso-common/src/utilities/style/tabBar'
|
import { tabClipPath } from 'enso-common/src/utilities/style/tabBar'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
@ -13,13 +14,13 @@ const TAB_EDGE_MARGIN_PX = 4
|
|||||||
const TAB_SIZE_PX = { width: 48 - TAB_EDGE_MARGIN_PX, height: 48 }
|
const TAB_SIZE_PX = { width: 48 - TAB_EDGE_MARGIN_PX, height: 48 }
|
||||||
const TAB_RADIUS_PX = 8
|
const TAB_RADIUS_PX = 8
|
||||||
|
|
||||||
type Tab = 'docs' | 'help'
|
|
||||||
|
|
||||||
const show = defineModel<boolean>('show', { required: true })
|
const show = defineModel<boolean>('show', { required: true })
|
||||||
const size = defineModel<number | undefined>('size')
|
const size = defineModel<number | undefined>('size')
|
||||||
const tab = defineModel<Tab>('tab')
|
const currentTab = defineModel<Tab>('tab')
|
||||||
const _props = defineProps<{
|
|
||||||
|
const props = defineProps<{
|
||||||
contentFullscreen: boolean
|
contentFullscreen: boolean
|
||||||
|
tabButtons: TabButton<Tab>[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const slideInPanel = ref<HTMLElement>()
|
const slideInPanel = ref<HTMLElement>()
|
||||||
@ -53,30 +54,26 @@ const tabStyle = {
|
|||||||
:title="`Documentation Panel (${documentationEditorBindings.bindings.toggle.humanReadable})`"
|
:title="`Documentation Panel (${documentationEditorBindings.bindings.toggle.humanReadable})`"
|
||||||
icon="right_panel"
|
icon="right_panel"
|
||||||
class="toggleDock"
|
class="toggleDock"
|
||||||
:class="{ aboveFullscreen: contentFullscreen }"
|
:class="{ aboveFullscreen: props.contentFullscreen }"
|
||||||
/>
|
/>
|
||||||
<SizeTransition width :duration="100">
|
<SizeTransition width :duration="100">
|
||||||
<div v-if="show" ref="slideInPanel" :style="style" class="panelOuter" data-testid="rightDock">
|
<div v-if="show" ref="slideInPanel" :style="style" class="panelOuter" data-testid="rightDock">
|
||||||
<div class="panelInner">
|
<div class="panelInner">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<slot v-if="tab == 'docs'" name="docs" />
|
<slot :name="`tab-${currentTab}`" />
|
||||||
<slot v-else-if="tab == 'help'" name="help" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tabBar">
|
<div class="tabBar">
|
||||||
<div class="tab" :style="tabStyle">
|
<div
|
||||||
|
v-for="{ tab, title, icon } in props.tabButtons"
|
||||||
|
:key="tab"
|
||||||
|
class="tab"
|
||||||
|
:style="tabStyle"
|
||||||
|
>
|
||||||
<ToggleIcon
|
<ToggleIcon
|
||||||
:modelValue="tab == 'docs'"
|
:modelValue="currentTab == tab"
|
||||||
title="Documentation Editor"
|
:title="title"
|
||||||
icon="text"
|
:icon="icon"
|
||||||
@update:modelValue="tab = 'docs'"
|
@update:modelValue="currentTab = tab"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="tab" :style="tabStyle">
|
|
||||||
<ToggleIcon
|
|
||||||
:modelValue="tab == 'help'"
|
|
||||||
title="Component Help"
|
|
||||||
icon="help"
|
|
||||||
@update:modelValue="tab = 'help'"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -65,6 +65,7 @@ const handler = documentationEditorBindings.handler({
|
|||||||
<div ref="toolbarElement" class="toolbar">
|
<div ref="toolbarElement" class="toolbar">
|
||||||
<FullscreenButton v-model="fullscreen" />
|
<FullscreenButton v-model="fullscreen" />
|
||||||
</div>
|
</div>
|
||||||
|
<slot name="belowToolbar" />
|
||||||
<div
|
<div
|
||||||
class="scrollArea"
|
class="scrollArea"
|
||||||
@keydown="handler"
|
@keydown="handler"
|
||||||
|
@ -21,8 +21,8 @@ import type { QualifiedName } from '@/util/qualifiedName'
|
|||||||
import { qnSegments, qnSlice } from '@/util/qualifiedName'
|
import { qnSegments, qnSlice } from '@/util/qualifiedName'
|
||||||
import { computed, watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ selectedEntry: SuggestionId | null; aiMode?: boolean }>()
|
const props = defineProps<{ selectedEntry: SuggestionId | undefined; aiMode?: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:selectedEntry': [value: SuggestionId | null] }>()
|
const emit = defineEmits<{ 'update:selectedEntry': [value: SuggestionId | undefined] }>()
|
||||||
const db = useSuggestionDbStore()
|
const db = useSuggestionDbStore()
|
||||||
|
|
||||||
const documentation = computed<Docs>(() => {
|
const documentation = computed<Docs>(() => {
|
||||||
|
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 ComponentBrowser from '@/components/ComponentBrowser.vue'
|
||||||
import type { Usage } from '@/components/ComponentBrowser/input'
|
import type { Usage } from '@/components/ComponentBrowser/input'
|
||||||
import { usePlacement } from '@/components/ComponentBrowser/placement'
|
import { usePlacement } from '@/components/ComponentBrowser/placement'
|
||||||
import ComponentDocumentation from '@/components/ComponentDocumentation.vue'
|
|
||||||
import DockPanel from '@/components/DockPanel.vue'
|
|
||||||
import DocumentationEditor from '@/components/DocumentationEditor.vue'
|
import DocumentationEditor from '@/components/DocumentationEditor.vue'
|
||||||
import GraphEdges from '@/components/GraphEditor/GraphEdges.vue'
|
import GraphEdges from '@/components/GraphEditor/GraphEdges.vue'
|
||||||
import GraphNodes from '@/components/GraphEditor/GraphNodes.vue'
|
import GraphNodes from '@/components/GraphEditor/GraphNodes.vue'
|
||||||
@ -31,7 +29,6 @@ import { useDoubleClick } from '@/composables/doubleClick'
|
|||||||
import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/composables/events'
|
import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/composables/events'
|
||||||
import { groupColorVar } from '@/composables/nodeColors'
|
import { groupColorVar } from '@/composables/nodeColors'
|
||||||
import type { PlacementStrategy } from '@/composables/nodeCreation'
|
import type { PlacementStrategy } from '@/composables/nodeCreation'
|
||||||
import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
|
|
||||||
import { provideGraphEditorLayers } from '@/providers/graphEditorLayers'
|
import { provideGraphEditorLayers } from '@/providers/graphEditorLayers'
|
||||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
import type { GraphNavigator } from '@/providers/graphNavigator'
|
||||||
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
||||||
@ -42,15 +39,15 @@ import { provideStackNavigator } from '@/providers/graphStackNavigator'
|
|||||||
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
||||||
import { provideKeyboard } from '@/providers/keyboard'
|
import { provideKeyboard } from '@/providers/keyboard'
|
||||||
import { provideSelectionButtons } from '@/providers/selectionButtons'
|
import { provideSelectionButtons } from '@/providers/selectionButtons'
|
||||||
import { injectVisibility } from '@/providers/visibility'
|
|
||||||
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
|
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
|
||||||
import type { Node, NodeId } from '@/stores/graph'
|
import type { Node, NodeId } from '@/stores/graph'
|
||||||
import { provideGraphStore } from '@/stores/graph'
|
import { provideGraphStore } from '@/stores/graph'
|
||||||
import { isInputNode, nodeId } from '@/stores/graph/graphDatabase'
|
import { isInputNode, nodeId } from '@/stores/graph/graphDatabase'
|
||||||
import type { RequiredImport } from '@/stores/graph/imports'
|
import type { RequiredImport } from '@/stores/graph/imports'
|
||||||
|
import { providePersisted } from '@/stores/persisted'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { provideNodeExecution } from '@/stores/project/nodeExecution'
|
import { provideNodeExecution } from '@/stores/project/nodeExecution'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { provideRightDock, StorageMode } from '@/stores/rightDock'
|
||||||
import { provideSuggestionDbStore } from '@/stores/suggestionDatabase'
|
import { provideSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||||
import type { SuggestionId, Typename } from '@/stores/suggestionDatabase/entry'
|
import type { SuggestionId, Typename } from '@/stores/suggestionDatabase/entry'
|
||||||
import { suggestionDocumentationUrl } from '@/stores/suggestionDatabase/entry'
|
import { suggestionDocumentationUrl } from '@/stores/suggestionDatabase/entry'
|
||||||
@ -60,12 +57,10 @@ import { Ast } from '@/util/ast'
|
|||||||
import { colorFromString } from '@/util/colors'
|
import { colorFromString } from '@/util/colors'
|
||||||
import { partition } from '@/util/data/array'
|
import { partition } from '@/util/data/array'
|
||||||
import { Rect } from '@/util/data/rect'
|
import { Rect } from '@/util/data/rect'
|
||||||
import { Err, Ok } from '@/util/data/result'
|
import { Err, Ok, unwrapOr } from '@/util/data/result'
|
||||||
import { Vec2 } from '@/util/data/vec2'
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
import { computedFallback, useSelectRef } from '@/util/reactivity'
|
|
||||||
import { until } from '@vueuse/core'
|
|
||||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||||
import { encoding, set } from 'lib0'
|
import { set } from 'lib0'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
onMounted,
|
onMounted,
|
||||||
@ -77,10 +72,8 @@ import {
|
|||||||
watch,
|
watch,
|
||||||
type ComponentInstance,
|
type ComponentInstance,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { encodeMethodPointer } from 'ydoc-shared/languageServerTypes'
|
|
||||||
import { isDevMode } from 'ydoc-shared/util/detect'
|
import { isDevMode } from 'ydoc-shared/util/detect'
|
||||||
|
import RightDockPanel from './RightDockPanel.vue'
|
||||||
const rootNode = ref<HTMLElement>()
|
|
||||||
|
|
||||||
const keyboard = provideKeyboard()
|
const keyboard = provideKeyboard()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
@ -88,7 +81,7 @@ const suggestionDb = provideSuggestionDbStore(projectStore)
|
|||||||
const graphStore = provideGraphStore(projectStore, suggestionDb)
|
const graphStore = provideGraphStore(projectStore, suggestionDb)
|
||||||
const widgetRegistry = provideWidgetRegistry(graphStore.db)
|
const widgetRegistry = provideWidgetRegistry(graphStore.db)
|
||||||
const _visualizationStore = provideVisualizationStore(projectStore)
|
const _visualizationStore = provideVisualizationStore(projectStore)
|
||||||
const visible = injectVisibility()
|
|
||||||
provideNodeExecution(projectStore)
|
provideNodeExecution(projectStore)
|
||||||
;(window as any)._mockSuggestion = suggestionDb.mockSuggestion
|
;(window as any)._mockSuggestion = suggestionDb.mockSuggestion
|
||||||
|
|
||||||
@ -112,6 +105,7 @@ const graphNavigator: GraphNavigator = provideGraphNavigator(viewportNode, keybo
|
|||||||
|
|
||||||
// === Exposed layers ===
|
// === Exposed layers ===
|
||||||
|
|
||||||
|
const rootNode = ref<HTMLElement>()
|
||||||
const floatingLayer = ref<HTMLElement>()
|
const floatingLayer = ref<HTMLElement>()
|
||||||
provideGraphEditorLayers({
|
provideGraphEditorLayers({
|
||||||
fullscreen: rootNode,
|
fullscreen: rootNode,
|
||||||
@ -120,75 +114,16 @@ provideGraphEditorLayers({
|
|||||||
|
|
||||||
// === Client saved state ===
|
// === Client saved state ===
|
||||||
|
|
||||||
const storedShowRightDock = ref()
|
const persisted = providePersisted(
|
||||||
const storedRightDockTab = ref()
|
() => projectStore.id,
|
||||||
const rightDockWidth = ref<number>()
|
graphStore,
|
||||||
|
graphNavigator,
|
||||||
|
() => zoomToAll(true),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
const rightDock = provideRightDock(graphStore, persisted)
|
||||||
* JSON serializable representation of graph state saved in localStorage. The names of fields here
|
|
||||||
* are kept relatively short, because it will be common to store hundreds of them within one big
|
|
||||||
* JSON object, and serialize it quite often whenever the state is modified. Shorter keys end up
|
|
||||||
* costing less localStorage space and slightly reduce serialization overhead.
|
|
||||||
*/
|
|
||||||
interface GraphStoredState {
|
|
||||||
/** Navigator position X */
|
|
||||||
x: number
|
|
||||||
/** Navigator position Y */
|
|
||||||
y: number
|
|
||||||
/** Navigator scale */
|
|
||||||
s: number
|
|
||||||
/** Whether or not the documentation panel is open. */
|
|
||||||
doc: boolean
|
|
||||||
/** The selected tab in the right-side panel. */
|
|
||||||
rtab: string
|
|
||||||
/** Width of the right dock. */
|
|
||||||
rwidth: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleAreasReady = computed(() => {
|
// === Zoom/pan ===
|
||||||
const nodesCount = graphStore.db.nodeIdToNode.size
|
|
||||||
const visibleNodeAreas = graphStore.visibleNodeAreas
|
|
||||||
return nodesCount > 0 && visibleNodeAreas.length == nodesCount
|
|
||||||
})
|
|
||||||
|
|
||||||
const { user: userSettings } = useSettings()
|
|
||||||
|
|
||||||
useSyncLocalStorage<GraphStoredState>({
|
|
||||||
storageKey: 'enso-graph-state',
|
|
||||||
mapKeyEncoder: (enc) => {
|
|
||||||
// Client graph state needs to be stored separately for:
|
|
||||||
// - each project
|
|
||||||
// - each function within the project
|
|
||||||
encoding.writeVarString(enc, projectStore.id)
|
|
||||||
const methodPtr = graphStore.currentMethodPointer()
|
|
||||||
if (methodPtr != null) encodeMethodPointer(enc, methodPtr)
|
|
||||||
},
|
|
||||||
debounce: 200,
|
|
||||||
captureState() {
|
|
||||||
return {
|
|
||||||
x: graphNavigator.targetCenter.x,
|
|
||||||
y: graphNavigator.targetCenter.y,
|
|
||||||
s: graphNavigator.targetScale,
|
|
||||||
doc: storedShowRightDock.value,
|
|
||||||
rtab: storedRightDockTab.value,
|
|
||||||
rwidth: rightDockWidth.value ?? null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async restoreState(restored, abort) {
|
|
||||||
if (restored) {
|
|
||||||
const pos = new Vec2(restored.x ?? 0, restored.y ?? 0)
|
|
||||||
const scale = restored.s ?? 1
|
|
||||||
graphNavigator.setCenterAndScale(pos, scale)
|
|
||||||
storedShowRightDock.value = restored.doc ?? undefined
|
|
||||||
storedRightDockTab.value = restored.rtab ?? undefined
|
|
||||||
rightDockWidth.value = restored.rwidth ?? undefined
|
|
||||||
} else {
|
|
||||||
await until(visibleAreasReady).toBe(true)
|
|
||||||
await until(visible).toBe(true)
|
|
||||||
if (!abort.aborted) zoomToAll(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function nodesBounds(nodeIds: Iterable<NodeId>) {
|
function nodesBounds(nodeIds: Iterable<NodeId>) {
|
||||||
let bounds = Rect.Bounding()
|
let bounds = Rect.Bounding()
|
||||||
@ -439,32 +374,18 @@ const codeEditorHandler = codeEditorBindings.handler({
|
|||||||
|
|
||||||
// === Documentation Editor ===
|
// === Documentation Editor ===
|
||||||
|
|
||||||
const displayedDocs = ref<SuggestionId | null>(null)
|
const displayedDocs = ref<SuggestionId>()
|
||||||
const aiMode = ref<boolean>(false)
|
const aiMode = ref<boolean>(false)
|
||||||
|
|
||||||
|
function toggleRightDockHelpPanel() {
|
||||||
|
rightDock.toggleVisible('help')
|
||||||
|
}
|
||||||
|
|
||||||
const docEditor = shallowRef<ComponentInstance<typeof DocumentationEditor>>()
|
const docEditor = shallowRef<ComponentInstance<typeof DocumentationEditor>>()
|
||||||
const documentationEditorArea = computed(() => unrefElement(docEditor))
|
const documentationEditorArea = computed(() => unrefElement(docEditor))
|
||||||
const showRightDock = computedFallback(
|
|
||||||
storedShowRightDock,
|
|
||||||
// Show documentation editor when documentation exists on first graph visit.
|
|
||||||
() => (markdownDocs.value?.length ?? 0) > 0,
|
|
||||||
)
|
|
||||||
const rightDockTab = computedFallback(storedRightDockTab, () => 'docs')
|
|
||||||
|
|
||||||
/* Separate Dock Panel state when Component Browser is opened. */
|
|
||||||
const rightDockTabForCB = ref('help')
|
|
||||||
const rightDockVisibleForCB = ref(true)
|
|
||||||
|
|
||||||
const documentationEditorHandler = documentationEditorBindings.handler({
|
const documentationEditorHandler = documentationEditorBindings.handler({
|
||||||
toggle() {
|
toggle: () => rightDock.toggleVisible(),
|
||||||
rightDockVisible.value = !rightDockVisible.value
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const markdownDocs = computed(() => {
|
|
||||||
const currentMethod = graphStore.methodAst
|
|
||||||
if (!currentMethod.ok) return
|
|
||||||
return currentMethod.value.mutableDocumentationMarkdown()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// === Component Browser ===
|
// === Component Browser ===
|
||||||
@ -473,6 +394,10 @@ const componentBrowserVisible = ref(false)
|
|||||||
const componentBrowserNodePosition = ref<Vec2>(Vec2.Zero)
|
const componentBrowserNodePosition = ref<Vec2>(Vec2.Zero)
|
||||||
const componentBrowserUsage = ref<Usage>({ type: 'newNode' })
|
const componentBrowserUsage = ref<Usage>({ type: 'newNode' })
|
||||||
|
|
||||||
|
watch(componentBrowserVisible, (v) =>
|
||||||
|
rightDock.setStorageMode(v ? StorageMode.ComponentBrowser : StorageMode.Default),
|
||||||
|
)
|
||||||
|
|
||||||
function openComponentBrowser(usage: Usage, position: Vec2) {
|
function openComponentBrowser(usage: Usage, position: Vec2) {
|
||||||
componentBrowserUsage.value = usage
|
componentBrowserUsage.value = usage
|
||||||
componentBrowserNodePosition.value = position
|
componentBrowserNodePosition.value = position
|
||||||
@ -482,47 +407,7 @@ function openComponentBrowser(usage: Usage, position: Vec2) {
|
|||||||
function hideComponentBrowser() {
|
function hideComponentBrowser() {
|
||||||
graphStore.editedNodeInfo = undefined
|
graphStore.editedNodeInfo = undefined
|
||||||
componentBrowserVisible.value = false
|
componentBrowserVisible.value = false
|
||||||
displayedDocs.value = null
|
displayedDocs.value = undefined
|
||||||
}
|
|
||||||
|
|
||||||
const rightDockDisplayedTab = useSelectRef(
|
|
||||||
componentBrowserVisible,
|
|
||||||
computed({
|
|
||||||
get() {
|
|
||||||
if (userSettings.value.showHelpForCB) {
|
|
||||||
return 'help'
|
|
||||||
} else {
|
|
||||||
return showRightDock.value ? rightDockTab.value : rightDockTabForCB.value
|
|
||||||
}
|
|
||||||
},
|
|
||||||
set(tab) {
|
|
||||||
rightDockTabForCB.value = tab
|
|
||||||
userSettings.value.showHelpForCB = tab === 'help'
|
|
||||||
if (showRightDock.value) rightDockTab.value = tab
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
rightDockTab,
|
|
||||||
)
|
|
||||||
|
|
||||||
const rightDockVisible = useSelectRef(
|
|
||||||
componentBrowserVisible,
|
|
||||||
computed({
|
|
||||||
get() {
|
|
||||||
return userSettings.value.showHelpForCB || rightDockVisibleForCB.value || showRightDock.value
|
|
||||||
},
|
|
||||||
set(vis) {
|
|
||||||
rightDockVisibleForCB.value = vis
|
|
||||||
userSettings.value.showHelpForCB = vis
|
|
||||||
if (!vis) showRightDock.value = false
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
showRightDock,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Show help panel if it is not visible. If it is visible, close the right dock. */
|
|
||||||
function toggleRightDockHelpPanel() {
|
|
||||||
rightDockVisible.value = !rightDockVisible.value || rightDockDisplayedTab.value !== 'help'
|
|
||||||
rightDockDisplayedTab.value = 'help'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function editWithComponentBrowser(node: NodeId, cursorPos: number) {
|
function editWithComponentBrowser(node: NodeId, cursorPos: number) {
|
||||||
@ -654,10 +539,9 @@ function collapseNodes(nodes: Node[]) {
|
|||||||
toasts.userActionFailed.show(`Unable to group nodes: ${info.error.payload}.`)
|
toasts.userActionFailed.show(`Unable to group nodes: ${info.error.payload}.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const currentMethod = projectStore.executionContext.getStackTop()
|
const currentMethodName = unwrapOr(graphStore.currentMethodPointer, undefined)?.name
|
||||||
const currentMethodName = graphStore.db.stackItemToMethodName(currentMethod)
|
|
||||||
if (currentMethodName == null) {
|
if (currentMethodName == null) {
|
||||||
bail(`Cannot get the method name for the current execution stack item. ${currentMethod}`)
|
bail(`Cannot get the method name for the current execution stack item.`)
|
||||||
}
|
}
|
||||||
const topLevel = graphStore.moduleRoot
|
const topLevel = graphStore.moduleRoot
|
||||||
if (!topLevel) {
|
if (!topLevel) {
|
||||||
@ -735,8 +619,6 @@ const groupColors = computed(() => {
|
|||||||
}
|
}
|
||||||
return styles
|
return styles
|
||||||
})
|
})
|
||||||
|
|
||||||
const documentationEditorFullscreen = ref(false)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -776,12 +658,13 @@ const documentationEditorFullscreen = ref(false)
|
|||||||
<TopBar
|
<TopBar
|
||||||
v-model:recordMode="projectStore.recordMode"
|
v-model:recordMode="projectStore.recordMode"
|
||||||
v-model:showCodeEditor="showCodeEditor"
|
v-model:showCodeEditor="showCodeEditor"
|
||||||
v-model:showDocumentationEditor="rightDockVisible"
|
:showDocumentationEditor="rightDock.visible"
|
||||||
:zoomLevel="100.0 * graphNavigator.targetScale"
|
:zoomLevel="100.0 * graphNavigator.targetScale"
|
||||||
:class="{ extraRightSpace: !rightDockVisible }"
|
:class="{ extraRightSpace: !rightDock.visible }"
|
||||||
@fitToAllClicked="zoomToSelected"
|
@fitToAllClicked="zoomToSelected"
|
||||||
@zoomIn="graphNavigator.stepZoom(+1)"
|
@zoomIn="graphNavigator.stepZoom(+1)"
|
||||||
@zoomOut="graphNavigator.stepZoom(-1)"
|
@zoomOut="graphNavigator.stepZoom(-1)"
|
||||||
|
@update:showDocumentationEditor="rightDock.setVisible"
|
||||||
/>
|
/>
|
||||||
<SceneScroller
|
<SceneScroller
|
||||||
:navigator="graphNavigator"
|
:navigator="graphNavigator"
|
||||||
@ -800,25 +683,7 @@ const documentationEditorFullscreen = ref(false)
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</BottomPanel>
|
</BottomPanel>
|
||||||
</div>
|
</div>
|
||||||
<DockPanel
|
<RightDockPanel ref="docPanel" v-model:displayedDocs="displayedDocs" :aiMode="aiMode" />
|
||||||
ref="docPanel"
|
|
||||||
v-model:show="rightDockVisible"
|
|
||||||
v-model:size="rightDockWidth"
|
|
||||||
v-model:tab="rightDockDisplayedTab"
|
|
||||||
:contentFullscreen="documentationEditorFullscreen"
|
|
||||||
>
|
|
||||||
<template #docs>
|
|
||||||
<DocumentationEditor
|
|
||||||
v-if="markdownDocs"
|
|
||||||
ref="docEditor"
|
|
||||||
:yText="markdownDocs"
|
|
||||||
@update:fullscreen="documentationEditorFullscreen = $event"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #help>
|
|
||||||
<ComponentDocumentation v-model="displayedDocs" :aiMode="aiMode" />
|
|
||||||
</template>
|
|
||||||
</DockPanel>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -862,7 +727,6 @@ const documentationEditorFullscreen = ref(false)
|
|||||||
contain: layout;
|
contain: layout;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
--group-color-fallback: #006b8a;
|
|
||||||
--node-color-no-type: #596b81;
|
--node-color-no-type: #596b81;
|
||||||
--output-node-color: #006b8a;
|
--output-node-color: #006b8a;
|
||||||
}
|
}
|
||||||
|
@ -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 { graphBindings, nodeEditBindings } from '@/bindings'
|
||||||
import ComponentContextMenu from '@/components/ComponentContextMenu.vue'
|
import ComponentContextMenu from '@/components/ComponentContextMenu.vue'
|
||||||
import ComponentMenu from '@/components/ComponentMenu.vue'
|
import ComponentMenu from '@/components/ComponentMenu.vue'
|
||||||
|
import ComponentWidgetTree, {
|
||||||
|
GRAB_HANDLE_X_MARGIN_L,
|
||||||
|
GRAB_HANDLE_X_MARGIN_R,
|
||||||
|
ICON_WIDTH,
|
||||||
|
} from '@/components/GraphEditor/ComponentWidgetTree.vue'
|
||||||
import GraphNodeComment from '@/components/GraphEditor/GraphNodeComment.vue'
|
import GraphNodeComment from '@/components/GraphEditor/GraphNodeComment.vue'
|
||||||
import GraphNodeMessage, {
|
import GraphNodeMessage, {
|
||||||
colorForMessageType,
|
colorForMessageType,
|
||||||
@ -11,11 +16,6 @@ import GraphNodeMessage, {
|
|||||||
import GraphNodeOutputPorts from '@/components/GraphEditor/GraphNodeOutputPorts.vue'
|
import GraphNodeOutputPorts from '@/components/GraphEditor/GraphNodeOutputPorts.vue'
|
||||||
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
|
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
|
||||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||||
import NodeWidgetTree, {
|
|
||||||
GRAB_HANDLE_X_MARGIN_L,
|
|
||||||
GRAB_HANDLE_X_MARGIN_R,
|
|
||||||
ICON_WIDTH,
|
|
||||||
} from '@/components/GraphEditor/NodeWidgetTree.vue'
|
|
||||||
import PointFloatingMenu from '@/components/PointFloatingMenu.vue'
|
import PointFloatingMenu from '@/components/PointFloatingMenu.vue'
|
||||||
import SmallPlusButton from '@/components/SmallPlusButton.vue'
|
import SmallPlusButton from '@/components/SmallPlusButton.vue'
|
||||||
import SvgIcon from '@/components/SvgIcon.vue'
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
@ -318,53 +318,13 @@ const nodeEditHandler = nodeEditBindings.handler({
|
|||||||
e.target.blur()
|
e.target.blur()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
edit(e) {
|
edit() {
|
||||||
const pos = 'clientX' in e ? new Vec2(e.clientX, e.clientY) : undefined
|
startEditingNode()
|
||||||
startEditingNode(pos)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function startEditingNode(position?: Vec2 | undefined) {
|
function startEditingNode() {
|
||||||
let sourceOffset = props.node.rootExpr.code().length
|
emit('update:edited', props.node.rootExpr.code().length)
|
||||||
if (position != null) {
|
|
||||||
let domNode, domOffset
|
|
||||||
if ((document as any).caretPositionFromPoint) {
|
|
||||||
const caret = document.caretPositionFromPoint(position.x, position.y)
|
|
||||||
domNode = caret?.offsetNode
|
|
||||||
domOffset = caret?.offset
|
|
||||||
} else if (document.caretRangeFromPoint) {
|
|
||||||
const caret = document.caretRangeFromPoint(position.x, position.y)
|
|
||||||
domNode = caret?.startContainer
|
|
||||||
domOffset = caret?.startOffset
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
'Neither `caretPositionFromPoint` nor `caretRangeFromPoint` are supported by this browser',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (domNode != null && domOffset != null) {
|
|
||||||
sourceOffset = getRelatedSpanOffset(domNode, domOffset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit('update:edited', sourceOffset)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRelatedSpanOffset(domNode: globalThis.Node, domOffset: number): number {
|
|
||||||
if (domNode instanceof HTMLElement && domOffset == 1) {
|
|
||||||
const offsetData = domNode.dataset.spanStart
|
|
||||||
const offset = (offsetData != null && parseInt(offsetData)) || 0
|
|
||||||
const length = domNode.textContent?.length ?? 0
|
|
||||||
return offset + length
|
|
||||||
} else if (domNode instanceof Text) {
|
|
||||||
const siblingEl = domNode.previousElementSibling
|
|
||||||
if (siblingEl instanceof HTMLElement) {
|
|
||||||
const offsetData = siblingEl.dataset.spanStart
|
|
||||||
if (offsetData != null)
|
|
||||||
return parseInt(offsetData) + domOffset + (siblingEl.textContent?.length ?? 0)
|
|
||||||
}
|
|
||||||
const offsetData = domNode.parentElement?.dataset.spanStart
|
|
||||||
if (offsetData != null) return parseInt(offsetData) + domOffset
|
|
||||||
}
|
|
||||||
return domOffset
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNodeClick = useDoubleClick(
|
const handleNodeClick = useDoubleClick(
|
||||||
@ -403,6 +363,16 @@ const dataSource = computed(
|
|||||||
() => ({ type: 'node', nodeId: props.node.rootExpr.externalId }) as const,
|
() => ({ type: 'node', nodeId: props.node.rootExpr.externalId }) as const,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pending = computed(() => {
|
||||||
|
switch (executionState.value) {
|
||||||
|
case 'Unknown':
|
||||||
|
case 'Pending':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// === Recompute node expression ===
|
// === Recompute node expression ===
|
||||||
|
|
||||||
function useRecomputation() {
|
function useRecomputation() {
|
||||||
@ -421,6 +391,30 @@ function useRecomputation() {
|
|||||||
return { recomputeOnce, isBeingRecomputed }
|
return { recomputeOnce, isBeingRecomputed }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nodeStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
transform: transform.value,
|
||||||
|
minWidth: isVisualizationEnabled.value ? `${visualizationWidth.value ?? 200}px` : undefined,
|
||||||
|
'--node-group-color': color.value,
|
||||||
|
...(props.node.zIndex ? { 'z-index': props.node.zIndex } : {}),
|
||||||
|
'--viz-below-node': `${graphSelectionSize.value.y - nodeSize.value.y}px`,
|
||||||
|
'--node-size-x': `${nodeSize.value.x}px`,
|
||||||
|
'--node-size-y': `${nodeSize.value.y}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeClass = computed(() => {
|
||||||
|
return {
|
||||||
|
selected: selected.value,
|
||||||
|
selectionVisible: selectionVisible.value,
|
||||||
|
pending: pending.value,
|
||||||
|
inputNode: props.node.type === 'input',
|
||||||
|
outputNode: props.node.type === 'output',
|
||||||
|
menuVisible: menuVisible.value,
|
||||||
|
menuFull: menuFull.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// === Component actions ===
|
// === Component actions ===
|
||||||
|
|
||||||
const { getNodeColor, getNodeColors } = injectNodeColors()
|
const { getNodeColor, getNodeColors } = injectNodeColors()
|
||||||
@ -476,25 +470,9 @@ const showMenuAt = ref<{ x: number; y: number }>()
|
|||||||
<div
|
<div
|
||||||
v-show="!edited"
|
v-show="!edited"
|
||||||
ref="rootNode"
|
ref="rootNode"
|
||||||
class="GraphNode"
|
class="GraphNode define-node-colors"
|
||||||
:style="{
|
:style="nodeStyle"
|
||||||
transform,
|
:class="nodeClass"
|
||||||
minWidth: isVisualizationEnabled ? `${visualizationWidth ?? 200}px` : undefined,
|
|
||||||
'--node-group-color': color,
|
|
||||||
...(node.zIndex ? { 'z-index': node.zIndex } : {}),
|
|
||||||
'--viz-below-node': `${graphSelectionSize.y - nodeSize.y}px`,
|
|
||||||
'--node-size-x': `${nodeSize.x}px`,
|
|
||||||
'--node-size-y': `${nodeSize.y}px`,
|
|
||||||
}"
|
|
||||||
:class="{
|
|
||||||
selected,
|
|
||||||
selectionVisible,
|
|
||||||
['executionState-' + executionState]: true,
|
|
||||||
inputNode: props.node.type === 'input',
|
|
||||||
outputNode: props.node.type === 'output',
|
|
||||||
menuVisible,
|
|
||||||
menuFull,
|
|
||||||
}"
|
|
||||||
:data-node-id="nodeId"
|
:data-node-id="nodeId"
|
||||||
@pointerenter="(nodeHovered = true), updateNodeHover($event)"
|
@pointerenter="(nodeHovered = true), updateNodeHover($event)"
|
||||||
@pointerleave="(nodeHovered = false), updateNodeHover(undefined)"
|
@pointerleave="(nodeHovered = false), updateNodeHover(undefined)"
|
||||||
@ -553,12 +531,11 @@ const showMenuAt = ref<{ x: number; y: number }>()
|
|||||||
@click="handleNodeClick"
|
@click="handleNodeClick"
|
||||||
@contextmenu.stop.prevent="ensureSelected(), (showMenuAt = $event)"
|
@contextmenu.stop.prevent="ensureSelected(), (showMenuAt = $event)"
|
||||||
>
|
>
|
||||||
<NodeWidgetTree
|
<ComponentWidgetTree
|
||||||
:ast="props.node.innerExpr"
|
:ast="props.node.innerExpr"
|
||||||
:nodeId="nodeId"
|
:nodeId="nodeId"
|
||||||
:nodeElement="rootNode"
|
:rootElement="rootNode"
|
||||||
:nodeType="props.node.type"
|
:nodeType="props.node.type"
|
||||||
:nodeSize="nodeSize"
|
|
||||||
:potentialSelfArgumentId="potentialSelfArgumentId"
|
:potentialSelfArgumentId="potentialSelfArgumentId"
|
||||||
:conditionalPorts="props.node.conditionalPorts"
|
:conditionalPorts="props.node.conditionalPorts"
|
||||||
:extended="isOnlyOneSelected"
|
:extended="isOnlyOneSelected"
|
||||||
@ -609,7 +586,6 @@ const showMenuAt = ref<{ x: number; y: number }>()
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
--output-port-transform: translateY(var(--viz-below-node));
|
--output-port-transform: translateY(var(--viz-below-node));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -622,33 +598,11 @@ const showMenuAt = ref<{ x: number; y: number }>()
|
|||||||
transition: fill 0.2s ease;
|
transition: fill 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.GraphNode {
|
|
||||||
--color-node-text: white;
|
|
||||||
--color-node-primary: var(--node-group-color);
|
|
||||||
--color-node-background: var(--node-group-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.GraphNode.selected {
|
|
||||||
--color-node-background: color-mix(in oklab, var(--color-node-primary) 30%, white 70%);
|
|
||||||
--color-node-text: color-mix(in oklab, var(--color-node-primary) 70%, black 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.GraphNode {
|
.GraphNode {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: var(--node-border-radius);
|
border-radius: var(--node-border-radius);
|
||||||
transition: box-shadow 0.2s ease-in-out;
|
transition: box-shadow 0.2s ease-in-out;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
/** Space between node and component above and below, such as comments and errors. */
|
|
||||||
--node-vertical-gap: 5px;
|
|
||||||
|
|
||||||
--color-node-primary: var(--node-group-color);
|
|
||||||
--node-color-port: color-mix(in oklab, var(--color-node-primary) 85%, white 15%);
|
|
||||||
--node-color-error: color-mix(in oklab, var(--node-group-color) 30%, rgb(255, 0, 0) 70%);
|
|
||||||
|
|
||||||
&.executionState-Unknown,
|
|
||||||
&.executionState-Pending {
|
|
||||||
--color-node-primary: color-mix(in oklab, var(--node-group-color) 60%, #aaa 40%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
@ -158,7 +158,7 @@ graph.suggestEdgeFromOutput(outputHovered)
|
|||||||
rx: calc(var(--node-border-radius) + var(--output-port-width) / 2);
|
rx: calc(var(--node-border-radius) + var(--output-port-width) / 2);
|
||||||
|
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: var(--node-color-port);
|
stroke: var(--color-node-edge);
|
||||||
stroke-width: calc(var(--output-port-width) + var(--output-port-overlap-anim));
|
stroke-width: calc(var(--output-port-width) + var(--output-port-overlap-anim));
|
||||||
transition: stroke 0.2s ease;
|
transition: stroke 0.2s ease;
|
||||||
--horizontal-line: calc(var(--node-size-x) - var(--node-border-radius) * 2);
|
--horizontal-line: calc(var(--node-size-x) - var(--node-border-radius) * 2);
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { WidgetModule } from '@/providers/widgetRegistry'
|
import type { WidgetModule } from '@/providers/widgetRegistry'
|
||||||
import { injectWidgetRegistry, WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
|
import { injectWidgetRegistry, WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
|
||||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
|
||||||
import {
|
import {
|
||||||
injectWidgetUsageInfo,
|
injectWidgetUsageInfo,
|
||||||
provideWidgetUsageInfo,
|
provideWidgetUsageInfo,
|
||||||
usageKeyForInput,
|
usageKeyForInput,
|
||||||
} from '@/providers/widgetUsageInfo'
|
} from '@/providers/widgetUsageInfo'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
|
||||||
import { Ast } from '@/util/ast'
|
|
||||||
import { computed, getCurrentInstance, proxyRefs, shallowRef, watchEffect, withCtx } from 'vue'
|
import { computed, getCurrentInstance, proxyRefs, shallowRef, watchEffect, withCtx } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -28,9 +25,7 @@ defineOptions({
|
|||||||
|
|
||||||
type UpdateHandler = (update: WidgetUpdate) => boolean
|
type UpdateHandler = (update: WidgetUpdate) => boolean
|
||||||
|
|
||||||
const graph = useGraphStore()
|
|
||||||
const registry = injectWidgetRegistry()
|
const registry = injectWidgetRegistry()
|
||||||
const tree = injectWidgetTree()
|
|
||||||
const parentUsageInfo = injectWidgetUsageInfo(true)
|
const parentUsageInfo = injectWidgetUsageInfo(true)
|
||||||
const usageKey = computed(() => usageKeyForInput(props.input))
|
const usageKey = computed(() => usageKeyForInput(props.input))
|
||||||
const sameInputAsParent = computed(() => parentUsageInfo?.usageKey === usageKey.value)
|
const sameInputAsParent = computed(() => parentUsageInfo?.usageKey === usageKey.value)
|
||||||
@ -82,13 +77,6 @@ provideWidgetUsageInfo(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const spanStart = computed(() => {
|
|
||||||
if (!(props.input instanceof Ast.Ast)) return undefined
|
|
||||||
const span = graph.moduleSource.getSpan(props.input.id)
|
|
||||||
if (span == null) return undefined
|
|
||||||
return span[0] - tree.nodeSpanStart
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -98,7 +86,6 @@ const spanStart = computed(() => {
|
|||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
:input="props.input"
|
:input="props.input"
|
||||||
:nesting="nesting"
|
:nesting="nesting"
|
||||||
:data-span-start="spanStart"
|
|
||||||
:data-port="props.input.portId"
|
:data-port="props.input.portId"
|
||||||
@update="updateHandler"
|
@update="updateHandler"
|
||||||
/>
|
/>
|
||||||
|
@ -1,111 +1,52 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||||
import { useTransitioning } from '@/composables/animation'
|
import { useTransitioning } from '@/composables/animation'
|
||||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
|
||||||
import { WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
|
import { WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
|
||||||
|
import { WidgetEditHandlerParent } from '@/providers/widgetRegistry/editHandler'
|
||||||
import { provideWidgetTree } from '@/providers/widgetTree'
|
import { provideWidgetTree } from '@/providers/widgetTree'
|
||||||
import { useGraphStore, type NodeId } from '@/stores/graph'
|
|
||||||
import type { NodeType } from '@/stores/graph/graphDatabase'
|
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import type { Vec2 } from '@/util/data/vec2'
|
import { toRef, watch } from 'vue'
|
||||||
import { iconOfNode } from '@/util/getIconName'
|
import { AstId } from 'ydoc-shared/ast'
|
||||||
import { computed, toRef, watch } from 'vue'
|
import { ExternalId } from 'ydoc-shared/yjsModel'
|
||||||
import { DisplayIcon } from './widgets/WidgetIcon.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
ast: Ast.Expression
|
externalId: string & ExternalId
|
||||||
nodeId: NodeId
|
input: WidgetInput
|
||||||
nodeElement: HTMLElement | undefined
|
rootElement: HTMLElement | undefined
|
||||||
nodeType: NodeType
|
potentialSelfArgumentId?: AstId | undefined
|
||||||
nodeSize: Vec2
|
|
||||||
potentialSelfArgumentId: Ast.AstId | undefined
|
|
||||||
/** Ports that are not targetable by default; see {@link NodeDataFromAst}. */
|
/** Ports that are not targetable by default; see {@link NodeDataFromAst}. */
|
||||||
conditionalPorts: Set<Ast.AstId>
|
conditionalPorts?: Set<Ast.AstId> | undefined
|
||||||
extended: boolean
|
extended: boolean
|
||||||
|
onUpdate: (update: WidgetUpdate) => boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
currentEditChanged: [WidgetEditHandlerParent | undefined]
|
||||||
}>()
|
}>()
|
||||||
const graph = useGraphStore()
|
|
||||||
const rootPort = computed(() => {
|
|
||||||
const input = WidgetInput.FromAst(props.ast)
|
|
||||||
if (
|
|
||||||
props.ast instanceof Ast.Ident &&
|
|
||||||
(!graph.db.isKnownFunctionCall(props.ast.id) || graph.db.connections.hasValue(props.ast.id))
|
|
||||||
) {
|
|
||||||
input.forcePort = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!props.potentialSelfArgumentId && topLevelIcon.value) {
|
const layoutTransitions = useTransitioning(
|
||||||
input[DisplayIcon] = {
|
new Set([
|
||||||
icon: topLevelIcon.value,
|
'margin-left',
|
||||||
showContents: props.nodeType != 'output',
|
'margin-right',
|
||||||
}
|
'margin-top',
|
||||||
}
|
'margin-bottom',
|
||||||
return input
|
'padding-left',
|
||||||
})
|
'padding-right',
|
||||||
const selection = injectGraphSelection()
|
'padding-top',
|
||||||
|
'padding-bottom',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
const observedLayoutTransitions = new Set([
|
const tree = provideWidgetTree(
|
||||||
'margin-left',
|
toRef(props, 'externalId'),
|
||||||
'margin-right',
|
toRef(props, 'rootElement'),
|
||||||
'margin-top',
|
|
||||||
'margin-bottom',
|
|
||||||
'padding-left',
|
|
||||||
'padding-right',
|
|
||||||
'padding-top',
|
|
||||||
'padding-bottom',
|
|
||||||
'width',
|
|
||||||
'height',
|
|
||||||
])
|
|
||||||
|
|
||||||
function selectNode() {
|
|
||||||
selection.setSelection(new Set([props.nodeId]))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWidgetUpdates(update: WidgetUpdate) {
|
|
||||||
selectNode()
|
|
||||||
const edit = update.edit ?? graph.startEdit()
|
|
||||||
if (update.portUpdate) {
|
|
||||||
const { origin } = update.portUpdate
|
|
||||||
if (Ast.isAstId(origin)) {
|
|
||||||
if ('value' in update.portUpdate) {
|
|
||||||
const value = update.portUpdate.value
|
|
||||||
const ast =
|
|
||||||
value instanceof Ast.Ast ? value
|
|
||||||
: value == null ? Ast.Wildcard.new(edit)
|
|
||||||
: undefined
|
|
||||||
if (ast) {
|
|
||||||
edit.replaceValue(origin, ast)
|
|
||||||
} else if (typeof value === 'string') {
|
|
||||||
edit.tryGet(origin)?.syncToCode(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ('metadata' in update.portUpdate) {
|
|
||||||
const { metadataKey, metadata } = update.portUpdate
|
|
||||||
edit.tryGet(origin)?.setWidgetMetadata(metadataKey, metadata)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(`[UPDATE ${origin}] Invalid top-level origin. Expected expression ID.`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
graph.commitEdit(edit)
|
|
||||||
// This handler is guaranteed to be the last handler in the chain.
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutTransitions = useTransitioning(observedLayoutTransitions)
|
|
||||||
const widgetTree = provideWidgetTree(
|
|
||||||
toRef(props, 'ast'),
|
|
||||||
toRef(props, 'nodeId'),
|
|
||||||
toRef(props, 'nodeElement'),
|
|
||||||
toRef(props, 'nodeSize'),
|
|
||||||
toRef(props, 'potentialSelfArgumentId'),
|
|
||||||
toRef(props, 'conditionalPorts'),
|
toRef(props, 'conditionalPorts'),
|
||||||
toRef(props, 'extended'),
|
toRef(props, 'extended'),
|
||||||
layoutTransitions.active,
|
layoutTransitions.active,
|
||||||
|
toRef(props, 'potentialSelfArgumentId'),
|
||||||
)
|
)
|
||||||
|
watch(toRef(tree, 'currentEdit'), (edit) => emit('currentEditChanged', edit))
|
||||||
const topLevelIcon = computed(() => iconOfNode(props.nodeId, graph.db))
|
|
||||||
|
|
||||||
watch(toRef(widgetTree, 'currentEdit'), (edit) => edit && selectNode())
|
|
||||||
</script>
|
</script>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export const GRAB_HANDLE_X_MARGIN_L = 4
|
export const GRAB_HANDLE_X_MARGIN_L = 4
|
||||||
@ -114,13 +55,13 @@ export const ICON_WIDTH = 16
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="NodeWidgetTree widgetRounded" spellcheck="false" v-on="layoutTransitions.events">
|
<div class="WidgetTreeRoot widgetRounded" spellcheck="false" v-on="layoutTransitions.events">
|
||||||
<NodeWidget :input="rootPort" @update="handleWidgetUpdates" />
|
<NodeWidget :input="input" :onUpdate="onUpdate" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.NodeWidgetTree {
|
.WidgetTreeRoot {
|
||||||
color: var(--color-node-text);
|
color: var(--color-node-text);
|
||||||
|
|
||||||
outline: none;
|
outline: none;
|
||||||
@ -161,7 +102,7 @@ export const ICON_WIDTH = 16
|
|||||||
* at the end of the widget template, so it doesn't prevent tokens around
|
* at the end of the widget template, so it doesn't prevent tokens around
|
||||||
* them from being properly padded.
|
* them from being properly padded.
|
||||||
*/
|
*/
|
||||||
.NodeWidgetTree {
|
.WidgetTreeRoot {
|
||||||
/*
|
/*
|
||||||
* Core of the propagation logic. Prevent left/right margin from propagating to non-first non-last
|
* Core of the propagation logic. Prevent left/right margin from propagating to non-first non-last
|
||||||
* children of a widget. That way, only the innermost left/right deep child of a rounded widget will
|
* children of a widget. That way, only the innermost left/right deep child of a rounded widget will
|
@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||||
import { FunctionName } from '@/components/GraphEditor/widgets/WidgetFunctionName.vue'
|
|
||||||
import SizeTransition from '@/components/SizeTransition.vue'
|
import SizeTransition from '@/components/SizeTransition.vue'
|
||||||
import { WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
import { WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||||
@ -25,11 +24,6 @@ const targetMaybePort = computed(() => {
|
|||||||
if (!ptr) return input
|
if (!ptr) return input
|
||||||
const definition = graph.getMethodAst(ptr)
|
const definition = graph.getMethodAst(ptr)
|
||||||
if (!definition.ok) return input
|
if (!definition.ok) return input
|
||||||
if (input.value instanceof Ast.PropertyAccess || input.value instanceof Ast.Ident) {
|
|
||||||
input[FunctionName] = {
|
|
||||||
editableName: definition.value.name.externalId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return input
|
return input
|
||||||
} else {
|
} else {
|
||||||
return { ...target.toWidgetInput(), forcePort: !(target instanceof ArgumentApplication) }
|
return { ...target.toWidgetInput(), forcePort: !(target instanceof ArgumentApplication) }
|
||||||
|
@ -33,7 +33,7 @@ export const widgetDefinition = defineWidget(
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: var(--node-color-port);
|
background-color: var(--color-node-port);
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,9 +36,10 @@ const value = computed({
|
|||||||
set(value) {
|
set(value) {
|
||||||
const edit = graph.startEdit()
|
const edit = graph.startEdit()
|
||||||
const theImport = value ? trueImport.value : falseImport.value
|
const theImport = value ? trueImport.value : falseImport.value
|
||||||
if (props.input.value instanceof Ast.Ast) {
|
const inputValue: Ast.Expression | string | undefined = props.input.value
|
||||||
|
if (inputValue instanceof Ast.Ast) {
|
||||||
const { requiresImport } = setBoolNode(
|
const { requiresImport } = setBoolNode(
|
||||||
edit.getVersion(props.input.value),
|
edit.getVersion(inputValue),
|
||||||
value ? ('True' as Identifier) : ('False' as Identifier),
|
value ? ('True' as Identifier) : ('False' as Identifier),
|
||||||
)
|
)
|
||||||
if (requiresImport) graph.addMissingImports(edit, theImport)
|
if (requiresImport) graph.addMissingImports(edit, theImport)
|
||||||
@ -64,7 +65,7 @@ const argumentName = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
function isBoolNode(ast: Ast.Expression) {
|
function isBoolNode(ast: Ast.Ast) {
|
||||||
const candidate =
|
const candidate =
|
||||||
ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean' ? ast.rhs
|
ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean' ? ast.rhs
|
||||||
: ast instanceof Ast.Ident ? ast.token
|
: ast instanceof Ast.Ident ? ast.token
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||||
import { useWidgetFunctionCallInfo } from '@/components/GraphEditor/widgets/WidgetFunction/widgetFunctionCallInfo'
|
import { useWidgetFunctionCallInfo } from '@/components/GraphEditor/widgets/WidgetFunction/widgetFunctionCallInfo'
|
||||||
import { FunctionName } from '@/components/GraphEditor/widgets/WidgetFunctionName.vue'
|
|
||||||
import { injectFunctionInfo, provideFunctionInfo } from '@/providers/functionInfo'
|
import { injectFunctionInfo, provideFunctionInfo } from '@/providers/functionInfo'
|
||||||
import {
|
import {
|
||||||
Score,
|
Score,
|
||||||
@ -65,13 +64,7 @@ const innerInput = computed(() => {
|
|||||||
input = { ...props.input }
|
input = { ...props.input }
|
||||||
}
|
}
|
||||||
const callInfo = methodCallInfo.value
|
const callInfo = methodCallInfo.value
|
||||||
if (callInfo) {
|
if (callInfo) input[CallInfo] = callInfo
|
||||||
input[CallInfo] = callInfo
|
|
||||||
if (input.value instanceof Ast.PropertyAccess || input.value instanceof Ast.Ident) {
|
|
||||||
const definition = graph.getMethodAst(callInfo.methodCall.methodPointer)
|
|
||||||
if (definition.ok) input[FunctionName] = { editableName: definition.value.name.externalId }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return input
|
return input
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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">
|
<script setup lang="ts">
|
||||||
import AutoSizedInput from '@/components/widgets/AutoSizedInput.vue'
|
import AutoSizedInput from '@/components/widgets/AutoSizedInput.vue'
|
||||||
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry'
|
||||||
|
import { useGraphStore } from '@/stores/graph'
|
||||||
|
import { usePersisted } from '@/stores/persisted'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import { Err, Ok, type Result } from '@/util/data/result'
|
import { Err, Ok, type Result } from '@/util/data/result'
|
||||||
import { useToast } from '@/util/toast'
|
import { useToast } from '@/util/toast'
|
||||||
import { computed, ref, watchEffect } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { PropertyAccess } from 'ydoc-shared/ast'
|
import { PropertyAccess } from 'ydoc-shared/ast'
|
||||||
import type { ExpressionId } from 'ydoc-shared/languageServerTypes'
|
import type { ExpressionId, MethodPointer } from 'ydoc-shared/languageServerTypes'
|
||||||
import NodeWidget from '../NodeWidget.vue'
|
import NodeWidget from '../NodeWidget.vue'
|
||||||
|
|
||||||
const props = defineProps(widgetProps(widgetDefinition))
|
const props = defineProps(widgetProps(widgetDefinition))
|
||||||
|
const graph = useGraphStore(true)
|
||||||
|
const persisted = usePersisted(true)
|
||||||
const displayedName = ref(props.input.value.code())
|
const displayedName = ref(props.input.value.code())
|
||||||
|
|
||||||
const project = useProjectStore()
|
const project = useProjectStore()
|
||||||
@ -25,24 +29,37 @@ const operator = computed(() =>
|
|||||||
const name = computed(() =>
|
const name = computed(() =>
|
||||||
props.input.value instanceof PropertyAccess ? props.input.value.rhs : props.input.value,
|
props.input.value instanceof PropertyAccess ? props.input.value.rhs : props.input.value,
|
||||||
)
|
)
|
||||||
watchEffect(() => (displayedName.value = name.value.code()))
|
|
||||||
|
|
||||||
function newNameAccepted(newName: string | undefined) {
|
const nameCode = computed(() => name.value.code())
|
||||||
|
watch(nameCode, (newValue) => (displayedName.value = newValue))
|
||||||
|
|
||||||
|
async function newNameAccepted(newName: string | undefined) {
|
||||||
if (!newName) {
|
if (!newName) {
|
||||||
displayedName.value = name.value.code()
|
displayedName.value = name.value.code()
|
||||||
} else {
|
} else {
|
||||||
renameFunction(newName).then((result) => result.ok || renameError.reportError(result.error))
|
const result = await renameFunction(newName)
|
||||||
|
if (!result.ok) {
|
||||||
|
renameError.reportError(result.error)
|
||||||
|
displayedName.value = name.value.code()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renameFunction(newName: string): Promise<Result> {
|
async function renameFunction(newName: string): Promise<Result> {
|
||||||
if (!project.modulePath?.ok) return project.modulePath ?? Err('Unknown module Path')
|
if (!project.modulePath?.ok) return project.modulePath ?? Err('Unknown module Path')
|
||||||
const refactorResult = await project.lsRpcConnection.renameSymbol(
|
const modPath = project.modulePath.value
|
||||||
project.modulePath.value,
|
const editedName = props.input[FunctionName].editableNameExpression
|
||||||
props.input[FunctionName].editableName,
|
const oldMethodPointer = props.input[FunctionName].methodPointer
|
||||||
newName,
|
const refactorResult = await project.lsRpcConnection.renameSymbol(modPath, editedName, newName)
|
||||||
)
|
|
||||||
if (!refactorResult.ok) return refactorResult
|
if (!refactorResult.ok) return refactorResult
|
||||||
|
if (oldMethodPointer) {
|
||||||
|
const newMethodPointer = {
|
||||||
|
...oldMethodPointer,
|
||||||
|
name: refactorResult.value.newName,
|
||||||
|
}
|
||||||
|
graph?.db.insertSyntheticMethodPointerUpdate(oldMethodPointer, newMethodPointer)
|
||||||
|
persisted?.handleModifiedMethodPointer(oldMethodPointer, newMethodPointer)
|
||||||
|
}
|
||||||
return Ok()
|
return Ok()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -56,21 +73,23 @@ declare module '@/providers/widgetRegistry' {
|
|||||||
* Id of expression which is accepted by Language Server's
|
* Id of expression which is accepted by Language Server's
|
||||||
* [`refactoring/renameSymbol` method](https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-language-server.md#refactoringrenamesymbol)
|
* [`refactoring/renameSymbol` method](https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-language-server.md#refactoringrenamesymbol)
|
||||||
*/
|
*/
|
||||||
editableName: ExpressionId
|
editableNameExpression: ExpressionId
|
||||||
|
methodPointer: MethodPointer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFunctionName(
|
function isFunctionName(input: WidgetInput): input is WidgetInput & {
|
||||||
input: WidgetInput,
|
value: Ast.Ast
|
||||||
): input is WidgetInput & { value: Ast.Ast; [FunctionName]: { editableName: ExpressionId } } {
|
[FunctionName]: { editableNameExpression: ExpressionId }
|
||||||
|
} {
|
||||||
return WidgetInput.isAst(input) && FunctionName in input
|
return WidgetInput.isAst(input) && FunctionName in input
|
||||||
}
|
}
|
||||||
|
|
||||||
export const widgetDefinition = defineWidget(
|
export const widgetDefinition = defineWidget(
|
||||||
isFunctionName,
|
isFunctionName,
|
||||||
{
|
{
|
||||||
priority: -20,
|
priority: 2,
|
||||||
score: Score.Perfect,
|
score: Score.Perfect,
|
||||||
},
|
},
|
||||||
import.meta.hot,
|
import.meta.hot,
|
||||||
|
@ -14,6 +14,7 @@ declare module '@/providers/widgetRegistry' {
|
|||||||
export interface WidgetInput {
|
export interface WidgetInput {
|
||||||
[DisplayIcon]?: {
|
[DisplayIcon]?: {
|
||||||
icon: Icon | URLString
|
icon: Icon | URLString
|
||||||
|
allowChoice?: boolean
|
||||||
showContents?: boolean
|
showContents?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -40,28 +40,22 @@ const isCurrentEdgeHoverTarget = computed(
|
|||||||
() =>
|
() =>
|
||||||
graph.mouseEditedEdge?.source != null &&
|
graph.mouseEditedEdge?.source != null &&
|
||||||
selection?.hoveredPort === portId.value &&
|
selection?.hoveredPort === portId.value &&
|
||||||
graph.db.getPatternExpressionNodeId(graph.mouseEditedEdge.source) !== tree.nodeId,
|
graph.db.getPatternExpressionNodeId(graph.mouseEditedEdge.source) !== tree.externalId,
|
||||||
)
|
)
|
||||||
const isCurrentDisconnectedEdgeTarget = computed(
|
const isCurrentDisconnectedEdgeTarget = computed(
|
||||||
() =>
|
() =>
|
||||||
graph.mouseEditedEdge?.disconnectedEdgeTarget === portId.value &&
|
graph.mouseEditedEdge?.disconnectedEdgeTarget === portId.value &&
|
||||||
graph.mouseEditedEdge?.target !== portId.value,
|
graph.mouseEditedEdge?.target !== portId.value,
|
||||||
)
|
)
|
||||||
const isSelfArgument = computed(
|
const connected = computed(() => hasConnection.value || isCurrentEdgeHoverTarget.value)
|
||||||
() =>
|
|
||||||
props.input.value instanceof Ast.Ast && props.input.value.id === tree.potentialSelfArgumentId,
|
|
||||||
)
|
|
||||||
const connected = computed(
|
|
||||||
() => (!isSelfArgument.value && hasConnection.value) || isCurrentEdgeHoverTarget.value,
|
|
||||||
)
|
|
||||||
const isTarget = computed(
|
const isTarget = computed(
|
||||||
() =>
|
() =>
|
||||||
(hasConnection.value && !isCurrentDisconnectedEdgeTarget.value) ||
|
(hasConnection.value && !isCurrentDisconnectedEdgeTarget.value) ||
|
||||||
isCurrentEdgeHoverTarget.value,
|
isCurrentEdgeHoverTarget.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
const rootNode = shallowRef<HTMLElement>()
|
const portRoot = shallowRef<HTMLElement>()
|
||||||
const nodeSize = useResizeObserver(rootNode)
|
const portSize = useResizeObserver(portRoot)
|
||||||
|
|
||||||
// Compute the scene-space bounding rectangle of the expression's widget. Those bounds are later
|
// Compute the scene-space bounding rectangle of the expression's widget. Those bounds are later
|
||||||
// used for edge positioning. Querying and updating those bounds is relatively expensive, so we only
|
// used for edge positioning. Querying and updating those bounds is relatively expensive, so we only
|
||||||
@ -87,20 +81,22 @@ providePortInfo(proxyRefs({ portId, connected: hasConnection }))
|
|||||||
|
|
||||||
watchEffect(
|
watchEffect(
|
||||||
(onCleanup) => {
|
(onCleanup) => {
|
||||||
|
const externalId = tree.externalId
|
||||||
|
if (!graph.db.isNodeId(externalId)) return
|
||||||
const id = portId.value
|
const id = portId.value
|
||||||
const instance = new PortViewInstance(portRect, tree.nodeId, props.onUpdate)
|
const instance = new PortViewInstance(portRect, externalId, props.onUpdate)
|
||||||
graph.addPortInstance(id, instance)
|
graph.addPortInstance(id, instance)
|
||||||
onCleanup(() => graph.removePortInstance(id, instance))
|
onCleanup(() => graph.removePortInstance(id, instance))
|
||||||
},
|
},
|
||||||
{ flush: 'post' },
|
{ flush: 'post' },
|
||||||
)
|
)
|
||||||
|
|
||||||
const keyboard = injectKeyboard()
|
const keyboard = injectKeyboard(true)
|
||||||
|
|
||||||
const enabled = computed(() => {
|
const enabled = computed(() => {
|
||||||
const input = props.input.value
|
const input = props.input.value
|
||||||
const isConditional = input instanceof Ast.Ast && tree.conditionalPorts.has(input.id)
|
const isConditional = input instanceof Ast.Ast && (tree.conditionalPorts?.has(input.id) ?? false)
|
||||||
return !isConditional || keyboard.mod
|
return !isConditional || (keyboard?.mod ?? false)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -121,8 +117,8 @@ function updateRect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function relativePortSceneRect(): Rect | undefined {
|
function relativePortSceneRect(): Rect | undefined {
|
||||||
const domNode = rootNode.value
|
const domNode = portRoot.value
|
||||||
const rootDomNode = tree.nodeElement
|
const rootDomNode = tree.rootElement
|
||||||
if (domNode == null || rootDomNode == null) return
|
if (domNode == null || rootDomNode == null) return
|
||||||
if (!enabled.value) return
|
if (!enabled.value) return
|
||||||
const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect())
|
const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect())
|
||||||
@ -133,10 +129,7 @@ function relativePortSceneRect(): Rect | undefined {
|
|||||||
return rect.isFinite() ? rect : undefined
|
return rect.isFinite() ? rect : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(() => [portSize.value, portRoot.value, tree.rootElement, enabled.value], updateRect)
|
||||||
() => [nodeSize.value, rootNode.value, tree.nodeElement, tree.nodeSize, enabled.value],
|
|
||||||
updateRect,
|
|
||||||
)
|
|
||||||
onUpdated(() => nextTick(updateRect))
|
onUpdated(() => nextTick(updateRect))
|
||||||
onMounted(() => nextTick(updateRect))
|
onMounted(() => nextTick(updateRect))
|
||||||
useRaf(toRef(tree, 'hasActiveAnimations'), updateRect)
|
useRaf(toRef(tree, 'hasActiveAnimations'), updateRect)
|
||||||
@ -183,7 +176,7 @@ export const widgetDefinition = defineWidget(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="rootNode"
|
ref="portRoot"
|
||||||
class="WidgetPort"
|
class="WidgetPort"
|
||||||
:class="{
|
:class="{
|
||||||
enabled,
|
enabled,
|
||||||
@ -210,11 +203,12 @@ export const widgetDefinition = defineWidget(
|
|||||||
min-height: var(--node-port-height);
|
min-height: var(--node-port-height);
|
||||||
min-width: var(--node-port-height);
|
min-width: var(--node-port-height);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WidgetPort.connected {
|
.WidgetPort.connected {
|
||||||
background-color: var(--node-color-port);
|
background-color: var(--color-node-port);
|
||||||
color: white;
|
color: var(--color-node-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.GraphEditor.draggingEdge .WidgetPort {
|
.GraphEditor.draggingEdge .WidgetPort {
|
||||||
|
@ -53,7 +53,7 @@ const activity = shallowRef<VNode>()
|
|||||||
const MAX_DROPDOWN_OVERSIZE_PX = 390
|
const MAX_DROPDOWN_OVERSIZE_PX = 390
|
||||||
|
|
||||||
const floatReference = computed(
|
const floatReference = computed(
|
||||||
() => enclosingTopLevelArgument(widgetRoot.value, tree) ?? widgetRoot.value,
|
() => enclosingTopLevelArgument(widgetRoot.value, tree.rootElement) ?? widgetRoot.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
function dropdownStyles(dropdownElement: Ref<HTMLElement | undefined>, limitWidth: boolean) {
|
function dropdownStyles(dropdownElement: Ref<HTMLElement | undefined>, limitWidth: boolean) {
|
||||||
@ -85,7 +85,7 @@ function dropdownStyles(dropdownElement: Ref<HTMLElement | undefined>, limitWidt
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
// Try to keep the dropdown within node's bounds.
|
// Try to keep the dropdown within node's bounds.
|
||||||
shift(() => (tree.nodeElement ? { boundary: tree.nodeElement } : {})),
|
shift(() => (tree.rootElement ? { boundary: tree.rootElement } : {})),
|
||||||
shift(), // Always keep within screen bounds, overriding node bounds.
|
shift(), // Always keep within screen bounds, overriding node bounds.
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
@ -471,7 +471,7 @@ declare module '@/providers/widgetRegistry' {
|
|||||||
:class="{ hovered: isHovered }"
|
:class="{ hovered: isHovered }"
|
||||||
/>
|
/>
|
||||||
</ConditionalTeleport>
|
</ConditionalTeleport>
|
||||||
<Teleport v-if="tree.nodeElement" :to="tree.nodeElement">
|
<Teleport v-if="tree.rootElement" :to="tree.rootElement">
|
||||||
<div ref="dropdownElement" :style="floatingStyles" class="widgetOutOfLayout floatingElement">
|
<div ref="dropdownElement" :style="floatingStyles" class="widgetOutOfLayout floatingElement">
|
||||||
<SizeTransition height :duration="100">
|
<SizeTransition height :duration="100">
|
||||||
<DropdownWidget
|
<DropdownWidget
|
||||||
|
@ -10,7 +10,7 @@ import {
|
|||||||
import ResizeHandles from '@/components/ResizeHandles.vue'
|
import ResizeHandles from '@/components/ResizeHandles.vue'
|
||||||
import AgGridTableView from '@/components/shared/AgGridTableView.vue'
|
import AgGridTableView from '@/components/shared/AgGridTableView.vue'
|
||||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||||
import { useTooltipRegistry } from '@/providers/tooltipState'
|
import { useTooltipRegistry } from '@/providers/tooltipRegistry'
|
||||||
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||||
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
|
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SvgButton from '@/components/SvgButton.vue'
|
import SvgButton from '@/components/SvgButton.vue'
|
||||||
import type { TooltipRegistry } from '@/providers/tooltipState'
|
import { provideTooltipRegistry, type TooltipRegistry } from '@/providers/tooltipRegistry'
|
||||||
import { provideTooltipRegistry } from '@/providers/tooltipState'
|
|
||||||
import type { IHeaderParams } from 'ag-grid-community'
|
import type { IHeaderParams } from 'ag-grid-community'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
@ -22,17 +22,13 @@ export const widgetDefinition = defineWidget(
|
|||||||
/** If the element is the recursively-first-child of a top-level argument, return the top-level argument element. */
|
/** If the element is the recursively-first-child of a top-level argument, return the top-level argument element. */
|
||||||
export function enclosingTopLevelArgument(
|
export function enclosingTopLevelArgument(
|
||||||
element: HTMLElement | undefined,
|
element: HTMLElement | undefined,
|
||||||
tree: { nodeElement: HTMLElement | undefined },
|
rootElement: HTMLElement | undefined,
|
||||||
): HTMLElement | undefined {
|
): HTMLElement | undefined {
|
||||||
return (
|
return (
|
||||||
element?.dataset.topLevelArgument !== undefined ? element
|
element?.dataset.topLevelArgument !== undefined ? element
|
||||||
: (
|
: !element || element === rootElement || element.parentElement?.firstElementChild !== element ?
|
||||||
!element ||
|
|
||||||
element === tree.nodeElement ||
|
|
||||||
element.parentElement?.firstElementChild !== element
|
|
||||||
) ?
|
|
||||||
undefined
|
undefined
|
||||||
: enclosingTopLevelArgument(element.parentElement, tree)
|
: enclosingTopLevelArgument(element.parentElement, rootElement)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -19,16 +19,13 @@ export type TransformUrlResult = Result<{
|
|||||||
}>
|
}>
|
||||||
export type UrlTransformer = (url: string) => Promise<TransformUrlResult>
|
export type UrlTransformer = (url: string) => Promise<TransformUrlResult>
|
||||||
|
|
||||||
export {
|
export const [provideDocumentationImageUrlTransformer, injectDocumentationImageUrlTransformer] =
|
||||||
injectFn as injectDocumentationImageUrlTransformer,
|
createContextStore(
|
||||||
provideFn as provideDocumentationImageUrlTransformer,
|
'Documentation image URL transformer',
|
||||||
}
|
(transformUrl: ToValue<UrlTransformer | undefined>) => ({
|
||||||
const { provideFn, injectFn } = createContextStore(
|
transformUrl: (url: string) => toValue(transformUrl)?.(url),
|
||||||
'Documentation image URL transformer',
|
}),
|
||||||
(transformUrl: ToValue<UrlTransformer | undefined>) => ({
|
)
|
||||||
transformUrl: (url: string) => toValue(transformUrl)?.(url),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
type ResourceId = string
|
type ResourceId = string
|
||||||
type Url = string
|
type Url = string
|
||||||
|
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) {
|
if (props.leftGap && e.parentElement) {
|
||||||
const parentStyle = getComputedStyle(e.parentElement)
|
const parentStyle = getComputedStyle(e.parentElement)
|
||||||
const negativeGap = `calc(${parentStyle.gap} * -1)`
|
const negativeGap = `calc(${parentStyle.gap || '0'} * -1)`
|
||||||
start.marginLeft = lastSnapshot?.marginLeft || negativeGap
|
start.marginLeft = lastSnapshot?.marginLeft || negativeGap
|
||||||
end.marginLeft = isEnter ? current.marginLeft : negativeGap
|
end.marginLeft = isEnter ? current.marginLeft : negativeGap
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="StandaloneButton">
|
<div class="StandaloneButton">
|
||||||
<SvgButton v-bind="props" :name="icon" />
|
<SvgButton v-bind="{ ...$attrs, ...props }" :name="icon" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type TooltipRegistry } from '@/providers/tooltipState'
|
import { type TooltipRegistry } from '@/providers/tooltipRegistry'
|
||||||
import { debouncedGetter } from '@/util/reactivity'
|
import { debouncedGetter } from '@/util/reactivity'
|
||||||
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
|
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTooltipRegistry } from '@/providers/tooltipState'
|
import { useTooltipRegistry } from '@/providers/tooltipRegistry'
|
||||||
import { usePropagateScopesToAllRoots } from '@/util/patching'
|
import { usePropagateScopesToAllRoots } from '@/util/patching'
|
||||||
import { toRef, useSlots } from 'vue'
|
import { toRef, useSlots } from 'vue'
|
||||||
|
|
||||||
|
@ -5,6 +5,8 @@ import { useGraphEditorLayers } from '@/providers/graphEditorLayers'
|
|||||||
import { Rect } from '@/util/data/rect'
|
import { Rect } from '@/util/data/rect'
|
||||||
import { computed, ref, toRef, watch } from 'vue'
|
import { computed, ref, toRef, watch } from 'vue'
|
||||||
|
|
||||||
|
export type SavedSize = Keyframe
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
fullscreen: boolean
|
fullscreen: boolean
|
||||||
}>()
|
}>()
|
||||||
@ -91,10 +93,6 @@ watch(
|
|||||||
const active = computed(() => props.fullscreen || animating.value)
|
const active = computed(() => props.fullscreen || animating.value)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
export type SavedSize = Keyframe
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- The outer `div` is to avoid having a dynamic root. A component whose root may change cannot be passed to a `slot`,
|
<!-- The outer `div` is to avoid having a dynamic root. A component whose root may change cannot be passed to a `slot`,
|
||||||
or used with `unrefElement`. -->
|
or used with `unrefElement`. -->
|
||||||
<template>
|
<template>
|
||||||
|
@ -138,7 +138,7 @@ const displayedChildren = computed(() => {
|
|||||||
|
|
||||||
const rootNode = ref<HTMLElement>()
|
const rootNode = ref<HTMLElement>()
|
||||||
|
|
||||||
const cssPropsToCopy = ['--color-node-primary', '--node-color-port', '--node-border-radius']
|
const cssPropsToCopy = ['--color-node-primary', '--color-node-port', '--node-border-radius']
|
||||||
|
|
||||||
function onDragStart(event: DragEvent, index: number) {
|
function onDragStart(event: DragEvent, index: number) {
|
||||||
if (!event.dataTransfer) return
|
if (!event.dataTransfer) return
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import { useAbortScope } from '@/util/net'
|
import { useAbortScope } from '@/util/net'
|
||||||
import { debouncedWatch, useLocalStorage } from '@vueuse/core'
|
import { debouncedWatch, useLocalStorage } from '@vueuse/core'
|
||||||
import { encoding } from 'lib0'
|
import { encoding } from 'lib0'
|
||||||
|
import { Encoder } from 'lib0/encoding.js'
|
||||||
import { computed, getCurrentInstance, ref, watch, withCtx } from 'vue'
|
import { computed, getCurrentInstance, ref, watch, withCtx } from 'vue'
|
||||||
import { xxHash128 } from 'ydoc-shared/ast/ffi'
|
import { xxHash128 } from 'ydoc-shared/ast/ffi'
|
||||||
import { AbortScope } from 'ydoc-shared/util/net'
|
import { AbortScope } from 'ydoc-shared/util/net'
|
||||||
|
|
||||||
|
export type EncodeFn = (enc: encoding.Encoder) => void
|
||||||
|
|
||||||
export interface SyncLocalStorageOptions<StoredState> {
|
export interface SyncLocalStorageOptions<StoredState> {
|
||||||
/** The main localStorage key under which a map of saved states will be stored. */
|
/** The main localStorage key under which a map of saved states will be stored. */
|
||||||
storageKey: string
|
storageKey: string
|
||||||
@ -15,7 +18,7 @@ export interface SyncLocalStorageOptions<StoredState> {
|
|||||||
* that is encoded in this function dictates the effective identity of stored state. Whenever the
|
* that is encoded in this function dictates the effective identity of stored state. Whenever the
|
||||||
* encoded key changes, the current state is saved and state stored under new key is restored.
|
* encoded key changes, the current state is saved and state stored under new key is restored.
|
||||||
*/
|
*/
|
||||||
mapKeyEncoder: (enc: encoding.Encoder) => void
|
mapKeyEncoder: EncodeFn
|
||||||
/**
|
/**
|
||||||
* **Reactive** current state serializer. Captures the environment data that will be stored in
|
* **Reactive** current state serializer. Captures the environment data that will be stored in
|
||||||
* localStorage. Returned object must be JSON-encodable. State will not be captured while async
|
* localStorage. Returned object must be JSON-encodable. State will not be captured while async
|
||||||
@ -42,7 +45,9 @@ export interface SyncLocalStorageOptions<StoredState> {
|
|||||||
export function useSyncLocalStorage<StoredState extends object>(
|
export function useSyncLocalStorage<StoredState extends object>(
|
||||||
options: SyncLocalStorageOptions<StoredState>,
|
options: SyncLocalStorageOptions<StoredState>,
|
||||||
) {
|
) {
|
||||||
const graphViewportStorageKey = computed(() => xxHash128(encoding.encode(options.mapKeyEncoder)))
|
const encodeKey = (encoder: EncodeFn) => xxHash128(encoding.encode(encoder))
|
||||||
|
|
||||||
|
const graphViewportStorageKey = computed(() => encodeKey(options.mapKeyEncoder))
|
||||||
|
|
||||||
// Ensure that restoreState function is run within component's context, allowing for temporary
|
// Ensure that restoreState function is run within component's context, allowing for temporary
|
||||||
// watchers to be created for async/await purposes.
|
// watchers to be created for async/await purposes.
|
||||||
@ -129,4 +134,19 @@ export function useSyncLocalStorage<StoredState extends object>(
|
|||||||
if (restoreIdInProgress.value === thisRestoreId) restoreIdInProgress.value = undefined
|
if (restoreIdInProgress.value === thisRestoreId) restoreIdInProgress.value = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Move saved state from under one key to another. Does nothing if `oldKey` does not have any
|
||||||
|
* associated saved state.
|
||||||
|
*/
|
||||||
|
moveToNewKey(oldKeyEncoder: EncodeFn, newKeyEncoder: EncodeFn) {
|
||||||
|
const oldKey = encodeKey(oldKeyEncoder)
|
||||||
|
const stateBlob = storageMap.value.get(oldKey)
|
||||||
|
if (stateBlob != null) {
|
||||||
|
const newKey = encodeKey(newKeyEncoder)
|
||||||
|
storageMap.value.set(newKey, stateBlob)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,8 +2,8 @@ import { createContextStore } from '@/providers'
|
|||||||
import type { Opt } from '@/util/data/opt'
|
import type { Opt } from '@/util/data/opt'
|
||||||
import { reactive, watch, type WatchSource } from 'vue'
|
import { reactive, watch, type WatchSource } from 'vue'
|
||||||
|
|
||||||
export { provideFn as provideAppClassSet }
|
export { provideAppClassSet }
|
||||||
const { provideFn, injectFn: injectAppClassSet } = createContextStore('App Class Set', () => {
|
const [provideAppClassSet, injectAppClassSet] = createContextStore('App Class Set', () => {
|
||||||
return reactive(new Map<string, number>())
|
return reactive(new Map<string, number>())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -3,8 +3,7 @@ import type { ToValue } from '@/util/reactivity'
|
|||||||
import type Backend from 'enso-common/src/services/Backend'
|
import type Backend from 'enso-common/src/services/Backend'
|
||||||
import { proxyRefs, toRef } from 'vue'
|
import { proxyRefs, toRef } from 'vue'
|
||||||
|
|
||||||
export { injectFn as injectBackend, provideFn as provideBackend }
|
export const [provideBackend, injectBackend] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore(
|
|
||||||
'backend',
|
'backend',
|
||||||
({ project, remote }: { project: ToValue<Backend | null>; remote: ToValue<Backend | null> }) =>
|
({ project, remote }: { project: ToValue<Backend | null>; remote: ToValue<Backend | null> }) =>
|
||||||
proxyRefs({
|
proxyRefs({
|
||||||
|
@ -116,5 +116,7 @@ function useComponentButtons(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { injectFn as injectComponentButtons, provideFn as provideComponentButtons }
|
export const [provideComponentButtons, injectComponentButtons] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore('Component buttons', useComponentButtons)
|
'Component buttons',
|
||||||
|
useComponentButtons,
|
||||||
|
)
|
||||||
|
@ -1,22 +1,22 @@
|
|||||||
import { computed, type Ref } from 'vue'
|
import { computed, type Ref } from 'vue'
|
||||||
import { createContextStore } from '.'
|
import { createContextStore } from '.'
|
||||||
|
|
||||||
export type EventLogger = ReturnType<typeof injectFn>
|
export type EventLogger = ReturnType<typeof injectEventLogger>
|
||||||
export { injectFn as injectEventLogger, provideFn as provideEventLogger }
|
export const [provideEventLogger, injectEventLogger] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore('event logger', eventLogger)
|
'event logger',
|
||||||
|
(logEvent: Ref<LogEvent>, projectId: Ref<string>) => {
|
||||||
|
const logProjectId = computed(() => {
|
||||||
|
const id = projectId.value
|
||||||
|
if (!id) return undefined
|
||||||
|
const prefix = 'project-'
|
||||||
|
const projectUuid = id.startsWith(prefix) ? id.substring(prefix.length) : id
|
||||||
|
return `${prefix}${projectUuid.replace(/-/g, '')}`
|
||||||
|
})
|
||||||
|
|
||||||
function eventLogger(logEvent: Ref<LogEvent>, projectId: Ref<string>) {
|
return {
|
||||||
const logProjectId = computed(() => {
|
async send(message: string) {
|
||||||
const id = projectId.value
|
logEvent.value(message, logProjectId.value)
|
||||||
if (!id) return undefined
|
},
|
||||||
const prefix = 'project-'
|
}
|
||||||
const projectUuid = id.startsWith(prefix) ? id.substring(prefix.length) : id
|
},
|
||||||
return `${prefix}${projectUuid.replace(/-/g, '')}`
|
)
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
async send(message: string) {
|
|
||||||
logEvent.value(message, logProjectId.value)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -10,5 +10,7 @@ interface FunctionInfo {
|
|||||||
outputType: string | undefined
|
outputType: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export { injectFn as injectFunctionInfo, provideFn as provideFunctionInfo }
|
export const [provideFunctionInfo, injectFunctionInfo] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore('Function info', identity<FunctionInfo>)
|
'Function info',
|
||||||
|
identity<FunctionInfo>,
|
||||||
|
)
|
||||||
|
@ -8,8 +8,7 @@ export interface GraphEditorLayers {
|
|||||||
floating: Readonly<Ref<HTMLElement | undefined>>
|
floating: Readonly<Ref<HTMLElement | undefined>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export { provideFn as provideGraphEditorLayers, injectFn as useGraphEditorLayers }
|
export const [provideGraphEditorLayers, useGraphEditorLayers] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore(
|
|
||||||
'Graph editor layers',
|
'Graph editor layers',
|
||||||
identity<GraphEditorLayers>,
|
identity<GraphEditorLayers>,
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { useNavigator } from '@/composables/navigator'
|
import { useNavigator } from '@/composables/navigator'
|
||||||
import { createContextStore } from '@/providers'
|
import { createContextStore } from '@/providers'
|
||||||
|
|
||||||
export type GraphNavigator = ReturnType<typeof injectFn>
|
export type GraphNavigator = ReturnType<typeof injectGraphNavigator>
|
||||||
export { injectFn as injectGraphNavigator, provideFn as provideGraphNavigator }
|
export const [provideGraphNavigator, injectGraphNavigator] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore('graph navigator', useNavigator)
|
'graph navigator',
|
||||||
|
useNavigator,
|
||||||
|
)
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { useNodeColors } from '@/composables/nodeColors'
|
import { useNodeColors } from '@/composables/nodeColors'
|
||||||
import { createContextStore } from '@/providers'
|
import { createContextStore } from '@/providers'
|
||||||
|
|
||||||
export { injectFn as injectNodeColors, provideFn as provideNodeColors }
|
export const [provideNodeColors, injectNodeColors] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore('node colors', useNodeColors)
|
'node colors',
|
||||||
|
useNodeColors,
|
||||||
|
)
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { useNodeCreation } from '@/composables/nodeCreation'
|
import { useNodeCreation } from '@/composables/nodeCreation'
|
||||||
import { createContextStore } from '@/providers'
|
import { createContextStore } from '@/providers'
|
||||||
|
|
||||||
export { injectFn as injectNodeCreation, provideFn as provideNodeCreation }
|
export const [provideNodeCreation, injectNodeCreation] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore('node creation', useNodeCreation)
|
'node creation',
|
||||||
|
useNodeCreation,
|
||||||
|
)
|
||||||
|
@ -8,8 +8,7 @@ import type { ExternalId } from 'ydoc-shared/yjsModel'
|
|||||||
|
|
||||||
const SELECTION_BRUSH_MARGIN_PX = 6
|
const SELECTION_BRUSH_MARGIN_PX = 6
|
||||||
|
|
||||||
export { injectFn as injectGraphSelection, provideFn as provideGraphSelection }
|
export const [provideGraphSelection, injectGraphSelection] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore(
|
|
||||||
'graph selection',
|
'graph selection',
|
||||||
(
|
(
|
||||||
navigator: NavigatorComposable,
|
navigator: NavigatorComposable,
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { useStackNavigator } from '@/composables/stackNavigator'
|
import { useStackNavigator } from '@/composables/stackNavigator'
|
||||||
import { createContextStore } from '@/providers'
|
import { createContextStore } from '@/providers'
|
||||||
|
|
||||||
export { injectFn as injectStackNavigator, provideFn as provideStackNavigator }
|
export const [provideStackNavigator, injectStackNavigator] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore('graph stack navigator', useStackNavigator)
|
'graph stack navigator',
|
||||||
|
useStackNavigator,
|
||||||
|
)
|
||||||
|
@ -5,8 +5,7 @@ import { type Ref } from 'vue'
|
|||||||
|
|
||||||
export type GuiConfig = ApplicationConfigValue
|
export type GuiConfig = ApplicationConfigValue
|
||||||
|
|
||||||
export { injectFn as injectGuiConfig, provideFn as provideGuiConfig }
|
export const [provideGuiConfig, injectGuiConfig] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore(
|
|
||||||
'GUI config',
|
'GUI config',
|
||||||
identity<Ref<ApplicationConfigValue>>,
|
identity<Ref<ApplicationConfigValue>>,
|
||||||
)
|
)
|
||||||
|
@ -13,8 +13,7 @@ const MISSING = Symbol('MISSING')
|
|||||||
* When creating a store, you usually want to reexport the `provideFn` and `injectFn` as renamed
|
* When creating a store, you usually want to reexport the `provideFn` and `injectFn` as renamed
|
||||||
* functions to make it easier to use the store in components without any name collisions.
|
* functions to make it easier to use the store in components without any name collisions.
|
||||||
* ```ts
|
* ```ts
|
||||||
* export { injectFn as injectSpecificThing, provideFn as provideSpecificThing }
|
* export const [provideThing, useThing] = createContextStore('specific thing', thatThingFactory)
|
||||||
* const { provideFn, injectFn } = createContextStore('specific thing', thatThingFactory)
|
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* Under the hood, this uses Vue's [Context API], therefore it can only be used within a component's
|
* Under the hood, this uses Vue's [Context API], therefore it can only be used within a component's
|
||||||
@ -93,5 +92,5 @@ export function createContextStore<F extends (...args: any[]) => any>(name: stri
|
|||||||
return injected
|
return injected
|
||||||
}
|
}
|
||||||
|
|
||||||
return { provideFn, injectFn } as const
|
return [provideFn, injectFn] as const
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { createContextStore } from '@/providers'
|
import { createContextStore } from '@/providers'
|
||||||
import { shallowRef, watch, type WatchSource } from 'vue'
|
import { shallowRef, watch, type WatchSource } from 'vue'
|
||||||
|
|
||||||
export { injectFn as injectInteractionHandler, provideFn as provideInteractionHandler }
|
export const [provideInteractionHandler, injectInteractionHandler] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore(
|
|
||||||
'Interaction handler',
|
'Interaction handler',
|
||||||
() => new InteractionHandler(),
|
() => new InteractionHandler(),
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useKeyboard } from '@/composables/keyboard'
|
import { useKeyboard } from '@/composables/keyboard'
|
||||||
import { createContextStore } from '@/providers'
|
import { createContextStore } from '@/providers'
|
||||||
|
|
||||||
export { injectFn as injectKeyboard, provideFn as provideKeyboard }
|
export const [provideKeyboard, injectKeyboard] = createContextStore('Keyboard watcher', () =>
|
||||||
|
useKeyboard(),
|
||||||
const { provideFn, injectFn } = createContextStore('Keyboard watcher', () => useKeyboard())
|
)
|
||||||
|
@ -14,5 +14,4 @@ interface PortInfo {
|
|||||||
connected: boolean
|
connected: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export { injectFn as injectPortInfo, provideFn as providePortInfo }
|
export const [providePortInfo, injectPortInfo] = createContextStore('Port info', identity<PortInfo>)
|
||||||
const { provideFn, injectFn } = createContextStore('Port info', identity<PortInfo>)
|
|
||||||
|
@ -23,8 +23,7 @@ interface SelectionArrowInfo {
|
|||||||
suppressArrow: boolean
|
suppressArrow: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export { injectFn as injectSelectionArrow, provideFn as provideSelectionArrow }
|
export const [provideSelectionArrow, injectSelectionArrow] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore(
|
|
||||||
'Selection arrow info',
|
'Selection arrow info',
|
||||||
identity<SelectionArrowInfo>,
|
identity<SelectionArrowInfo>,
|
||||||
)
|
)
|
||||||
|
@ -71,7 +71,7 @@ function useSelectionButtons(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { injectFn as injectSelectionButtons, provideFn as provideSelectionButtons }
|
export { injectFn as injectSelectionButtons, provideFn as provideSelectionButtons }
|
||||||
const { provideFn, injectFn } = createContextStore('Selection buttons', useSelectionButtons)
|
const [provideFn, injectFn] = createContextStore('Selection buttons', useSelectionButtons)
|
||||||
|
|
||||||
export type ComponentAndSelectionButtons = DisjointKeysUnion<ComponentButtons, SelectionButtons>
|
export type ComponentAndSelectionButtons = DisjointKeysUnion<ComponentButtons, SelectionButtons>
|
||||||
|
|
||||||
|
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 { identity } from '@vueuse/core'
|
||||||
import { type Ref } from 'vue'
|
import { type Ref } from 'vue'
|
||||||
|
|
||||||
export { injectFn as injectVisibility, provideFn as provideVisibility }
|
export const [provideVisibility, injectVisibility] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore('Visibility', identity<Ref<boolean>>)
|
'Visibility',
|
||||||
|
identity<Ref<boolean>>,
|
||||||
|
)
|
||||||
|
@ -27,8 +27,8 @@ export interface VisualizationConfig {
|
|||||||
setToolbarOverlay: (enableOverlay: boolean) => void
|
setToolbarOverlay: (enableOverlay: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export { provideFn as provideVisualizationConfig }
|
export { provideVisualizationConfig }
|
||||||
const { provideFn, injectFn } = createContextStore(
|
const [provideVisualizationConfig, injectVisualizationConfig] = createContextStore(
|
||||||
'Visualization config',
|
'Visualization config',
|
||||||
reactive<VisualizationConfig>,
|
reactive<VisualizationConfig>,
|
||||||
)
|
)
|
||||||
@ -38,5 +38,5 @@ const { provideFn, injectFn } = createContextStore(
|
|||||||
|
|
||||||
/** TODO: Add docs */
|
/** TODO: Add docs */
|
||||||
export function useVisualizationConfig() {
|
export function useVisualizationConfig() {
|
||||||
return injectFn()
|
return injectVisualizationConfig()
|
||||||
}
|
}
|
||||||
|
@ -11,10 +11,8 @@ import type { WidgetEditHandlerParent } from './widgetRegistry/editHandler'
|
|||||||
export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
|
export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
|
||||||
|
|
||||||
export namespace WidgetInput {
|
export namespace WidgetInput {
|
||||||
/** Returns widget-input data for the given AST expression or token. */
|
/** Returns widget-input data for the given AST tree or token. */
|
||||||
export function FromAst<A extends Ast.Expression | Ast.Token>(
|
export function FromAst<A extends Ast.Ast | Ast.Token>(ast: A): WidgetInput & { value: A } {
|
||||||
ast: A,
|
|
||||||
): WidgetInput & { value: A } {
|
|
||||||
return {
|
return {
|
||||||
portId: ast.id,
|
portId: ast.id,
|
||||||
value: ast,
|
value: ast,
|
||||||
@ -117,10 +115,10 @@ export interface WidgetInput {
|
|||||||
*/
|
*/
|
||||||
portId: PortId
|
portId: PortId
|
||||||
/**
|
/**
|
||||||
* An expected widget value. If Ast.Expression or Ast.Token, the widget represents an existing part of
|
* An expected widget value. If Ast.Ast or Ast.Token, the widget represents an existing part of
|
||||||
* code. If string, it may be e.g. a default value of an argument.
|
* code. If string, it may be e.g. a default value of an argument.
|
||||||
*/
|
*/
|
||||||
value: Ast.Expression | Ast.Token | string | undefined
|
value: Ast.Ast | Ast.Token | string | undefined
|
||||||
/** An expected type which widget should set. */
|
/** An expected type which widget should set. */
|
||||||
expectedType?: Typename | undefined
|
expectedType?: Typename | undefined
|
||||||
/** Configuration provided by engine. */
|
/** Configuration provided by engine. */
|
||||||
@ -165,7 +163,7 @@ export interface WidgetProps<T> {
|
|||||||
* Every widget type should set it's name as `metadataKey`.
|
* Every widget type should set it's name as `metadataKey`.
|
||||||
*
|
*
|
||||||
* The handlers interested in a specific port update should apply it using received edit. The edit
|
* The handlers interested in a specific port update should apply it using received edit. The edit
|
||||||
* is committed in {@link NodeWidgetTree}.
|
* is committed in {@link ComponentWidgetTree}.
|
||||||
*/
|
*/
|
||||||
export interface WidgetUpdate {
|
export interface WidgetUpdate {
|
||||||
edit?: Ast.MutableModule | undefined
|
edit?: Ast.MutableModule | undefined
|
||||||
@ -334,8 +332,7 @@ function makeInputMatcher<T extends WidgetInput>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { injectFn as injectWidgetRegistry, provideFn as provideWidgetRegistry }
|
export const [provideWidgetRegistry, injectWidgetRegistry] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore(
|
|
||||||
'Widget registry',
|
'Widget registry',
|
||||||
(db: GraphDb) => new WidgetRegistry(db),
|
(db: GraphDb) => new WidgetRegistry(db),
|
||||||
)
|
)
|
||||||
|
@ -1,37 +1,28 @@
|
|||||||
import { createContextStore } from '@/providers'
|
import { createContextStore } from '@/providers'
|
||||||
import { type WidgetEditHandlerRoot } from '@/providers/widgetRegistry/editHandler'
|
import { type WidgetEditHandlerRoot } from '@/providers/widgetRegistry/editHandler'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
|
||||||
import { type NodeId } from '@/stores/graph/graphDatabase'
|
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import type { Vec2 } from '@/util/data/vec2'
|
|
||||||
import { computed, proxyRefs, shallowRef, type Ref, type ShallowUnwrapRef } from 'vue'
|
import { computed, proxyRefs, shallowRef, type Ref, type ShallowUnwrapRef } from 'vue'
|
||||||
|
import { AstId } from 'ydoc-shared/ast'
|
||||||
|
import { ExternalId } from 'ydoc-shared/yjsModel'
|
||||||
|
|
||||||
export { injectFn as injectWidgetTree, provideFn as provideWidgetTree }
|
export const [provideWidgetTree, injectWidgetTree] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore(
|
|
||||||
'Widget tree',
|
'Widget tree',
|
||||||
(
|
(
|
||||||
astRoot: Ref<Ast.Expression>,
|
externalId: Ref<ExternalId>,
|
||||||
nodeId: Ref<NodeId>,
|
rootElement: Ref<HTMLElement | undefined>,
|
||||||
nodeElement: Ref<HTMLElement | undefined>,
|
conditionalPorts: Ref<Set<Ast.AstId> | undefined>,
|
||||||
nodeSize: Ref<Vec2>,
|
|
||||||
potentialSelfArgumentId: Ref<Ast.AstId | undefined>,
|
|
||||||
conditionalPorts: Ref<Set<Ast.AstId>>,
|
|
||||||
extended: Ref<boolean>,
|
extended: Ref<boolean>,
|
||||||
hasActiveAnimations: Ref<boolean>,
|
hasActiveAnimations: Ref<boolean>,
|
||||||
|
potentialSelfArgumentId: Ref<AstId | undefined>,
|
||||||
) => {
|
) => {
|
||||||
const graph = useGraphStore()
|
|
||||||
const nodeSpanStart = computed(() => graph.moduleSource.getSpan(astRoot.value.id)![0])
|
|
||||||
const { setCurrentEditRoot, currentEdit } = useCurrentEdit()
|
const { setCurrentEditRoot, currentEdit } = useCurrentEdit()
|
||||||
return proxyRefs({
|
return proxyRefs({
|
||||||
astRoot,
|
externalId,
|
||||||
nodeId,
|
rootElement,
|
||||||
nodeElement,
|
|
||||||
nodeSize,
|
|
||||||
potentialSelfArgumentId,
|
|
||||||
conditionalPorts,
|
conditionalPorts,
|
||||||
extended,
|
extended,
|
||||||
nodeSpanStart,
|
|
||||||
hasActiveAnimations,
|
hasActiveAnimations,
|
||||||
|
potentialSelfArgumentId,
|
||||||
setCurrentEditRoot,
|
setCurrentEditRoot,
|
||||||
currentEdit,
|
currentEdit,
|
||||||
})
|
})
|
||||||
|
@ -2,8 +2,10 @@ import { createContextStore } from '@/providers'
|
|||||||
import type { WidgetComponent, WidgetInput, WidgetUpdate } from '@/providers/widgetRegistry'
|
import type { WidgetComponent, WidgetInput, WidgetUpdate } from '@/providers/widgetRegistry'
|
||||||
import { identity } from '@vueuse/core'
|
import { identity } from '@vueuse/core'
|
||||||
|
|
||||||
export { injectFn as injectWidgetUsageInfo, provideFn as provideWidgetUsageInfo }
|
export const [provideWidgetUsageInfo, injectWidgetUsageInfo] = createContextStore(
|
||||||
const { provideFn, injectFn } = createContextStore('Widget usage info', identity<WidgetUsageInfo>)
|
'Widget usage info',
|
||||||
|
identity<WidgetUsageInfo>,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Information about a widget that can be accessed in its child views. Currently this is used during
|
* Information about a widget that can be accessed in its child views. Currently this is used during
|
||||||
|
@ -2,6 +2,7 @@ import { computeNodeColor } from '@/composables/nodeColors'
|
|||||||
import { ComputedValueRegistry, type ExpressionInfo } from '@/stores/project/computedValueRegistry'
|
import { ComputedValueRegistry, type ExpressionInfo } from '@/stores/project/computedValueRegistry'
|
||||||
import { SuggestionDb, type Group } from '@/stores/suggestionDatabase'
|
import { SuggestionDb, type Group } from '@/stores/suggestionDatabase'
|
||||||
import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry'
|
import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry'
|
||||||
|
import { assert } from '@/util/assert'
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import type { AstId, NodeMetadata } from '@/util/ast/abstract'
|
import type { AstId, NodeMetadata } from '@/util/ast/abstract'
|
||||||
import { MutableModule } from '@/util/ast/abstract'
|
import { MutableModule } from '@/util/ast/abstract'
|
||||||
@ -12,7 +13,12 @@ import { recordEqual } from '@/util/data/object'
|
|||||||
import { unwrap } from '@/util/data/result'
|
import { unwrap } from '@/util/data/result'
|
||||||
import { Vec2 } from '@/util/data/vec2'
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
|
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
|
||||||
import { tryIdentifier } from '@/util/qualifiedName'
|
import {
|
||||||
|
isIdentifierOrOperatorIdentifier,
|
||||||
|
isQualifiedName,
|
||||||
|
normalizeQualifiedName,
|
||||||
|
tryIdentifier,
|
||||||
|
} from '@/util/qualifiedName'
|
||||||
import {
|
import {
|
||||||
nonReactiveView,
|
nonReactiveView,
|
||||||
resumeReactivity,
|
resumeReactivity,
|
||||||
@ -23,7 +29,12 @@ import * as objects from 'enso-common/src/utilities/data/object'
|
|||||||
import * as set from 'lib0/set'
|
import * as set from 'lib0/set'
|
||||||
import { reactive, ref, shallowReactive, type Ref, type WatchStopHandle } from 'vue'
|
import { reactive, ref, shallowReactive, type Ref, type WatchStopHandle } from 'vue'
|
||||||
import { type SourceDocument } from 'ydoc-shared/ast/sourceDocument'
|
import { type SourceDocument } from 'ydoc-shared/ast/sourceDocument'
|
||||||
import type { MethodCall, StackItem } from 'ydoc-shared/languageServerTypes'
|
import {
|
||||||
|
methodPointerEquals,
|
||||||
|
type MethodCall,
|
||||||
|
type MethodPointer,
|
||||||
|
type StackItem,
|
||||||
|
} from 'ydoc-shared/languageServerTypes'
|
||||||
import type { Opt } from 'ydoc-shared/util/data/opt'
|
import type { Opt } from 'ydoc-shared/util/data/opt'
|
||||||
import type { ExternalId, VisualizationMetadata } from 'ydoc-shared/yjsModel'
|
import type { ExternalId, VisualizationMetadata } from 'ydoc-shared/yjsModel'
|
||||||
import { isUuid, visMetadataEquals } from 'ydoc-shared/yjsModel'
|
import { isUuid, visMetadataEquals } from 'ydoc-shared/yjsModel'
|
||||||
@ -181,7 +192,7 @@ export class GraphDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** TODO: Add docs */
|
/** TODO: Add docs */
|
||||||
isNodeId(externalId: ExternalId): boolean {
|
isNodeId(externalId: ExternalId): externalId is NodeId {
|
||||||
return this.nodeIdToNode.has(asNodeId(externalId))
|
return this.nodeIdToNode.has(asNodeId(externalId))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -443,6 +454,42 @@ export class GraphDb {
|
|||||||
return id ? this.idToExternalMap.get(id) : undefined
|
return id ? this.idToExternalMap.get(id) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously replace all instances of specific method pointer usage within the value registry and
|
||||||
|
* suggestion database.
|
||||||
|
*
|
||||||
|
* FIXME: This is a hack in order to make function renaming from within that function work correctly.
|
||||||
|
* Execution contexts don't send expression updates about their parent frames, so we end up with an
|
||||||
|
* outdated methodPointer on the parent frame's expression. We have to update the valueRegistry and
|
||||||
|
* suggestionDb entries to keep it working correctly. Both need to be updated synchronously to avoid
|
||||||
|
* flashing.
|
||||||
|
*/
|
||||||
|
insertSyntheticMethodPointerUpdate(
|
||||||
|
oldMethodPointer: MethodPointer,
|
||||||
|
newMethodPointer: MethodPointer,
|
||||||
|
) {
|
||||||
|
for (const value of this.valuesRegistry.db.values()) {
|
||||||
|
if (
|
||||||
|
value.methodCall != null &&
|
||||||
|
methodPointerEquals(value.methodCall.methodPointer, oldMethodPointer)
|
||||||
|
) {
|
||||||
|
value.methodCall.methodPointer = newMethodPointer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestion = this.suggestionDb.findByMethodPointer(oldMethodPointer)
|
||||||
|
const suggestionEntry = suggestion != null ? this.suggestionDb.get(suggestion) : null
|
||||||
|
if (suggestionEntry != null) {
|
||||||
|
DEV: assert(isQualifiedName(newMethodPointer.module))
|
||||||
|
DEV: assert(isQualifiedName(newMethodPointer.definedOnType))
|
||||||
|
DEV: assert(isIdentifierOrOperatorIdentifier(newMethodPointer.name))
|
||||||
|
Object.assign(suggestionEntry, {
|
||||||
|
definedIn: normalizeQualifiedName(newMethodPointer.module),
|
||||||
|
memberOf: normalizeQualifiedName(newMethodPointer.definedOnType),
|
||||||
|
name: newMethodPointer.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
/** TODO: Add docs */
|
/** TODO: Add docs */
|
||||||
static Mock(registry = ComputedValueRegistry.Mock(), db = new SuggestionDb()): GraphDb {
|
static Mock(registry = ComputedValueRegistry.Mock(), db = new SuggestionDb()): GraphDb {
|
||||||
return new GraphDb(db, ref([]), registry)
|
return new GraphDb(db, ref([]), registry)
|
||||||
|
@ -15,7 +15,7 @@ import {
|
|||||||
import { useUnconnectedEdges, type UnconnectedEdge } from '@/stores/graph/unconnectedEdges'
|
import { useUnconnectedEdges, type UnconnectedEdge } from '@/stores/graph/unconnectedEdges'
|
||||||
import { type ProjectStore } from '@/stores/project'
|
import { type ProjectStore } from '@/stores/project'
|
||||||
import { type SuggestionDbStore } from '@/stores/suggestionDatabase'
|
import { type SuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||||
import { assert, bail } from '@/util/assert'
|
import { assert, assertNever, bail } from '@/util/assert'
|
||||||
import { Ast } from '@/util/ast'
|
import { Ast } from '@/util/ast'
|
||||||
import type { AstId, Identifier, MutableModule } from '@/util/ast/abstract'
|
import type { AstId, Identifier, MutableModule } from '@/util/ast/abstract'
|
||||||
import { isAstId, isIdentifier } from '@/util/ast/abstract'
|
import { isAstId, isIdentifier } from '@/util/ast/abstract'
|
||||||
@ -23,9 +23,9 @@ import { reactiveModule } from '@/util/ast/reactive'
|
|||||||
import { partition } from '@/util/data/array'
|
import { partition } from '@/util/data/array'
|
||||||
import { stringUnionToArray, type Events } from '@/util/data/observable'
|
import { stringUnionToArray, type Events } from '@/util/data/observable'
|
||||||
import { Rect } from '@/util/data/rect'
|
import { Rect } from '@/util/data/rect'
|
||||||
import { Err, mapOk, Ok, unwrap, type Result } from '@/util/data/result'
|
import { andThen, Err, mapOk, Ok, unwrap, type Result } from '@/util/data/result'
|
||||||
import { Vec2 } from '@/util/data/vec2'
|
import { Vec2 } from '@/util/data/vec2'
|
||||||
import { normalizeQualifiedName, qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
|
import { normalizeQualifiedName, tryQualifiedName } from '@/util/qualifiedName'
|
||||||
import { useWatchContext } from '@/util/reactivity'
|
import { useWatchContext } from '@/util/reactivity'
|
||||||
import { computedAsync } from '@vueuse/core'
|
import { computedAsync } from '@vueuse/core'
|
||||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||||
@ -82,7 +82,7 @@ export class PortViewInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type GraphStore = ReturnType<typeof useGraphStore>
|
export type GraphStore = ReturnType<typeof useGraphStore>
|
||||||
export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createContextStore(
|
export const [provideGraphStore, useGraphStore] = createContextStore(
|
||||||
'graph',
|
'graph',
|
||||||
(proj: ProjectStore, suggestionDb: SuggestionDbStore) => {
|
(proj: ProjectStore, suggestionDb: SuggestionDbStore) => {
|
||||||
proj.setObservedFileName('Main.enso')
|
proj.setObservedFileName('Main.enso')
|
||||||
@ -140,10 +140,32 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const methodAst = computed<Result<Ast.FunctionDef>>(() =>
|
const immediateMethodAst = computed<Result<Ast.FunctionDef>>(() =>
|
||||||
syncModule.value ? getExecutedMethodAst(syncModule.value) : Err('AST not yet initialized'),
|
syncModule.value ? getExecutedMethodAst(syncModule.value) : Err('AST not yet initialized'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// When renaming a function, we temporarily lose track of edited function AST. Ensure that we
|
||||||
|
// still resolve it before the refactor code change is received.
|
||||||
|
const lastKnownResolvedMethodAstId = ref<AstId>()
|
||||||
|
watch(immediateMethodAst, (ast) => {
|
||||||
|
if (ast.ok) lastKnownResolvedMethodAstId.value = ast.value.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const fallbackMethodAst = computed(() => {
|
||||||
|
const id = lastKnownResolvedMethodAstId.value
|
||||||
|
const ast = id != null ? syncModule.value?.get(id) : undefined
|
||||||
|
if (ast instanceof Ast.FunctionDef) return ast
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const methodAst = computed(() => {
|
||||||
|
const imm = immediateMethodAst.value
|
||||||
|
if (imm.ok) return imm
|
||||||
|
const flb = fallbackMethodAst.value
|
||||||
|
if (flb) return Ok(flb)
|
||||||
|
return imm
|
||||||
|
})
|
||||||
|
|
||||||
const watchContext = useWatchContext()
|
const watchContext = useWatchContext()
|
||||||
|
|
||||||
const afterUpdate: (() => void)[] = []
|
const afterUpdate: (() => void)[] = []
|
||||||
@ -167,20 +189,26 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
|
|||||||
db.updateBindings(methodAst.value.value, moduleSource)
|
db.updateBindings(methodAst.value.value, moduleSource)
|
||||||
})
|
})
|
||||||
|
|
||||||
function getExecutedMethodAst(module?: Ast.Module): Result<Ast.FunctionDef> {
|
const currentMethodPointer = computed((): Result<MethodPointer> => {
|
||||||
const executionStackTop = proj.executionContext.getStackTop()
|
const executionStackTop = proj.executionContext.getStackTop()
|
||||||
switch (executionStackTop.type) {
|
switch (executionStackTop.type) {
|
||||||
case 'ExplicitCall': {
|
case 'ExplicitCall': {
|
||||||
return getMethodAst(executionStackTop.methodPointer, module)
|
return Ok(executionStackTop.methodPointer)
|
||||||
}
|
}
|
||||||
case 'LocalCall': {
|
case 'LocalCall': {
|
||||||
const exprId = executionStackTop.expressionId
|
const exprId = executionStackTop.expressionId
|
||||||
const info = db.getExpressionInfo(exprId)
|
const info = db.getExpressionInfo(exprId)
|
||||||
const ptr = info?.methodCall?.methodPointer
|
const ptr = info?.methodCall?.methodPointer
|
||||||
if (!ptr) return Err("Unknown method pointer of execution stack's top frame")
|
if (!ptr) return Err("Unknown method pointer of execution stack's top frame")
|
||||||
return getMethodAst(ptr, module)
|
return Ok(ptr)
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
return assertNever(executionStackTop)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function getExecutedMethodAst(module?: Ast.Module): Result<Ast.FunctionDef> {
|
||||||
|
return andThen(currentMethodPointer.value, (ptr) => getMethodAst(ptr, module))
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMethodAst(ptr: MethodPointer, edit?: Ast.Module): Result<Ast.FunctionDef> {
|
function getMethodAst(ptr: MethodPointer, edit?: Ast.Module): Result<Ast.FunctionDef> {
|
||||||
@ -748,8 +776,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
|
|||||||
if (expressionInfo?.methodCall == null) return false
|
if (expressionInfo?.methodCall == null) return false
|
||||||
|
|
||||||
const definedOnType = tryQualifiedName(expressionInfo.methodCall.methodPointer.definedOnType)
|
const definedOnType = tryQualifiedName(expressionInfo.methodCall.methodPointer.definedOnType)
|
||||||
const openModuleName = qnLastSegment(proj.modulePath.value)
|
if (definedOnType.ok && definedOnType.value !== proj.modulePath.value) {
|
||||||
if (definedOnType.ok && qnLastSegment(definedOnType.value) !== openModuleName) {
|
|
||||||
// Cannot enter node that is not defined on current module.
|
// Cannot enter node that is not defined on current module.
|
||||||
// TODO: Support entering nodes in other modules within the same project.
|
// TODO: Support entering nodes in other modules within the same project.
|
||||||
return false
|
return false
|
||||||
@ -813,11 +840,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
|
|||||||
addMissingImportsDisregardConflicts,
|
addMissingImportsDisregardConflicts,
|
||||||
isConnectedTarget,
|
isConnectedTarget,
|
||||||
nodeCanBeEntered,
|
nodeCanBeEntered,
|
||||||
currentMethodPointer() {
|
currentMethodPointer,
|
||||||
const currentMethod = proj.executionContext.getStackTop()
|
|
||||||
if (currentMethod.type === 'ExplicitCall') return currentMethod.methodPointer
|
|
||||||
return db.getExpressionInfo(currentMethod.expressionId)?.methodCall?.methodPointer
|
|
||||||
},
|
|
||||||
modulePath,
|
modulePath,
|
||||||
connectedEdges,
|
connectedEdges,
|
||||||
...unconnectedEdges,
|
...unconnectedEdges,
|
||||||
|
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 { assert } from '@/util/assert'
|
||||||
import { findIndexOpt } from '@/util/data/array'
|
import { findDifferenceIndex } from '@/util/data/array'
|
||||||
import { isSome, type Opt } from '@/util/data/opt'
|
import { isSome, type Opt } from '@/util/data/opt'
|
||||||
import { Err, Ok, ResultError, type Result } from '@/util/data/result'
|
import { Err, Ok, ResultError, type Result } from '@/util/data/result'
|
||||||
import { AsyncQueue, type AbortScope } from '@/util/net'
|
import { AsyncQueue, type AbortScope } from '@/util/net'
|
||||||
@ -240,12 +240,18 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TODO: Add docs */
|
/**
|
||||||
|
* The stack of execution frames that we want to currently inspect. The actual stack
|
||||||
|
* state in the language server can differ, since it is updated asynchronously.
|
||||||
|
*/
|
||||||
get desiredStack() {
|
get desiredStack() {
|
||||||
return this._desiredStack
|
return this._desiredStack
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TODO: Add docs */
|
/**
|
||||||
|
* Set the currently desired stack of excution frames. This will cause appropriate
|
||||||
|
* stack push/pop operations to be sent to the language server.
|
||||||
|
*/
|
||||||
set desiredStack(stack: StackItem[]) {
|
set desiredStack(stack: StackItem[]) {
|
||||||
this._desiredStack.length = 0
|
this._desiredStack.length = 0
|
||||||
this._desiredStack.push(...stack)
|
this._desiredStack.push(...stack)
|
||||||
@ -400,28 +406,36 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
|
|||||||
const state = newState
|
const state = newState
|
||||||
if (state.status !== 'created')
|
if (state.status !== 'created')
|
||||||
return Err('Cannot sync stack when execution context is not created')
|
return Err('Cannot sync stack when execution context is not created')
|
||||||
const firstDifferent =
|
while (true) {
|
||||||
findIndexOpt(this._desiredStack, (item, index) => {
|
// Since this is an async function, the desired state can change inbetween individual API calls.
|
||||||
const stateStack = state.stack[index]
|
// We need to compare the desired stack state against current state on every loop iteration.
|
||||||
return stateStack == null || !stackItemsEqual(item, stateStack)
|
|
||||||
}) ?? this._desiredStack.length
|
const firstDifferent = findDifferenceIndex(
|
||||||
for (let i = state.stack.length; i > firstDifferent; --i) {
|
this._desiredStack,
|
||||||
const popResult = await this.withBackoff(
|
state.stack,
|
||||||
() => this.lsRpc.popExecutionContextItem(this.id),
|
stackItemsEqual,
|
||||||
'Failed to pop execution stack frame',
|
|
||||||
)
|
)
|
||||||
if (popResult.ok) state.stack.pop()
|
|
||||||
else return popResult
|
if (state.stack.length > firstDifferent) {
|
||||||
}
|
// Found a difference within currently set stack context. We need to pop our way up to it.
|
||||||
for (let i = state.stack.length; i < this._desiredStack.length; ++i) {
|
const popResult = await this.withBackoff(
|
||||||
const newItem = this._desiredStack[i]!
|
() => this.lsRpc.popExecutionContextItem(this.id),
|
||||||
const pushResult = await this.withBackoff(
|
'Failed to pop execution stack frame',
|
||||||
() => this.lsRpc.pushExecutionContextItem(this.id, newItem),
|
)
|
||||||
'Failed to push execution stack frame',
|
if (popResult.ok) state.stack.pop()
|
||||||
)
|
else return popResult
|
||||||
if (pushResult.ok) state.stack.push(newItem)
|
} else if (state.stack.length < this._desiredStack.length) {
|
||||||
else return pushResult
|
// Desired stack is matching current state, but it is longer. We need to push the next item.
|
||||||
|
const newItem = this._desiredStack[state.stack.length]!
|
||||||
|
const pushResult = await this.withBackoff(
|
||||||
|
() => this.lsRpc.pushExecutionContextItem(this.id, newItem),
|
||||||
|
'Failed to push execution stack frame',
|
||||||
|
)
|
||||||
|
if (pushResult.ok) state.stack.push(newItem)
|
||||||
|
else return pushResult
|
||||||
|
} else break
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok()
|
return Ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,7 +101,7 @@ export type ProjectStore = ReturnType<typeof useProjectStore>
|
|||||||
* performed using a CRDT data types from Yjs. Once the data is synchronized with a "LS bridge"
|
* performed using a CRDT data types from Yjs. Once the data is synchronized with a "LS bridge"
|
||||||
* client, it is submitted to the language server as a document update.
|
* client, it is submitted to the language server as a document update.
|
||||||
*/
|
*/
|
||||||
export const { provideFn: provideProjectStore, injectFn: useProjectStore } = createContextStore(
|
export const [provideProjectStore, useProjectStore] = createContextStore(
|
||||||
'project',
|
'project',
|
||||||
(props: { projectId: string; renameProject: (newName: string) => void }) => {
|
(props: { projectId: string; renameProject: (newName: string) => void }) => {
|
||||||
const { projectId, renameProject: renameProjectBackend } = props
|
const { projectId, renameProject: renameProjectBackend } = props
|
||||||
|
@ -6,7 +6,7 @@ import { ExecutionEnvironment } from 'ydoc-shared/languageServerTypes'
|
|||||||
import { ExternalId } from 'ydoc-shared/yjsModel'
|
import { ExternalId } from 'ydoc-shared/yjsModel'
|
||||||
|
|
||||||
/** Allows to recompute certain expressions (usually nodes). */
|
/** Allows to recompute certain expressions (usually nodes). */
|
||||||
export const { provideFn: provideNodeExecution, injectFn: useNodeExecution } = createContextStore(
|
export const [provideNodeExecution, useNodeExecution] = createContextStore(
|
||||||
'nodeExecution',
|
'nodeExecution',
|
||||||
(projectStore: ProjectStore) => {
|
(projectStore: ProjectStore) => {
|
||||||
const recomputationInProgress = reactive(new Set<ExternalId>())
|
const recomputationInProgress = reactive(new Set<ExternalId>())
|
||||||
|
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,
|
showHelpForCB: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { injectFn: useSettings, provideFn: provideSettings } = createContextStore(
|
export const [provideSettings, useSettings] = createContextStore('settings', () => {
|
||||||
'settings',
|
const user = ref<UserSettings>(defaultUserSettings)
|
||||||
() => {
|
|
||||||
const user = ref<UserSettings>(defaultUserSettings)
|
|
||||||
|
|
||||||
useSyncLocalStorage<UserSettings>({
|
useSyncLocalStorage<UserSettings>({
|
||||||
storageKey: 'enso-user-settings',
|
storageKey: 'enso-user-settings',
|
||||||
mapKeyEncoder: () => {},
|
mapKeyEncoder: () => {},
|
||||||
debounce: 200,
|
debounce: 200,
|
||||||
captureState() {
|
captureState() {
|
||||||
return user.value ?? defaultUserSettings
|
return user.value ?? defaultUserSettings
|
||||||
},
|
},
|
||||||
async restoreState(restored) {
|
async restoreState(restored) {
|
||||||
if (restored) {
|
if (restored) {
|
||||||
user.value = { ...defaultUserSettings, ...restored }
|
user.value = { ...defaultUserSettings, ...restored }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return { user }
|
return { user }
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
@ -67,3 +67,14 @@ export function documentationData(
|
|||||||
isUnstable: isSome(tagValue(parsed, 'Unstable')) || isSome(tagValue(parsed, 'Advanced')),
|
isUnstable: isSome(tagValue(parsed, 'Unstable')) || isSome(tagValue(parsed, 'Advanced')),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ICON tag value from the documentation block. Only use this function
|
||||||
|
* if all you need is icon, since the docs parsing is an expensive operation.
|
||||||
|
* @param documentation String representation of documentation block.
|
||||||
|
* @returns Value of icon tag within the docs.
|
||||||
|
*/
|
||||||
|
export function getDocsIcon(documentation: Opt<string>): Opt<Icon> {
|
||||||
|
const parsed = documentation != null ? parseDocs(documentation) : []
|
||||||
|
return tagValue(parsed, 'Icon') as Opt<Icon>
|
||||||
|
}
|
||||||
|
@ -171,8 +171,9 @@ class Synchronizer {
|
|||||||
|
|
||||||
/** {@link useSuggestionDbStore} composable object */
|
/** {@link useSuggestionDbStore} composable object */
|
||||||
export type SuggestionDbStore = ReturnType<typeof useSuggestionDbStore>
|
export type SuggestionDbStore = ReturnType<typeof useSuggestionDbStore>
|
||||||
export const { provideFn: provideSuggestionDbStore, injectFn: useSuggestionDbStore } =
|
export const [provideSuggestionDbStore, useSuggestionDbStore] = createContextStore(
|
||||||
createContextStore('suggestionDatabase', (projectStore: ProjectStore) => {
|
'suggestionDatabase',
|
||||||
|
(projectStore: ProjectStore) => {
|
||||||
const entries = new SuggestionDb()
|
const entries = new SuggestionDb()
|
||||||
const groups = ref<Group[]>([])
|
const groups = ref<Group[]>([])
|
||||||
|
|
||||||
@ -194,4 +195,5 @@ export const { provideFn: provideSuggestionDbStore, injectFn: useSuggestionDbSto
|
|||||||
|
|
||||||
const _synchronizer = new Synchronizer(projectStore, entries, groups)
|
const _synchronizer = new Synchronizer(projectStore, entries, groups)
|
||||||
return proxyRefs({ entries: markRaw(entries), groups, _synchronizer, mockSuggestion })
|
return proxyRefs({ entries: markRaw(entries), groups, _synchronizer, mockSuggestion })
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
@ -78,8 +78,9 @@ const builtinVisualizationsByName = Object.fromEntries(
|
|||||||
builtinVisualizations.map((viz) => [viz.name, viz]),
|
builtinVisualizations.map((viz) => [viz.name, viz]),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const { provideFn: provideVisualizationStore, injectFn: useVisualizationStore } =
|
export const [provideVisualizationStore, useVisualizationStore] = createContextStore(
|
||||||
createContextStore('visualization', (proj: ProjectStore) => {
|
'visualization',
|
||||||
|
(proj: ProjectStore) => {
|
||||||
const cache = reactive(new Map<VisualizationId, Promise<VisualizationModule>>())
|
const cache = reactive(new Map<VisualizationId, Promise<VisualizationModule>>())
|
||||||
/**
|
/**
|
||||||
* A map from file path to {@link AbortController}, so that a file change event can stop previous
|
* A map from file path to {@link AbortController}, so that a file change event can stop previous
|
||||||
@ -283,4 +284,5 @@ export const { provideFn: provideVisualizationStore, injectFn: useVisualizationS
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { types, get, icon }
|
return { types, get, icon }
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { partitionPoint } from '@/util/data/array'
|
import { findDifferenceIndex, partitionPoint } from '@/util/data/array'
|
||||||
import { fc, test } from '@fast-check/vitest'
|
import { fc, test } from '@fast-check/vitest'
|
||||||
import { expect } from 'vitest'
|
import { expect } from 'vitest'
|
||||||
|
|
||||||
@ -38,3 +38,34 @@ test.prop({
|
|||||||
const target = arr[i]!
|
const target = arr[i]!
|
||||||
expect(partitionPoint(arr, (n) => n > target)).toEqual(i)
|
expect(partitionPoint(arr, (n) => n > target)).toEqual(i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test.prop({
|
||||||
|
array: fc.array(fc.anything()),
|
||||||
|
})('findDifferenceIndex (same array)', ({ array }) => {
|
||||||
|
expect(findDifferenceIndex(array, array)).toEqual(array.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.prop({
|
||||||
|
array: fc.array(fc.anything()),
|
||||||
|
})('findDifferenceIndex (empty)', ({ array }) => {
|
||||||
|
expect(findDifferenceIndex(array, [])).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.prop({
|
||||||
|
arr1: fc.array(fc.integer()),
|
||||||
|
arr2: fc.array(fc.integer()),
|
||||||
|
returnedIndex: fc.context(),
|
||||||
|
})('findDifferenceIndex (arbitrary arrays)', ({ arr1, arr2, returnedIndex }) => {
|
||||||
|
const differenceIndex = findDifferenceIndex(arr1, arr2)
|
||||||
|
const differenceIndexInverse = findDifferenceIndex(arr2, arr1)
|
||||||
|
returnedIndex.log(`${differenceIndex}`)
|
||||||
|
expect(differenceIndex).toEqual(differenceIndexInverse)
|
||||||
|
|
||||||
|
const shorterArrayLen = Math.min(arr1.length, arr2.length)
|
||||||
|
expect(differenceIndex).toBeLessThanOrEqual(shorterArrayLen)
|
||||||
|
expect(arr1.slice(0, differenceIndex)).toEqual(arr2.slice(0, differenceIndex))
|
||||||
|
if (differenceIndex < shorterArrayLen) {
|
||||||
|
expect(arr1.slice(differenceIndex)).not.toEqual(arr2.slice(differenceIndex))
|
||||||
|
expect(arr1[differenceIndex]).not.toEqual(arr2[differenceIndex])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ -88,3 +88,17 @@ export function partition<T>(array: Iterable<T>, pred: (elem: T) => boolean): [T
|
|||||||
|
|
||||||
return [truthy, falsy]
|
return [truthy, falsy]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find smallest index at which two arrays differ. Returns an index past the array (i.e. array length) when both arrays are equal.
|
||||||
|
*/
|
||||||
|
export function findDifferenceIndex<T>(
|
||||||
|
lhs: T[],
|
||||||
|
rhs: T[],
|
||||||
|
equals = (a: T, b: T) => a === b,
|
||||||
|
): number {
|
||||||
|
return (
|
||||||
|
findIndexOpt(lhs, (item, index) => index >= rhs.length || !equals(item, rhs[index]!)) ??
|
||||||
|
lhs.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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 {
|
export function mapOr<T, R>(optional: Opt<T>, fallback: R, mapper: (value: T) => R): R {
|
||||||
return isSome(optional) ? mapper(optional) : fallback
|
return isSome(optional) ? mapper(optional) : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the value inside the given {@link Opt} if it is not nullish,
|
||||||
|
* else return undefined.
|
||||||
|
*/
|
||||||
|
export function mapOrUndefined<T, R>(optional: Opt<T>, mapper: (value: T) => Opt<R>): Opt<R> {
|
||||||
|
return mapOr(optional, undefined, mapper)
|
||||||
|
}
|
||||||
|
@ -78,6 +78,15 @@ export function mapOk<T, U, E>(result: Result<T, E>, f: (value: T) => U): Result
|
|||||||
else return result
|
else return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maps the {@link Result} value with a function that returns a result. */
|
||||||
|
export function andThen<T, U, E>(
|
||||||
|
result: Result<T, E>,
|
||||||
|
f: (value: T) => Result<U, E>,
|
||||||
|
): Result<U, E> {
|
||||||
|
if (result.ok) return f(result.value)
|
||||||
|
else return result
|
||||||
|
}
|
||||||
|
|
||||||
/** If the value is nullish, returns {@link Ok} with it. */
|
/** If the value is nullish, returns {@link Ok} with it. */
|
||||||
export function transposeResult<T, E>(value: Opt<Result<T, E>>): Result<Opt<T>, E>
|
export function transposeResult<T, E>(value: Opt<Result<T, E>>): Result<Opt<T>, E>
|
||||||
/** If any of the values is an error, the first error is returned. */
|
/** If any of the values is an error, the first error is returned. */
|
||||||
|
Loading…
Reference in New Issue
Block a user