Skip to content

Commit

Permalink
New Component Browser with Groups panel (#12386)
Browse files Browse the repository at this point in the history
Fixes #12309
Fixes #12327

https://github.com/user-attachments/assets/a6e77db3-92f3-4032-8df7-69e48411fac8

# Important Notes
The list of components was refactored out to `LazyList` - this list instantiates HTML elements only of visible things. I'm not sure about the name - is there any more "technical" term for such a list?
  • Loading branch information
farmaazon authored Mar 5, 2025
1 parent f534af4 commit da89ff6
Show file tree
Hide file tree
Showing 22 changed files with 671 additions and 388 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
buttons][12341]
- [Cloud File Browser allows renaming existing directories in "writing"
components][12323]
- [New Component Browser displaying list of groups][12386]
- ["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]
Expand All @@ -52,6 +53,7 @@
[12275]: https://github.com/enso-org/enso/pull/12275
[12341]: https://github.com/enso-org/enso/pull/12341
[12323]: https://github.com/enso-org/enso/pull/12323
[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

Expand Down
34 changes: 31 additions & 3 deletions app/gui/integration-test/project-view/componentBrowser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ test('Filling input with suggestion', async ({ page }) => {
await expect(locate.componentBrowserEntry(page)).toExist()

// Applying suggestion
await page.keyboard.press('Tab')
await page.keyboard.press('Shift+Enter')
await expect(locate.componentBrowser(page)).toExist()
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue('Data.read ')
})
Expand All @@ -202,6 +202,34 @@ test('Filtering list', async ({ page }) => {
await expect(segments).toHaveText(['Data.', 're', 'ad', '_te', 'xt'])
const highlighted = locate.componentBrowserEntry(page).locator('.component-label-segment.match')
await expect(highlighted).toHaveText(['re', '_te'])
// Filtered-out group are hidden, and the rest displays number of matched elements.
await expect(page.locator('.groupEntry')).toHaveText(['all (1)', 'Input (1)'])
})

test('Navigating groups', async ({ page }) => {
await actions.goToGraph(page)
await locate.addNewNodeButton(page).click()
await expect(locate.componentBrowserSelectedEntry(page)).toExist()
await expect(page.locator('.groupEntry')).toHaveText(['all', 'Input', 'Output'])
await expect(locate.componentBrowserEntryByLabel(page, 'Data.read')).toExist()
await expect(locate.componentBrowserEntryByLabel(page, 'Data.every_tag')).toExist()

// Hover first group: `Data.read` is filtered out
await page.locator('.groupEntry').nth(1).hover()
await expect(locate.componentBrowserEntryByLabel(page, 'Data.read')).toExist()
await expect(locate.componentBrowserEntryByLabel(page, 'Data.every_tag')).toHaveCount(0)
await expect(locate.componentBrowserSelectedEntry(page)).toExist() // component list didn't lose focus.

// Navigate to second group using arrows.
await page.keyboard.press('Tab')
await expect(locate.componentBrowserSelectedEntry(page)).toHaveCount(0)
await page.keyboard.press('ArrowDown')
await expect(locate.componentBrowserSelectedEntry(page)).toHaveCount(0)
await expect(page.locator('.groupEntry.selected')).toHaveText('Output')
await expect(locate.componentBrowserEntryByLabel(page, 'Data.read')).toHaveCount(0)
await expect(locate.componentBrowserEntryByLabel(page, 'Data.every_tag')).toExist()
await page.keyboard.press('Tab')
await expect(locate.componentBrowserSelectedEntry(page)).toExist()
})

test('Editing existing nodes', async ({ page }) => {
Expand Down Expand Up @@ -247,7 +275,7 @@ test('Visualization preview: type-based visualization selection', async ({ page
const input = locate.componentBrowserInput(page).locator('input')
await input.fill('Table.ne')
await expect(input).toHaveValue('Table.ne')
await locate.componentBrowser(page).getByTestId('switchToEditMode').click()
await page.keyboard.press(`Shift+Enter`)
await expect(locate.tableVisualization(page)).toBeVisible()
await page.keyboard.press('Escape')
await expect(locate.componentBrowser(page)).toBeHidden()
Expand All @@ -262,7 +290,7 @@ test('Visualization preview: user visualization selection', async ({ page }) =>
const input = locate.componentBrowserInput(page).locator('input')
await input.fill('4')
await expect(input).toHaveValue('4')
await locate.componentBrowser(page).getByTestId('switchToEditMode').click()
await page.keyboard.press(`Shift+Enter`)
await expect(locate.jsonVisualization(page)).toBeVisible()
await expect(locate.jsonVisualization(page)).toContainText('"visualizedExpr": "4"')
await locate.toggleVisualizationSelectorButton(page).click()
Expand Down
4 changes: 2 additions & 2 deletions app/gui/integration-test/project-view/locate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@ export const nodeCommentContent = componentLocator('.GraphNodeComment div[conten
* It may be covered by selected one due to way we display them.
*/
export function componentBrowserEntry(page: Locator | Page) {
return page.locator(`.ComponentBrowser .list-variant:not(.selected) .component`)
return page.locator(`.ComponentEntry`)
}

/** A selected variant of Component Browser Entry */
export function componentBrowserSelectedEntry(page: Locator | Page) {
return page.locator(`.ComponentBrowser .list-variant.selected .component`)
return page.locator(`.ComponentEntry.selected`)
}

/** A not-selected variant of Component Browser entry with given label */
Expand Down
14 changes: 10 additions & 4 deletions app/gui/src/project-view/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,24 @@ export const textEditorsBindings = defineKeybinds('text-editors', {
openLink: ['Mod+PointerMain'],
})

export const listBindings = defineKeybinds('list', {
moveUp: ['ArrowUp'],
moveDown: ['ArrowDown'],
accept: ['Enter'],
})

export const interactionBindings = defineKeybinds('current-interaction', {
cancel: ['Escape'],
})

export const componentBrowserBindings = defineKeybinds('component-browser', {
applySuggestion: ['Tab'],
applySuggestion: ['Shift+Enter'],
acceptSuggestion: ['Enter'],
acceptCode: ['Enter'],
acceptInput: ['Mod+Enter'],
acceptAIPrompt: ['Tab', 'Enter'],
moveUp: ['ArrowUp'],
moveDown: ['ArrowDown'],
acceptAIPrompt: ['Enter'],
switchPanelFocus: ['Tab'],
switchToCodeEditMode: ['Mod+Tab'],
})

export const graphBindings = defineKeybinds('graph-editor', {
Expand Down
107 changes: 52 additions & 55 deletions app/gui/src/project-view/components/ComponentBrowser.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { componentBrowserBindings } from '@/bindings'
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'
Expand All @@ -10,6 +10,7 @@ import SvgButton from '@/components/SvgButton.vue'
import { useResizeObserver } from '@/composables/events'
import type { useNavigator } from '@/composables/navigator'
import { groupColorStyle } from '@/composables/nodeColors'
import { Action, registerHandlers } from '@/providers/action'
import { injectNodeColors } from '@/providers/graphNodeColors'
import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler'
import { useGraphStore } from '@/stores/graph'
Expand All @@ -24,10 +25,9 @@ import { tryGetIndex } from '@/util/data/array'
import type { Opt } from '@/util/data/opt'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { DEFAULT_ICON, iconOfNode, suggestionEntryToIcon } from '@/util/getIconName'
import { debouncedGetter } from '@/util/reactivity'
import type { ComponentInstance } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch, watchEffect } from 'vue'
import { computed, onMounted, onUnmounted, ref, toValue, watch, watchEffect } from 'vue'
import type { SuggestionId } from 'ydoc-shared/languageServerTypes/suggestions'
import type { VisualizationIdentifier } from 'ydoc-shared/yjsModel'
Expand All @@ -40,10 +40,10 @@ const PAN_MARGINS = {
left: 80,
right: 40,
}
const COMPONENT_EDITOR_PADDING = 12
const COMPONENT_EDITOR_PADDING = 14
const ICON_WIDTH = 16
// Component editor is larger than a typical node, so the edge should touch it a bit higher.
const EDGE_Y_OFFSET = -6
const EDGE_Y_OFFSET = -8
const cssComponentEditorPadding = `${COMPONENT_EDITOR_PADDING}px`
Expand Down Expand Up @@ -241,27 +241,12 @@ const nodeColor = computed(() => {
return 'var(--node-color-no-type)'
})
const selectedSuggestionIcon = computed(() => {
return selectedSuggestion.value ? suggestionEntryToIcon(selectedSuggestion.value) : undefined
})
const icon = computed(() => {
if (!input.selfArgument) return undefined
if (input.mode.mode === 'componentBrowsing' && selectedSuggestionIcon.value)
return selectedSuggestionIcon.value
if (props.usage.type === 'editNode') {
return iconOfNode(props.usage.node, graphStore.db)
}
return DEFAULT_ICON
})
// === Preview ===
const previewedCode = debouncedGetter<string>(() => input.code, 200)
const previewedSuggestionReturnType = computed(() => {
const id = input.mode.mode === 'codeEditing' ? input.mode.appliedSuggestion : undefined
const appliedEntry = id != null ? suggestionDbStore.entries.get(id) : undefined
const appliedEntry = input.mode.mode === 'codeEditing' ? input.mode.appliedSuggestion : undefined
const entry =
appliedEntry ? appliedEntry
: props.usage.type === 'editNode' ? graphStore.db.getNodeMainSuggestion(props.usage.node)
Expand Down Expand Up @@ -317,36 +302,65 @@ function applySuggestion(component: Opt<Component> = null) {
function acceptInput() {
const appliedReturnType =
input.mode.mode === 'codeEditing' && input.mode.appliedSuggestion != null ?
suggestionDbStore.entries.get(input.mode.appliedSuggestion)?.returnType(projectNames)
input.mode.mode === 'codeEditing' ?
input.mode.appliedSuggestion?.returnType(projectNames)
: undefined
emit('accepted', input.code.trim(), input.importsToAdd(), appliedReturnType)
interaction.ended(cbOpen)
}
// === Key Events Handler ===
// === Action Handlers ===
const outsideComponentBrowsing = computed(() => input.mode.mode != 'componentBrowsing')
const actions = registerHandlers({
'componentBrowser.editSuggestion': {
action: applySuggestion,
disabled: outsideComponentBrowsing,
},
'componentBrowser.acceptSuggestion': {
action: acceptSuggestion,
disabled: outsideComponentBrowsing,
},
'componentBrowser.acceptInputAsCode': {
action: acceptInput,
disabled: outsideComponentBrowsing,
},
'componentBrowser.switchToCodeEditMode': {
disabled: outsideComponentBrowsing,
action: input.switchToCodeEditMode,
},
})
function performActionIfNotDisabled(action: Action & { action: () => void }) {
if (toValue(action.hidden) || toValue(action.disabled)) return false
else return action.action()
}
const handler = componentBrowserBindings.handler({
applySuggestion() {
if (input.mode.mode != 'componentBrowsing') return false
applySuggestion()
return performActionIfNotDisabled(actions['componentBrowser.editSuggestion'])
},
acceptSuggestion() {
if (input.mode.mode != 'componentBrowsing') return false
acceptSuggestion()
return performActionIfNotDisabled(actions['componentBrowser.acceptSuggestion'])
},
acceptCode() {
if (input.mode.mode != 'codeEditing') return false
acceptInput()
},
acceptInput() {
if (input.mode.mode != 'componentBrowsing' && input.mode.mode != 'codeEditing') return false
acceptInput()
},
acceptInput,
acceptAIPrompt() {
if (input.mode.mode == 'aiPrompt') input.applyAIPrompt()
else return false
},
switchToCodeEditMode() {
return performActionIfNotDisabled(actions['componentBrowser.switchToCodeEditMode'])
},
switchPanelFocus() {
componentList.value?.switchPanelFocus()
},
})
const listsHandler = listBindings.handler({
moveUp() {
componentList.value?.moveUp()
},
Expand All @@ -364,7 +378,7 @@ const handler = componentBrowserBindings.handler({
:data-self-argument="input.selfArgument"
tabindex="-1"
@focusout="handleDefocus"
@keydown="handler"
@keydown="handler($event) !== false || listsHandler($event)"
@pointerdown.stop.prevent
@pointerup.stop.prevent
@click.stop.prevent
Expand Down Expand Up @@ -397,28 +411,11 @@ const handler = componentBrowserBindings.handler({
ref="inputElement"
v-model="input.content"
class="component-editor"
:navigator="props.navigator"
:icon="icon"
:usage="usage"
:mode="input.mode"
:nodeColor="nodeColor"
:style="{ '--component-editor-padding': cssComponentEditorPadding }"
>
<SvgButton
name="add_to_graph_editor"
:title="
input.mode.mode === 'componentBrowsing' && selected != null ?
'Accept Suggested Component'
: 'Accept'
"
@click.stop="input.mode.mode === 'componentBrowsing' ? acceptSuggestion() : acceptInput()"
/>
<SvgButton
name="edit"
:disabled="input.mode.mode === 'codeEditing'"
:title="selected != null ? 'Edit Suggested Component' : 'Code Edit Mode'"
data-testid="switchToEditMode"
@click.stop="applySuggestion()"
/>
</ComponentEditor>
/>
<div
v-if="input.mode.mode === 'codeEditing' && !isVisualizationVisible"
class="show-visualization"
Expand All @@ -433,7 +430,6 @@ const handler = componentBrowserBindings.handler({
v-if="input.mode.mode === 'componentBrowsing' && currentFiltering"
ref="componentList"
:filtering="currentFiltering"
:autoSelectFirstComponent="true"
@acceptSuggestion="acceptSuggestion($event)"
@update:selectedComponent="selected = $event"
/>
Expand All @@ -443,7 +439,7 @@ const handler = componentBrowserBindings.handler({
<style scoped>
.ComponentBrowser {
--radius-default: 20px;
--background-color: #eaeaea;
--background-color: #fff;
--doc-panel-bottom-clip: 4px;
min-width: 295px;
width: min-content;
Expand All @@ -463,6 +459,7 @@ const handler = componentBrowserBindings.handler({
.component-editor {
position: relative;
z-index: 1;
}
.visualization-preview {
Expand Down
Loading

0 comments on commit da89ff6

Please sign in to comment.