Skip to content

Literals and operators in Component Browser #12420

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
- ["Insert link" button added to documentation panel][12365]
- [Cloud File Browser, when opened first time after opening project, shows and
highlights the currently set file][12184]
- [It's easier to write numeric/text nodes in Component Browser][12420]. When
typing digits only, any names containing digits are not the best match
anymore. Also unclosed text literals will be automatically closed.

[11889]: https://github.com/enso-org/enso/pull/11889
[11836]: https://github.com/enso-org/enso/pull/11836
Expand All @@ -56,6 +59,7 @@
[12386]: https://github.com/enso-org/enso/pull/12386
[12365]: https://github.com/enso-org/enso/pull/12365
[12184]: https://github.com/enso-org/enso/pull/12184
[12420]: https://github.com/enso-org/enso/pull/12420

#### Enso Standard Library

Expand Down
57 changes: 29 additions & 28 deletions app/gui/src/project-view/components/ComponentBrowser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { componentBrowserBindings, listBindings } from '@/bindings'
import { type Component } from '@/components/ComponentBrowser/component'
import ComponentEditor from '@/components/ComponentBrowser/ComponentEditor.vue'
import ComponentList from '@/components/ComponentBrowser/ComponentList.vue'
import { Filtering } from '@/components/ComponentBrowser/filtering'
import { useComponentBrowserInput, type Usage } from '@/components/ComponentBrowser/input'
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
import SvgButton from '@/components/SvgButton.vue'
Expand All @@ -15,7 +14,6 @@ import { injectNodeColors } from '@/providers/graphNodeColors'
import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler'
import { useGraphStore } from '@/stores/graph'
import type { RequiredImport } from '@/stores/graph/imports'
import { useProjectStore } from '@/stores/project'
import { injectProjectNames } from '@/stores/projectNames'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { type Typename } from '@/stores/suggestionDatabase/entry'
Expand All @@ -29,6 +27,8 @@ import { debouncedGetter } from '@/util/reactivity'
import type { ComponentInstance } from 'vue'
import { computed, onMounted, onUnmounted, ref, toValue, watch, watchEffect } from 'vue'
import type { SuggestionId } from 'ydoc-shared/languageServerTypes/suggestions'
import { Range } from 'ydoc-shared/util/data/range'
import { Ok } from 'ydoc-shared/util/data/result'
import type { VisualizationIdentifier } from 'ydoc-shared/yjsModel'

// Difference in position between the component browser and a node for the input of the component browser to
Expand All @@ -47,7 +47,6 @@ const EDGE_Y_OFFSET = -8

const cssComponentEditorPadding = `${COMPONENT_EDITOR_PADDING}px`

const projectStore = useProjectStore()
const suggestionDbStore = useSuggestionDbStore()
const graphStore = useGraphStore()
const interaction = injectInteractionHandler()
Expand Down Expand Up @@ -177,15 +176,6 @@ const selectedSuggestion = computed(() => {

const input = useComponentBrowserInput()

const currentFiltering = computed(() => {
if (input.mode.mode === 'componentBrowsing') {
const currentModule = projectStore.moduleProjectPath
return new Filtering(input.mode.filter, currentModule?.ok ? currentModule.value : undefined)
} else {
return undefined
}
})

onUnmounted(() => {
graphStore.cbEditedEdge = undefined
})
Expand Down Expand Up @@ -285,19 +275,26 @@ watch(

// === Accepting Entry ===

function acceptSuggestion(component: Opt<Component> = null) {
const suggestionId = component?.suggestionId ?? selectedSuggestionId.value
if (suggestionId == null) return acceptInput()
const result = input.applySuggestion(suggestionId)
if (result.ok) acceptInput()
else result.error.log('Cannot apply suggestion')
function applyComponent(component: Opt<Component> = null) {
component ??= selected.value
if (component == null) {
input.switchToCodeEditMode()
return Ok()
}
if (component.suggestionId != null) {
return input.applySuggestion(component.suggestionId)
} else {
// Component without suggestion database entry, for example "literal" component.
input.content = { text: component.label, selection: Range.emptyAt(component.label.length) }
input.switchToCodeEditMode()
return Ok()
}
}

function applySuggestion(component: Opt<Component> = null) {
const suggestionId = component?.suggestionId ?? selectedSuggestionId.value
if (suggestionId == null) return input.switchToCodeEditMode()
const result = input.applySuggestion(suggestionId)
if (!result.ok) result.error.log('Cannot apply suggestion')
function acceptComponent(component: Opt<Component> = null) {
const result = applyComponent(component)
if (result.ok) acceptInput()
else result.error.log('Cannot apply suggestion')
}

function acceptInput() {
Expand All @@ -314,11 +311,14 @@ function acceptInput() {
const outsideComponentBrowsing = computed(() => input.mode.mode != 'componentBrowsing')
const actions = registerHandlers({
'componentBrowser.editSuggestion': {
action: applySuggestion,
action: () => {
const result = applyComponent()
if (!result.ok) result.error.log('Cannot apply component')
},
disabled: outsideComponentBrowsing,
},
'componentBrowser.acceptSuggestion': {
action: acceptSuggestion,
action: acceptComponent,
disabled: outsideComponentBrowsing,
},
'componentBrowser.acceptInputAsCode': {
Expand Down Expand Up @@ -427,10 +427,11 @@ const listsHandler = listBindings.handler({
/>
</div>
<ComponentList
v-if="input.mode.mode === 'componentBrowsing' && currentFiltering"
v-if="input.mode.mode === 'componentBrowsing'"
ref="componentList"
:filtering="currentFiltering"
@acceptSuggestion="acceptSuggestion($event)"
:filter="input.mode.filter"
:literal="input.mode.literal"
@acceptSuggestion="acceptComponent($event)"
@update:selectedComponent="selected = $event"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
<script setup lang="ts">
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
import { makeComponentLists, type Component } from '@/components/ComponentBrowser/component'
import ComponentEntry from '@/components/ComponentBrowser/ComponentEntry.vue'
import type { Filtering } from '@/components/ComponentBrowser/filtering'
import { Filter, Filtering } from '@/components/ComponentBrowser/filtering'
import SvgIcon from '@/components/SvgIcon.vue'
import VirtualizedList from '@/components/VirtualizedList.vue'
import { groupColorStyle } from '@/composables/nodeColors'
import { useProjectStore } from '@/stores/project'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { Ast } from '@/util/ast'
import { tryGetIndex } from '@/util/data/array'
import { computed, ref, toRef, watch } from 'vue'
import * as map from 'lib0/map'
import { computed, ref, watch } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'

const ITEM_SIZE = 24
const SCROLL_TO_SELECTION_MARGIN = ITEM_SIZE / 2
const MOUSE_SELECTION_DEBOUNCE = 200

const props = defineProps<{
filtering: Filtering
filter: Filter
literal?: Ast.Ast | undefined
}>()
const emit = defineEmits<{
acceptSuggestion: [suggestion: Component]
'update:selectedComponent': [selected: Component | null]
}>()

const projectStore = useProjectStore()
const root = ref<HTMLElement>()
const groupsPanel = ref<ComponentExposed<typeof VirtualizedList>>()
const componentsPanel = ref<ComponentExposed<typeof VirtualizedList>>()
Expand All @@ -42,18 +47,34 @@ const displayedSelectedComponentIndex = computed({
},
})

watch(toRef(props, 'filtering'), () => (displayedSelectedComponentIndex.value = 0))
const filtering = computed(() => {
const currentModule = projectStore.moduleProjectPath
return new Filtering(props.filter, currentModule?.ok ? currentModule.value : undefined)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a little simpler with unwrapOr.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentModule may be undefined, so I wouldn't avoid ?:

})

watch(filtering, () => (displayedSelectedComponentIndex.value = 0))
watch(selectedGroupIndex, () => (selectedComponentIndex.value = 0))

const suggestionDbStore = useSuggestionDbStore()
const components = computed(() => makeComponentList(suggestionDbStore.entries, props.filtering))
const components = computed(() => {
const lists = makeComponentLists(suggestionDbStore.entries, filtering.value)
if (props.literal != null) {
map
.setIfUndefined(lists, 'all', (): Component[] => [])
.unshift({
label: props.literal.code(),
icon: props.literal instanceof Ast.TextLiteral ? 'text_input' : 'input_number',
})
}
return lists
})
const currentGroups = computed(() => {
return Array.from(components.value.entries(), ([id, components]) => ({
id,
...(id === 'all' ? { name: 'all' }
: id === 'suggestions' ? { name: 'suggestions' }
: (suggestionDbStore.groups[id] ?? { name: 'unknown' })),
...(props.filtering.pattern != null ? { displayedNumber: components.length } : {}),
...(filtering.value?.pattern != null ? { displayedNumber: components.length } : {}),
}))
})
const displayedGroupId = computed(() =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useComponentBrowserInput } from '@/components/ComponentBrowser/input'
import { GraphDb, NodeId } from '@/stores/graph/graphDatabase'
import { ComputedValueRegistry } from '@/stores/project/computedValueRegistry'
import { SuggestionDb } from '@/stores/suggestionDatabase'
import { unwrap } from '@/util/data/result'
import { parseAbsoluteProjectPathRaw } from '@/util/projectPath'
import { expect, test } from 'vitest'
import { parseExpression } from 'ydoc-shared/ast'
import { assert, assertUnreachable } from 'ydoc-shared/util/assert'
import { Range } from 'ydoc-shared/util/data/range'

const aiMock = { query: assertUnreachable }
const operator1Id = '3d0e9b96-3ca0-4c35-a820-7d3a1649de55' as NodeId
const operator2Id = '5eb16101-dd2b-4034-a6e2-476e8bfa1f2b' as NodeId

function mockGraphDb() {
const computedValueRegistryMock = ComputedValueRegistry.Mock()
computedValueRegistryMock.db.set(operator1Id, {
typename: unwrap(parseAbsoluteProjectPathRaw('Standard.Base.Number')),
rawTypename: 'Standard.Base.Number',
methodCall: undefined,
payload: { type: 'Value' },
profilingInfo: [],
})
const db = GraphDb.Mock(computedValueRegistryMock)
db.mockNode('operator1', operator1Id, 'Data.read')
db.mockNode('operator2', operator2Id)
return db
}

test.each`
inputContent | expectedLiteral
${'read'} | ${undefined}
${'operator1'} | ${undefined}
${'12 + 14'} | ${undefined}
${'12'} | ${'12'}
${'12.6'} | ${'12.6'}
${'-12'} | ${'-12'}
${'-12.6'} | ${'-12.6'}
${'- 12.6'} | ${undefined /* in Enso this is partial OprApp, not negation */}
${'"text"'} | ${'"text"'}
${"'text'"} | ${"'text'"}
${"'text"} | ${"'text'"}
${"'''text"} | ${"'''text"}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Negative numeric literal case?

`('Reading literal from $inputContent', ({ inputContent, expectedLiteral }) => {
console.log(parseExpression(inputContent))
const input = useComponentBrowserInput(mockGraphDb(), new SuggestionDb(), aiMock)
input.reset({ type: 'newNode' })
input.content = { text: inputContent, selection: Range.empty }
assert(input.mode.mode === 'componentBrowsing')
expect(input.mode.literal?.code()).toBe(expectedLiteral)
})

test.each`
inputContent | source | expectedCode
${'read'} | ${'operator1'} | ${'operator1.read'}
${'read'} | ${'operator2'} | ${'operator2.read'}
${'read "file"'} | ${'operator2'} | ${'operator2.read "file"'}
${'+ 2'} | ${'operator1'} | ${'operator1 + 2'}
${'+ 3'} | ${'operator2'} | ${'operator2 + 3'}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some more good cases:

  • Operators without space
  • - without space--to make sure we don't treat that as a negative literal here

${'+2'} | ${'operator1'} | ${'operator1+2'}
${'-2'} | ${'operator1'} | ${'operator1-2'}
`(
'Code generated by CB from $inputContent with source node $sourceNode',
({ inputContent, source, expectedCode }) => {
const db = mockGraphDb()
const input = useComponentBrowserInput(db, new SuggestionDb(), aiMock)
const sourcePort = db.getNodeFirstOutputPort(db.getIdentDefiningNode(source))
assert(sourcePort != null)
input.reset({ type: 'newNode', sourcePort })
input.content = { text: inputContent, selection: Range.empty }
expect(input.code).toBe(expectedCode)
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ interface ComponentLabel {

/** A model of component suggestion displayed in the Component Browser. */
export interface Component extends ComponentLabel {
suggestionId: SuggestionId
suggestionId?: SuggestionId
icon: Icon
group?: number | undefined
}
Expand Down Expand Up @@ -112,7 +112,7 @@ export function makeComponent({ id, entry, match }: ComponentInfo): Component {
}

/** Create {@link Component} list for each displayed group from filtered suggestions. */
export function makeComponentList(
export function makeComponentLists(
db: SuggestionDb,
filtering: Filtering,
): Map<GroupId, Component[]> {
Expand Down
Loading
Loading