Merge branch 'wip/gmt/10480-tweaks' into wip/gmt/10163-d-comp

This commit is contained in:
Gregory Travis 2024-07-15 16:59:17 -04:00
commit 3fa7cc42ff
229 changed files with 6073 additions and 2453 deletions

View File

@ -13,10 +13,12 @@
node being completely altered by accidental code put to the widget.
- [Redesigned "record control" panel][10509]. Now it contains more intuitive
"refresh" and "run workflow" buttons.
- [Warning messages do not obscure visualization buttons][10546].
[10433]: https://github.com/enso-org/enso/pull/10443
[10457]: https://github.com/enso-org/enso/pull/10457
[10509]: https://github.com/enso-org/enso/pull/10509
[10546]: https://github.com/enso-org/enso/pull/10546
#### Enso Enso Standard Library
@ -65,6 +67,7 @@
- [Copied table-viz range pastes as Table component][10352]
- [Added support for links in documentation panels][10353].
- [Added support for opening documentation in an external browser][10396].
- Added a [cloud file browser][10513].
[10064]: https://github.com/enso-org/enso/pull/10064
[10179]: https://github.com/enso-org/enso/pull/10179
@ -80,6 +83,7 @@
[10352]: https://github.com/enso-org/enso/pull/10352
[10353]: https://github.com/enso-org/enso/pull/10353
[10396]: https://github.com/enso-org/enso/pull/10396
[10513]: https://github.com/enso-org/enso/pull/10513
#### Enso Language & Runtime

View File

@ -18,7 +18,7 @@ export async function goToGraph(page: Page, closeDocPanel: boolean = true) {
await expect(page.getByTestId('rightDock')).toExist()
await page.getByRole('button', { name: 'Documentation Panel' }).click()
// Wait for the closing animation.
await expect(page.getByTestId('rightDock')).not.toBeVisible()
await expect(page.getByTestId('rightDock')).toBeHidden()
}
// Wait for position initialization
await expectNodePositionsInitialized(page, 72)
@ -26,7 +26,7 @@ export async function goToGraph(page: Page, closeDocPanel: boolean = true) {
export async function expectNodePositionsInitialized(page: Page, yPos: number) {
// Wait until edges are initialized and displayed correctly.
await expect(page.getByTestId('broken-edge')).toHaveCount(0)
await expect(page.getByTestId('broken-edge')).toBeHidden()
// Wait until node sizes are initialized.
await expect(locate.graphNode(page).first().locator('.bgFill')).toBeVisible()
// TODO: The yPos should not need to be a variable. Instead, first automatically positioned nodes
@ -55,3 +55,9 @@ export async function dragNodeByBinding(page: Page, nodeBinding: string, x: numb
force: true,
})
}
/// Move mouse away to avoid random hover events and wait for any circular menus to disappear.
export async function ensureNoCircularMenusVisible(page: Page) {
await page.mouse.move(-1000, 0)
await expect(locate.circularMenu(page)).toBeHidden()
}

View File

@ -68,15 +68,15 @@ test('Collapsing nodes', async ({ page }) => {
// Widgets may "steal" clicks, so we always click at icon.
await locate
.graphNodeByBinding(page, 'prod')
.locator('.icon')
.locator('.grab-handle')
.click({ modifiers: ['Shift'] })
await locate
.graphNodeByBinding(page, 'sum')
.locator('.icon')
.locator('.grab-handle')
.click({ modifiers: ['Shift'] })
await locate
.graphNodeByBinding(page, 'ten')
.locator('.icon')
.locator('.grab-handle')
.click({ modifiers: ['Shift'] })
await page.getByLabel('Group Selected Components').click()
@ -86,14 +86,14 @@ test('Collapsing nodes', async ({ page }) => {
await mockCollapsedFunctionInfo(page, 'prod', 'collapsed')
await locate.graphNodeIcon(collapsedNode).dblclick()
await actions.ensureNoCircularMenusVisible(page)
await expect(locate.graphNode(page)).toHaveCount(4)
await expect(locate.graphNodeByBinding(page, 'ten')).toExist()
await expect(locate.graphNodeByBinding(page, 'sum')).toExist()
await expect(locate.graphNodeByBinding(page, 'prod')).toExist()
await locate
.graphNodeByBinding(page, 'ten')
.locator('.icon')
.locator('.grab-handle')
.click({ modifiers: ['Shift'] })
// Wait till node is selected.
await expect(locate.graphNodeByBinding(page, 'ten').and(page.locator('.selected'))).toHaveCount(1)

View File

@ -27,7 +27,7 @@ async function expectAndCancelBrowser(
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(expectedText)
await expect(locate.componentBrowserInput(page).locator('input')).toBeInViewport()
await page.keyboard.press('Escape')
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.graphNode(page)).toHaveCount(nodeCount)
}
@ -73,7 +73,7 @@ test('Opening Component Browser with small plus buttons', async ({ page }) => {
// Small (+) button shown when node is hovered
await page.keyboard.press('Escape')
await page.mouse.move(100, 80)
await expect(locate.smallPlusButton(page)).not.toBeVisible()
await expect(locate.smallPlusButton(page)).toBeHidden()
await locate.graphNodeIcon(locate.graphNodeByBinding(page, 'selected')).hover()
await expect(locate.smallPlusButton(page)).toBeVisible()
await locate.smallPlusButton(page).click()
@ -82,7 +82,7 @@ test('Opening Component Browser with small plus buttons', async ({ page }) => {
// Small (+) button shown when node is sole selection
await page.keyboard.press('Escape')
await page.mouse.move(300, 300)
await expect(locate.smallPlusButton(page)).not.toBeVisible()
await expect(locate.smallPlusButton(page)).toBeHidden()
await locate.graphNodeByBinding(page, 'selected').click()
await expect(locate.smallPlusButton(page)).toBeVisible()
await locate.smallPlusButton(page).click()
@ -122,7 +122,7 @@ test('Accepting suggestion', async ({ page }) => {
await locate.addNewNodeButton(page).click()
let nodeCount = await locate.graphNode(page).count()
await locate.componentBrowserEntry(page).nth(1).click()
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.graphNode(page)).toHaveCount(nodeCount + 1)
await expect(locate.graphNode(page).last().locator('.WidgetToken')).toHaveText([
'Data',
@ -136,7 +136,7 @@ test('Accepting suggestion', async ({ page }) => {
await deselectAllNodes(page)
await locate.addNewNodeButton(page).click()
await locate.componentBrowserSelectedEntry(page).first().click()
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.graphNode(page)).toHaveCount(nodeCount + 1)
await expect(locate.graphNode(page).last().locator('.WidgetToken')).toHaveText([
'Data',
@ -150,7 +150,7 @@ test('Accepting suggestion', async ({ page }) => {
await deselectAllNodes(page)
await locate.addNewNodeButton(page).click()
await page.keyboard.press('Enter')
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.graphNode(page)).toHaveCount(nodeCount + 1)
await expect(locate.graphNode(page).last().locator('.WidgetToken')).toHaveText([
'Data',
@ -166,7 +166,7 @@ test('Accepting any written input', async ({ page }) => {
const nodeCount = await locate.graphNode(page).count()
await locate.componentBrowserInput(page).locator('input').fill('re')
await page.keyboard.press(ACCEPT_SUGGESTION_SHORTCUT)
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.graphNode(page)).toHaveCount(nodeCount + 1)
await expect(locate.graphNode(page).last().locator('.WidgetToken')).toHaveText('re')
})
@ -216,7 +216,7 @@ test('Editing existing nodes', async ({ page }) => {
await input.pressSequentially(` ${ADDED_PATH}`)
await expect(input).toHaveValue(`Data.read ${ADDED_PATH}`)
await page.keyboard.press('Enter')
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(node.locator('.WidgetToken')).toHaveText(['Data', '.', 'read', '"', '"'])
await expect(node.locator('.WidgetText input')).toHaveValue(ADDED_PATH.replaceAll('"', ''))
@ -228,9 +228,9 @@ test('Editing existing nodes', async ({ page }) => {
for (let i = 0; i < ADDED_PATH.length; ++i) await page.keyboard.press('Backspace')
await expect(input).toHaveValue('Data.read ')
await page.keyboard.press('Enter')
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(node.locator('.WidgetToken')).toHaveText(['Data', '.', 'read'])
await expect(node.locator('.WidgetText')).not.toBeVisible()
await expect(node.locator('.WidgetText')).toBeHidden()
})
test('Visualization preview: type-based visualization selection', async ({ page }) => {
@ -247,9 +247,9 @@ test('Visualization preview: type-based visualization selection', async ({ page
await expect(input).toHaveValue('Table.ne')
// The table visualization is not currently working with `executeExpression` (#9194), but we can test that the JSON
// visualization is no longer selected.
await expect(locate.jsonVisualization(page)).not.toBeVisible()
await expect(locate.jsonVisualization(page)).toBeHidden()
await page.keyboard.press('Escape')
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.graphNode(page)).toHaveCount(nodeCount)
})
@ -268,9 +268,9 @@ test('Visualization preview: user visualization selection', async ({ page }) =>
await page.getByRole('button', { name: 'Table' }).click()
// The table visualization is not currently working with `executeExpression` (#9194), but we can test that the JSON
// visualization is no longer selected.
await expect(locate.jsonVisualization(page)).not.toBeVisible()
await expect(locate.jsonVisualization(page)).toBeHidden()
await page.keyboard.press('Escape')
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.graphNode(page)).toHaveCount(nodeCount)
})
@ -278,7 +278,7 @@ test('Component browser handling of overridden record-mode', async ({ page }) =>
await actions.goToGraph(page)
const node = locate.graphNodeByBinding(page, 'data')
const ADDED_PATH = '"/home/enso/Input.txt"'
const recordModeToggle = node.getByTestId('overrideRecordingButton')
const recordModeToggle = node.getByTestId('toggleRecord')
const recordModeIndicator = node.getByTestId('recordingOverriddenButton')
// Enable record mode for the node.
@ -302,7 +302,7 @@ test('Component browser handling of overridden record-mode', async ({ page }) =>
await page.keyboard.press('End')
await input.pressSequentially(` ${ADDED_PATH}`)
await page.keyboard.press('Enter')
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
// See TODO above.
await page.mouse.move(700, 1200, { steps: 20 })
await expect(recordModeIndicator).toBeVisible()

View File

@ -31,21 +31,21 @@ test('Previewing visualization', async ({ page }) => {
const port = await locate.outputPortCoordinates(node)
await page.keyboard.down('Meta')
await page.keyboard.down('Control')
await expect(locate.anyVisualization(page)).not.toBeVisible()
await expect(locate.anyVisualization(page)).toBeHidden()
await page.mouse.move(port.x, port.y)
await expect(locate.anyVisualization(node)).toBeVisible()
await page.keyboard.up('Meta')
await page.keyboard.up('Control')
await expect(locate.anyVisualization(page)).not.toBeVisible()
await expect(locate.anyVisualization(page)).toBeHidden()
await page.keyboard.down('Meta')
await page.keyboard.down('Control')
await expect(locate.anyVisualization(node)).toBeVisible()
await page.mouse.move(1, 1)
await expect(locate.anyVisualization(page)).not.toBeVisible()
await expect(locate.anyVisualization(page)).toBeHidden()
await page.keyboard.up('Meta')
await page.keyboard.up('Control')
await page.mouse.move(port.x, port.y)
await expect(locate.anyVisualization(page)).not.toBeVisible()
await expect(locate.anyVisualization(page)).toBeHidden()
})
test('Warnings visualization', async ({ page }) => {
@ -56,7 +56,7 @@ test('Warnings visualization', async ({ page }) => {
const input = locate.componentBrowserInput(page).locator('input')
await input.fill('Warning.attach "Uh oh" 42')
await page.keyboard.press('Enter')
await expect(locate.componentBrowser(page)).not.toBeVisible()
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.circularMenu(page)).toExist()
await locate.toggleVisualizationButton(page).click()
await expect(locate.anyVisualization(page)).toExist()

View File

@ -7,7 +7,7 @@ test('Main method documentation', async ({ page }) => {
await actions.goToGraph(page)
// Documentation panel hotkey opens right-dock.
await expect(locate.rightDock(page)).not.toBeVisible()
await expect(locate.rightDock(page)).toBeHidden()
await page.keyboard.press(`${CONTROL_KEY}+D`)
await expect(locate.rightDock(page)).toBeVisible()
@ -16,5 +16,5 @@ test('Main method documentation', async ({ page }) => {
// Documentation hotkey closes right-dock.p
await page.keyboard.press(`${CONTROL_KEY}+D`)
await expect(locate.rightDock(page)).not.toBeVisible()
await expect(locate.rightDock(page)).toBeHidden()
})

View File

@ -11,18 +11,18 @@ test('Selecting nodes by click', async ({ page }) => {
const selectionMenu = page.locator('.SelectionMenu')
await expect(node1).not.toBeSelected()
await expect(node2).not.toBeSelected()
await expect(selectionMenu).not.toBeVisible()
await expect(selectionMenu).toBeHidden()
await locate.graphNodeIcon(node1).click()
await expect(node1).toBeSelected()
await expect(node2).not.toBeSelected()
await expect(selectionMenu).not.toBeVisible()
await expect(selectionMenu).toBeHidden()
// Check that clicking an unselected node deselects replaces the previous selection.
await locate.graphNodeIcon(node2).click()
await expect(node1).not.toBeSelected()
await expect(node2).toBeSelected()
await expect(selectionMenu).not.toBeVisible()
await expect(selectionMenu).toBeHidden()
await page.waitForTimeout(300) // Avoid double clicks
await locate.graphNodeIcon(node1).click({ modifiers: ['Shift'] })
@ -34,13 +34,13 @@ test('Selecting nodes by click', async ({ page }) => {
await locate.graphNodeIcon(node2).click()
await expect(node1).not.toBeSelected()
await expect(node2).toBeSelected()
await expect(selectionMenu).not.toBeVisible()
await expect(selectionMenu).toBeHidden()
// Check that clicking the background deselects all nodes.
await locate.graphEditor(page).click({ position: { x: 600, y: 200 } })
await expect(node1).not.toBeSelected()
await expect(node2).not.toBeSelected()
await expect(selectionMenu).not.toBeVisible()
await expect(selectionMenu).toBeHidden()
})
test('Selecting nodes by area drag', async ({ page }) => {

View File

@ -37,11 +37,11 @@ class DropDownLocator {
}
async expectNotVisible(): Promise<void> {
await expect(this.dropDown).not.toBeVisible()
await expect(this.dropDown).toBeHidden()
}
async clickOption(option: string): Promise<void> {
const item = await this.item(option)
const item = this.item(option)
await item.click()
}
@ -49,12 +49,12 @@ class DropDownLocator {
await this.rootWidget.click()
}
async selectedItem(text: string): Promise<Locator> {
selectedItem(text: string): Locator {
const page = this.dropDown.page()
return this.selectedItems.filter({ has: page.getByText(text) })
}
async item(text: string): Promise<Locator> {
item(text: string): Locator {
const page = this.dropDown.page()
return this.items.filter({ has: page.getByText(text) })
}
@ -107,7 +107,7 @@ test('Multi-selection widget', async ({ page }) => {
// Enable an item.
await dropDown.clickOption('Column A')
await expect(await dropDown.selectedItem('Column A')).toExist()
await expect(dropDown.selectedItem('Column A')).toExist()
await expect(vector).toBeVisible()
await expect(vectorItems).toHaveCount(1)
await expect(vectorItems.first()).toHaveValue('Column A')
@ -182,12 +182,12 @@ test('Multi-selection widget: Item edits', async ({ page }) => {
await dropDown.clickOption('Column B')
// Edit an item
await expect(await dropDown.selectedItem('Column A')).toExist()
await expect(await dropDown.selectedItem('Column B')).toExist()
await expect(dropDown.selectedItem('Column A')).toExist()
await expect(dropDown.selectedItem('Column B')).toExist()
await expect(vectorItems.first()).toHaveValue('Column A')
await vectorItems.first().fill('Something Else')
await expect(await dropDown.selectedItem('Column A')).not.toExist()
await expect(await dropDown.selectedItem('Column B')).toExist()
await expect(dropDown.selectedItem('Column A')).toBeHidden()
await expect(dropDown.selectedItem('Column B')).toExist()
})
async function dataReadNodeWithMethodCallInfo(page: Page): Promise<Locator> {

View File

@ -52,6 +52,7 @@ const conf = [
argsIgnorePattern: '^_',
},
],
'no-unused-labels': 0,
},
},
{

7
app/gui2/lib0-ext.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/** @file Fixups for too loose typings present in lib0. */
import 'lib0/set'
declare module 'lib0/set' {
function first<T>(set: Set<T>): T | undefined
}

View File

@ -47,6 +47,7 @@ export default defineConfig({
reporter: 'html',
use: {
headless: !DEBUG,
actionTimeout: 5000,
trace: 'retain-on-failure',
viewport: { width: 1920, height: 1750 },
...(DEBUG ?

View File

@ -0,0 +1,13 @@
import { fc, test } from '@fast-check/vitest'
import { __TEST, isAstId } from 'shared/ast/mutableModule'
import { expect } from 'vitest'
const { newAstId } = __TEST
test.prop({ str: fc.stringMatching(/^[A-Za-z]+$/) })('isAstId matches created IDs', ({ str }) => {
expect(newAstId(str)).toSatisfy(isAstId)
})
test.prop({ str: fc.uuid() })('isAstId does not match uuids', ({ str: uuid }) => {
expect(uuid).not.toSatisfy(isAstId)
})

View File

@ -183,7 +183,10 @@ export class MutableModule implements Module {
getStateAsUpdate(): ModuleUpdate {
const updateBuilder = new UpdateBuilder(this, this.nodes, undefined)
for (const id of this.nodes.keys()) updateBuilder.addNode(id as AstId)
for (const id of this.nodes.keys()) {
DEV: assertAstId(id)
updateBuilder.addNode(id)
}
return updateBuilder.finish()
}
@ -204,23 +207,24 @@ export class MutableModule implements Module {
if (event.target === this.nodes) {
// Updates to the node map.
for (const [key, change] of event.changes.keys) {
const id = key as AstId
if (!isAstId(key)) continue
switch (change.action) {
case 'add':
updateBuilder.addNode(id)
updateBuilder.addNode(key)
break
case 'update':
updateBuilder.updateAllFields(id)
updateBuilder.updateAllFields(key)
break
case 'delete':
updateBuilder.deleteNode(id)
updateBuilder.deleteNode(key)
break
}
}
} else if (event.target.parent === this.nodes) {
// Updates to a node's fields.
assert(event.target instanceof Y.Map)
const id = event.target.get('id') as AstId
const id = event.target.get('id')
DEV: assertAstId(id)
const node = this.nodes.get(id)
if (!node) continue
const changes: (readonly [string, unknown])[] = Array.from(event.changes.keys, ([key]) => [
@ -230,7 +234,8 @@ export class MutableModule implements Module {
updateBuilder.updateFields(id, changes)
} else if (event.target.parent.parent === this.nodes) {
// Updates to fields of a metadata object within a node.
const id = event.target.parent.get('id') as AstId
const id = event.target.parent.get('id')
DEV: assertAstId(id)
const node = this.nodes.get(id)
if (!node) continue
const metadata = node.get('metadata') as unknown as Map<string, unknown>
@ -352,14 +357,25 @@ export class MutableModule implements Module {
type MutableRootPointer = MutableInvalid & { get expression(): MutableAst | undefined }
function newAstId(type: string): AstId {
return `ast:${type}#${random.uint53()}` as AstId
function newAstId(type: string, sequenceNum = random.uint53()): AstId {
const id = `ast:${type}#${sequenceNum}`
DEV: assertAstId(id)
return id
}
export const __TEST = { newAstId }
/** Checks whether the input looks like an AstId. */
const astIdRegex = /^ast:[A-Za-z]+#[0-9]+$/
export function isAstId(value: string): value is AstId {
return /ast:[A-Za-z]*#[0-9]*/.test(value)
return astIdRegex.test(value)
}
export const ROOT_ID = `Root` as AstId
export function assertAstId(value: string): asserts value is AstId {
assert(isAstId(value), `Incorrect AST ID: ${value}`)
}
export const ROOT_ID = newAstId('Root', 0)
class UpdateBuilder {
readonly nodesAdded = new Set<AstId>()

101
app/gui2/shortcuts.md Normal file
View File

@ -0,0 +1,101 @@
## General Assumptions
#### The <kbd>Meta</kbd> key.
The <kbd>Meta</kbd> key was introduced to make the shortcuts consistent across
platforms. It is defined as <kbd>Command ⌘</kbd> on macOS, and as <kbd>Ctrl</kbd>
on Windows and Linux.
#### Mouse Buttons
Shortcuts are designed to work well with both the mouse and the touchpad.
- <kbd>LMB</kbd> corresponds to Left Mouse Button
- <kbd>MMB</kbd> corresponds to Middle Mouse Button
- <kbd>RMB</kbd> corresponds to Right Mouse Button
## Graph Editor
#### General Shortcuts
| Shortcut | Action |
| --------------------------------------------------------------------------------- | ------------------------------ |
| <kbd>Escape</kbd> | Cancel current interaction |
| <kbd>Meta</kbd>+<kbd>`</kbd> | Show/hide Code Editor |
| <kbd>Meta</kbd>+<kbd>D</kbd> | Show/hide Documentation Editor |
| <kbd>Meta</kbd>+<kbd>,</kbd> | Show Settings |
| <kbd>Meta</kbd>+<kbd>/</kbd> | Show About Window |
| <kbd>Meta</kbd>+<kbd>Z</kbd> | Undo last action |
| <kbd>Meta</kbd>+<kbd>Y</kbd> or <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd> | Redo last undone action |
#### Navigation
| Shortcut | Action |
| ------------------------------------------------- | ------------------------------------ |
| Drag gesture (two fingers) | Pan the scene. |
| Pinch gesture (two fingers) | Zoom the scene. |
| <kbd>MMB</kbd> drag | Pan the scene. |
| <kbd>RMB</kbd> drag | Zoom the scene. |
| <kbd>LMB</kbd> double press component | Step into the component. |
| <kbd>LMB</kbd> double press background | Step out of the current component. |
| <kbd>Meta</kbd>+<kbd>E</kbd> | Step in the last selected component. |
| <kbd>Meta</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd> | Step out of the current component. |
| <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>A</kbd> | Zoom to selected components. |
#### Component Layout
| Shortcut | Action |
| ------------------------------------------ | ---------------------------------------------------------------------- |
| <kbd>LMB</kbd> drag non-selected component | Move the component to new position (dragging do not modify selection). |
| <kbd>LMB</kbd> drag selected component | Move all selected components the component to new positions. |
#### Component Selection
| Shortcut | Action |
| ------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| <kbd>LMB</kbd> click component | Deselect all components. Select the target component. |
| <kbd>LMB</kbd> click background | Deselect all components. |
| <kbd>LMB</kbd> drag background | Select components using selection-box. |
| <kbd>Shift</kbd> + <kbd>LMB</kbd> click component | Add / remove component to the selection group. |
| <kbd>Shift</kbd> + <kbd>LMB</kbd> drag background | Add / remove components to the selection group. |
| <kbd>Meta</kbd> + <kbd>A</kbd> | Select all components. |
| <kbd>Escape</kbd> | Deselect all components (if not in a mode, like edit mode). |
| <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>LMB</kbd> click component | Add component to the selection group. |
| <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>LMB</kbd> drag background | Add components to the selection group. |
| <kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>LMB</kbd> click component | Remove component to the selection group. |
| <kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>LMB</kbd> drag background | Remove components to the selection group. |
| <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>LMB</kbd> click component | Inverse component selection. |
| <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>LMB</kbd> drag background | Inverse components selection. |
#### Component Browser
| Shortcut | Action |
| ------------------------------------------------ | ----------------------------------------------------------------------------- |
| <kbd>Enter</kbd> | Open component browser |
| <kbd>Tab</kbd> | Apply currently selected suggestion (insert it without finishing the editing) |
| <kbd>Enter</kbd> or <kbd>LMB</kbd> on suggestion | Accept currently selected suggestion (insert it and finish editing) |
| <kbd>Meta</kbd> + <kbd>Enter</kbd> | Accept raw input and finish editing |
| <kbd></kbd> | Move selection up |
| <kbd></kbd> | Move selection down |
#### Component Editing
| Shortcut | Action |
| ------------------------------------------------- | ------------------------------------- |
| <kbd>Meta</kbd> + <kbd>C</kbd> | Copy selected components. |
| <kbd>Meta</kbd> + <kbd>V</kbd> | Paste copied components. |
| <kbd>BackSpace</kbd> or <kbd>Delete</kbd> | Remove selected components. |
| <kbd>Meta</kbd>+<kbd>G</kbd> | Collapse (group) selected components. |
| <kbd>Meta</kbd>+<kbd>LMB</kbd> | Start editing component expression. |
| <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>C</kbd> | Change color of selected components. |
#### Visualization
| Shortcut | Action |
| ----------------------------------- | ----------------------------------------------------------------- |
| <kbd>Space</kbd> | Toggle visualization visibility of the selected component. |
| <kbd>Meta</kbd> hold | Preview visualization of the hovered component (hide on release). |
| <kbd>Shift</kbd> + <kbd>Space</kbd> | Toggle visualization fullscreen mode. |
| <kbd>Escape</kbd> | Exit visualization fullscreen mode. |
| <kbd>Meta</kbd> + <kbd>Space</kbd> | Cycle visualizations of the selected component. |
| <kbd>F1</kbd> | Open documentation view |

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import HelpScreen from '@/components/HelpScreen.vue'
import { provideAppClassSet } from '@/providers/appClass'
import { provideBackend } from '@/providers/backend'
import { provideEventLogger } from '@/providers/eventLogging'
import { provideGuiConfig } from '@/providers/guiConfig'
import { registerAutoBlurHandler } from '@/util/autoBlur'
@ -13,7 +14,8 @@ import {
} from '@/util/config'
import ProjectView from '@/views/ProjectView.vue'
import { useEventListener } from '@vueuse/core'
import { computed, toRef, watch } from 'vue'
import type Backend from 'enso-common/src/services/Backend'
import { computed, markRaw, toRaw, toRef, watch } from 'vue'
import TooltipDisplayer from './components/TooltipDisplayer.vue'
import { provideTooltipRegistry } from './providers/tooltipState'
import { initializePrefixes } from './util/ast/node'
@ -26,8 +28,11 @@ const props = defineProps<{
hidden: boolean
ignoreParamsRegex?: RegExp
renameProject: (newName: string) => void
backend: Backend
}>()
provideBackend(() => markRaw(toRaw(props.backend)))
const classSet = provideAppClassSet()
const appTooltips = provideTooltipRegistry()

View File

@ -699,6 +699,10 @@
<path d="M6 1.33334C5.74749 1.33334 5.51664 1.47601 5.40372 1.70187L2.07038 8.36854C1.96705 8.57519 1.9781 8.82062 2.09957 9.01716C2.22104 9.21371 2.43562 9.33334 2.66667 9.33334H5.14615L4.01991 13.8383C3.94834 14.1246 4.07348 14.4239 4.32752 14.574C4.58156 14.7241 4.90407 14.6893 5.1203 14.4885L14.4536 5.82187C14.6545 5.63533 14.7207 5.34484 14.6205 5.08967C14.5203 4.83449 14.2741 4.66668 14 4.66668H11.1775L12.5717 2.34301C12.6952 2.13705 12.6985 1.88055 12.5801 1.67154C12.4618 1.46254 12.2402 1.33334 12 1.33334H6Z" fill="currentColor"></path>
</symbol>
<symbol id="paragraph" viewBox="0 0 16 16" width="16" height="16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.83334 5.33333C1.83334 3.49238 3.32573 2 5.16668 2H13.3333C13.7015 2 14 2.29848 14 2.66667C14 3.03486 13.7015 3.33333 13.3333 3.33333H10.6667V13.3333C10.6667 13.7015 10.3682 14 10 14C9.63182 14 9.33334 13.7015 9.33334 13.3333V3.33333H7.33334V13.3333C7.33334 13.7015 7.03487 14 6.66668 14C6.29849 14 6.00001 13.7015 6.00001 13.3333V8.66667H5.16668C3.32573 8.66667 1.83334 7.17428 1.83334 5.33333ZM6.00001 7.33333V3.33333H5.16668C4.06211 3.33333 3.16668 4.22876 3.16668 5.33333C3.16668 6.4379 4.06211 7.33333 5.16668 7.33333H6.00001Z" fill="currentColor"></path>
</symbol>
<symbol id="parse" viewBox="0 0 16 16" width="16" height="16" fill="none">
<rect x="2" y="3" width="10" height="1" fill="currentColor"></rect>
<path d="M12 1L16 3.5L12 6V1Z" fill="currentColor"></path>

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 147 KiB

View File

@ -90,8 +90,8 @@ function readableBinding(binding: keyof (typeof graphBindings)['bindings']) {
/>
<ToggleIcon
icon="record"
class="overrideRecordingButton slot7"
data-testid="overrideRecordingButton"
class="slot7 record"
data-testid="toggleRecord"
title="Record"
:modelValue="props.isRecordingOverridden"
@update:modelValue="emit('update:isRecordingOverridden', $event)"
@ -201,16 +201,6 @@ function readableBinding(binding: keyof (typeof graphBindings)['bindings']) {
opacity: 10%;
}
.overrideRecordingButton {
&.toggledOn {
opacity: 100%;
color: red;
}
&.toggledOff {
opacity: unset;
}
}
/**
* The following styles are used to position the icons in a circular pattern. The slots are named slot1 to slot8 and
* are positioned using absolute positioning. The slots are positioned in a circle with slot1 at the top and the rest

View File

@ -187,12 +187,13 @@ onUnmounted(() => {
// Compute edge, except for the color. The color is set in a separate watch, as it changes more often.
watchEffect(() => {
const sourceIdent = input.selfArgument.value
const sourceNode = sourceIdent != null && graphStore.db.getIdentDefiningNode(sourceIdent)
if (!sourceNode) {
const sourceNode =
sourceIdent != null ? graphStore.db.getIdentDefiningNode(sourceIdent) : undefined
const source = graphStore.db.getNodeFirstOutputPort(sourceNode)
if (!source) {
graphStore.cbEditedEdge = undefined
return
}
const source = graphStore.db.getNodeFirstOutputPort(sourceNode)
const scenePos = originScenePos.value.add(
new Vec2(COMPONENT_EDITOR_PADDING + ICON_WIDTH / 2, 0).scale(clientToSceneFactor.value),
)

View File

@ -1,5 +1,5 @@
import { useComponentBrowserInput } from '@/components/ComponentBrowser/input'
import { asNodeId, GraphDb } from '@/stores/graph/graphDatabase'
import { asNodeId, GraphDb, type NodeId } from '@/stores/graph/graphDatabase'
import type { RequiredImport } from '@/stores/graph/imports'
import { ComputedValueRegistry } from '@/stores/project/computedValueRegistry'
import { SuggestionDb } from '@/stores/suggestionDatabase'
@ -14,24 +14,21 @@ import {
makeType,
type SuggestionEntry,
} from '@/stores/suggestionDatabase/entry'
import type { AstId } from '@/util/ast/abstract'
import { unwrap } from '@/util/data/result'
import { tryIdentifier, tryQualifiedName } from '@/util/qualifiedName'
import { initializeFFI } from 'shared/ast/ffi'
import { assertUnreachable } from 'shared/util/assert'
import type { ExternalId, Uuid } from 'shared/yjsModel'
import { expect, test } from 'vitest'
await initializeFFI()
const aiMock = { query: assertUnreachable }
const operator1Id = '3d0e9b96-3ca0-4c35-a820-7d3a1649de55' as AstId
const operator1ExternalId = operator1Id as Uuid as ExternalId
const operator2Id = '5eb16101-dd2b-4034-a6e2-476e8bfa1f2b' as AstId
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(operator1ExternalId, {
computedValueRegistryMock.db.set(operator1Id, {
typename: 'Standard.Base.Number',
methodCall: undefined,
payload: { type: 'Value' },

View File

@ -26,7 +26,7 @@ const toggleDocumentationEditorShortcut = documentationEditorBindings.bindings.t
<template>
<DropdownMenu v-model:open="open" placement="bottom-end" class="ExtendedMenu">
<template #button
><SvgIcon name="3_dot_menu" class="moreIcon" title="Additional Options"
><SvgButton name="3_dot_menu" class="moreIcon" title="Additional Options"
/></template>
<template #entries>
<div>

View File

@ -39,7 +39,6 @@ import { provideInteractionHandler } from '@/providers/interactionHandler'
import { provideKeyboard } from '@/providers/keyboard'
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
import { provideGraphStore, type NodeId } from '@/stores/graph'
import { asNodeId } from '@/stores/graph/graphDatabase'
import type { RequiredImport } from '@/stores/graph/imports'
import { useProjectStore } from '@/stores/project'
import { provideSuggestionDbStore } from '@/stores/suggestionDatabase'
@ -211,8 +210,6 @@ const nodeSelection = provideGraphSelection(
graphStore.isPortEnabled,
{
isValid: (id) => graphStore.db.nodeIdToNode.has(id),
pack: (id) => graphStore.db.nodeIdToNode.get(id)?.rootExpr.externalId,
unpack: (eid) => asNodeId(graphStore.db.idFromExternal(eid)),
onSelected: (id) => graphStore.db.moveNodeToTop(id),
},
)
@ -518,6 +515,7 @@ function clearFocus() {
function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[]) {
const sourcePort = graphStore.db.getNodeFirstOutputPort(sourceNode)
if (sourcePort == null) return
const sourcePortAst = graphStore.viewModule.get(sourcePort)
const [toCommit, toEdit] = partition(options, (opts) => opts.commit)
createNodes(
@ -565,25 +563,25 @@ function collapseNodes() {
}
const selectedNodeRects = filterDefined(Array.from(selected, graphStore.visibleArea))
graphStore.edit((edit) => {
const { refactoredNodeId, collapsedNodeIds, outputNodeId } = performCollapse(
const { refactoredExpressionAstId, collapsedNodeIds, outputNodeId } = performCollapse(
info.value,
edit.getVersion(topLevel),
graphStore.db,
currentMethodName,
)
const position = collapsedNodePlacement(selectedNodeRects)
edit.get(refactoredNodeId).mutableNodeMetadata().set('position', position.xy())
edit.get(refactoredExpressionAstId).mutableNodeMetadata().set('position', position.xy())
if (outputNodeId != null) {
const collapsedNodeRects = filterDefined(
Array.from(collapsedNodeIds, graphStore.visibleArea),
)
const { place } = usePlacement(collapsedNodeRects, graphNavigator.viewport)
const position = place(collapsedNodeRects)
edit.get(outputNodeId).mutableNodeMetadata().set('position', position.xy())
edit.get(refactoredExpressionAstId).mutableNodeMetadata().set('position', position.xy())
}
})
} catch (err) {
console.log('Error while collapsing, this is not normal.', err)
console.error('Error while collapsing, this is not normal.', err)
}
}
@ -783,7 +781,7 @@ const groupColors = computed(() => {
will-change: transform;
}
::selection {
.layer.nodes:deep(::selection) {
background-color: rgba(255, 255, 255, 20%);
}
</style>

View File

@ -20,10 +20,10 @@ import SmallPlusButton from '@/components/SmallPlusButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { useDoubleClick } from '@/composables/doubleClick'
import { usePointer, useResizeObserver } from '@/composables/events'
import { useKeyboard } from '@/composables/keyboard'
import { injectGraphNavigator } from '@/providers/graphNavigator'
import { injectNodeColors } from '@/providers/graphNodeColors'
import { injectGraphSelection } from '@/providers/graphSelection'
import { injectKeyboard } from '@/providers/keyboard'
import { useGraphStore, type Node } from '@/stores/graph'
import { asNodeId } from '@/stores/graph/graphDatabase'
import { useProjectStore } from '@/stores/project'
@ -83,7 +83,7 @@ const projectStore = useProjectStore()
const graph = useGraphStore()
const navigator = injectGraphNavigator(true)
const nodeId = computed(() => asNodeId(props.node.rootExpr.id))
const nodeId = computed(() => asNodeId(props.node.rootExpr.externalId))
const potentialSelfArgumentId = computed(() => props.node.primarySubject)
const connectedSelfArgumentId = computed(() =>
potentialSelfArgumentId.value && graph.isConnectedTarget(potentialSelfArgumentId.value) ?
@ -100,9 +100,8 @@ const nodeSize = useResizeObserver(rootNode)
function inputExternalIds() {
const externalIds = new Array<ExternalId>()
for (const inputId of graph.db.nodeDependents.reverseLookup(nodeId.value)) {
const externalId = graph.db.idToExternal(inputId)
if (externalId) {
externalIds.push(externalId)
if (inputId) {
externalIds.push(inputId)
}
}
return externalIds
@ -123,7 +122,7 @@ interface Message {
alwaysShow: boolean
}
const availableMessage = computed<Message | undefined>(() => {
const externalId = graph.db.idToExternal(nodeId.value)
const externalId = nodeId.value
if (!externalId) return undefined
const info = projectStore.computedValueRegistry.db.get(externalId)
switch (info?.payload.type) {
@ -224,7 +223,7 @@ function openFullMenu() {
const isDocsVisible = ref(false)
const outputHovered = ref(false)
const keyboard = useKeyboard()
const keyboard = injectKeyboard()
const visualizationWidth = computed(() => props.node.vis?.width ?? null)
const visualizationHeight = computed(() => props.node.vis?.height ?? null)
const isVisualizationEnabled = computed(() => props.node.vis?.visible ?? false)
@ -441,11 +440,11 @@ watchEffect(() => {
<Teleport :to="graphNodeSelections">
<GraphNodeSelection
v-if="navigator && !edited"
:data-node-id="nodeId"
:nodePosition="props.node.position"
:nodeSize="graphSelectionSize"
:class="{ draggable: true, dragged: isDragged }"
:selected
:nodeId
:color
:externalHovered="nodeHovered"
@visible="selectionVisible = $event"
@ -556,7 +555,7 @@ watchEffect(() => {
</svg>
<SmallPlusButton
v-if="menuVisible && isVisualizationVisible"
class="below-viz"
class="afterNode"
@createNodes="emit('createNodes', $event)"
/>
</div>
@ -666,6 +665,7 @@ watchEffect(() => {
position: absolute;
top: 100%;
margin-top: 4px;
transform: translateY(var(--viz-below-node));
}
.messageWithMenu {
@ -706,11 +706,4 @@ watchEffect(() => {
.dragged {
cursor: grabbing !important;
}
.below-viz {
position: absolute;
top: 100%;
transform: translateY(var(--viz-below-node));
margin-top: 4px;
}
</style>

View File

@ -2,6 +2,7 @@
import { useApproach } from '@/composables/animation'
import { useDoubleClick } from '@/composables/doubleClick'
import { useGraphStore, type NodeId } from '@/stores/graph'
import { isDef } from '@vueuse/core'
import { setIfUndefined } from 'lib0/map'
import type { AstId } from 'shared/ast'
import {
@ -35,7 +36,10 @@ interface PortData {
const outputPortsSet = computed(() => {
const bindings = graph.db.nodeOutputPorts.lookup(props.nodeId)
if (bindings.size === 0) return new Set([props.nodeId])
if (bindings.size === 0) {
const astId = graph.db.idFromExternal(props.nodeId)
return new Set([astId].filter(isDef))
}
return bindings
})

View File

@ -1,12 +1,10 @@
<script lang="ts" setup>
import { Vec2 } from '@/util/data/vec2'
import type { AstId } from 'shared/ast'
import { computed, ref, watchEffect } from 'vue'
const props = defineProps<{
nodePosition: Vec2
nodeSize: Vec2
nodeId: AstId
selected: boolean
externalHovered: boolean
color: string
@ -37,7 +35,6 @@ const rootStyle = computed(() => {
class="GraphNodeSelection"
:class="{ visible, selected: props.selected }"
:style="rootStyle"
:data-node-id="props.nodeId"
@pointerenter="hovered = true"
@pointerleave="hovered = false"
/>

View File

@ -64,7 +64,7 @@ function handleWidgetUpdates(update: WidgetUpdate) {
: value == null ? Ast.Wildcard.new(edit)
: undefined
if (ast) {
edit.replaceValue(origin as Ast.AstId, ast)
edit.replaceValue(origin, ast)
} else if (typeof value === 'string') {
edit.tryGet(origin)?.syncToCode(value)
}

View File

@ -152,6 +152,7 @@ const COLLAPSED_FUNCTION_NAME = 'collapsed' as IdentifierOrOperatorIdentifier
interface CollapsingResult {
/** The ID of the node refactored to the collapsed function call. */
refactoredNodeId: NodeId
refactoredExpressionAstId: Ast.AstId
/** IDs of nodes inside the collapsed function, except the output node.
* The order of these IDs is reversed comparing to the order of nodes in the source code.
*/
@ -177,11 +178,11 @@ export function performCollapse(
[...info.extracted.ids].map((nodeId) => db.nodeIdToNode.get(nodeId)?.outerExpr.id),
)
const astIdToReplace = db.nodeIdToNode.get(info.refactored.id)?.outerExpr.id
const { ast: refactoredAst, nodeId: refactoredNodeId } = collapsedCallAst(
info,
collapsedName,
edit,
)
const {
ast: refactoredAst,
nodeId: refactoredNodeId,
expressionAstId: refactoredExpressionAstId,
} = collapsedCallAst(info, collapsedName, edit)
const collapsed: Ast.Owned[] = []
// Update the definition of the refactored function.
functionBlock.updateLines((lines) => {
@ -203,20 +204,20 @@ export function performCollapse(
// Insert a new function.
const collapsedNodeIds = collapsed
.map((ast) => asNodeId(nodeFromAst(ast)?.rootExpr.id ?? ast.id))
.map((ast) => asNodeId(nodeFromAst(ast)?.rootExpr.externalId ?? ast.externalId))
.reverse()
let outputNodeId: NodeId | undefined
const outputIdentifier = info.extracted.output?.identifier
if (outputIdentifier != null) {
const ident = Ast.Ident.new(edit, outputIdentifier)
collapsed.push(ident)
outputNodeId = asNodeId(ident.id)
outputNodeId = asNodeId(ident.externalId)
}
const argNames = info.extracted.inputs
const collapsedFunction = Ast.Function.fromStatements(edit, collapsedName, argNames, collapsed)
const collapsedFunctionWithIcon = Ast.Documented.new('ICON group', collapsedFunction)
topLevel.insert(posToInsert, collapsedFunctionWithIcon, undefined)
return { refactoredNodeId, collapsedNodeIds, outputNodeId }
return { refactoredNodeId, refactoredExpressionAstId, collapsedNodeIds, outputNodeId }
}
/** Prepare a method call expression for collapsed method. */
@ -224,14 +225,14 @@ function collapsedCallAst(
info: CollapsedInfo,
collapsedName: IdentifierOrOperatorIdentifier,
edit: Ast.MutableModule,
): { ast: Ast.Owned; nodeId: NodeId } {
): { ast: Ast.Owned; expressionAstId: Ast.AstId; nodeId: NodeId } {
const pattern = info.refactored.pattern
const args = info.refactored.arguments
const functionName = `${MODULE_NAME}.${collapsedName}`
const expression = functionName + (args.length > 0 ? ' ' : '') + args.join(' ')
const expressionAst = Ast.parse(expression, edit)
const ast = Ast.Assignment.new(edit, pattern, expressionAst)
return { ast, nodeId: asNodeId(expressionAst.id) }
return { ast, expressionAstId: expressionAst.id, nodeId: asNodeId(expressionAst.externalId) }
}
/** Find the position before the current method to insert a collapsed one. */

View File

@ -19,6 +19,7 @@ const targetMaybePort = computed(() => {
const target = application.value.target
if (target instanceof Ast.Ast) {
const input = WidgetInput.FromAst(target)
input.forcePort = true
if (!application.value.calledFunction) return input
const ptr = entryMethodPointer(application.value.calledFunction)
if (!ptr) return input

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import {
CustomDropdownItemsKey,
type CustomDropdownItem,
} from '@/components/GraphEditor/widgets/WidgetSelection.vue'
import FileBrowserWidget from '@/components/widgets/FileBrowserWidget.vue'
import { injectBackend } from '@/providers/backend'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast'
import { ArgumentInfoKey } from '@/util/callTree'
import { BackendType } from 'enso-common/src/services/Backend'
import { computed, h } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
const item: CustomDropdownItem = {
label: 'Choose file from cloud...',
onClick: ({ setActivity, close }) => {
setActivity(
h(FileBrowserWidget, {
onPathSelected: (path: string) => {
props.onUpdate({
portUpdate: { value: Ast.TextLiteral.new(path), origin: props.input.portId },
})
close()
},
}),
)
},
}
const innerWidgetInput = computed(() => {
const existingItems = props.input[CustomDropdownItemsKey] ?? []
return {
...props.input,
[CustomDropdownItemsKey]: [...existingItems, item],
}
})
</script>
<script lang="ts">
const FILE_MODULE = 'Standard.Base.System.File'
const FILE_TYPE = FILE_MODULE + '.File'
const WRITABLE_FILE_MODULE = 'Standard.Base.System.File.Generic.Writable_File'
const WRITABLE_FILE_TYPE = WRITABLE_FILE_MODULE + '.Writable_File'
export const widgetDefinition = defineWidget(
WidgetInput.isAstOrPlaceholder,
{
priority: 49,
score: (props) => {
const backend = injectBackend(true)?.backend
if (backend?.type !== BackendType.remote) return Score.Mismatch
if (props.input.dynamicConfig?.kind === 'File_Browse') return Score.Perfect
const reprType = props.input[ArgumentInfoKey]?.info?.reprType
if (reprType?.includes(FILE_TYPE) || reprType?.includes(WRITABLE_FILE_TYPE))
return Score.Perfect
return Score.Mismatch
},
},
import.meta.hot,
)
</script>
<template>
<div class="WidgetCloudBrowser">
<NodeWidget :input="innerWidgetInput" />
</div>
</template>

View File

@ -78,9 +78,11 @@ function handleArgUpdate(update: WidgetUpdate): boolean {
// Find the updated argument by matching origin port/expression with the appropriate argument.
// We are interested only in updates at the top level of the argument AST. Updates from nested
// widgets do not need to be processed at the function application level.
const argApp = [...app.iterApplications()].find(
const applications = [...app.iterApplications()]
const argAppIndex = applications.findIndex(
(app) => 'portId' in app.argument && app.argument.portId === origin,
)
const argApp = applications[argAppIndex]
// Perform appropriate AST update, either insertion or deletion.
if (value != null && argApp?.argument instanceof ArgumentPlaceholder) {
@ -112,13 +114,18 @@ function handleArgUpdate(update: WidgetUpdate): boolean {
// Proper fix would involve adding a proper "optimistic response" mechanism that can also be
// saved in the undo transaction.
const deletedArgIdx = argApp.argument.index
if (deletedArgIdx != null) {
const notAppliedArguments = methodCallInfo.value?.methodCall.notAppliedArguments
if (deletedArgIdx != null && methodCallInfo.value) {
// Grab original expression info data straight from DB, so we modify the original state.
const notAppliedArguments = graph.db.getExpressionInfo(
methodCallInfo.value.methodCallSource,
)?.methodCall?.notAppliedArguments
if (notAppliedArguments != null) {
const insertAt = partitionPoint(notAppliedArguments, (i) => i < deletedArgIdx)
// Insert the deleted argument back to the method info. This directly modifies observable
// data in `ComputedValueRegistry`. That's on purpose.
notAppliedArguments.splice(insertAt, 0, deletedArgIdx)
if (notAppliedArguments[insertAt] != deletedArgIdx) {
// Insert the deleted argument back to the method info. This directly modifies observable
// data in `ComputedValueRegistry`. That's on purpose.
notAppliedArguments.splice(insertAt, 0, deletedArgIdx)
}
}
}
@ -137,7 +144,7 @@ function handleArgUpdate(update: WidgetUpdate): boolean {
},
})
return true
} else if (value == null && argApp.appTree instanceof Ast.OprApp) {
} else if (argApp.appTree instanceof Ast.OprApp) {
/* Case: Removing infix application. */
// Infix application is removed as a whole. Only the target is kept.
@ -160,12 +167,17 @@ function handleArgUpdate(update: WidgetUpdate): boolean {
// Traverse the application chain, starting from the outermost application and going
// towards the innermost target.
for (let innerApp of [...app.iterApplications()]) {
for (let innerApp of applications) {
if (innerApp.appTree.id === argApp.appTree.id) {
// Found the application with the argument to remove. Skip the argument and use the
// application target's code. This is the final iteration of the loop.
const appTree = edit.getVersion(argApp.appTree)
appTree.replace(appTree.function.take())
if (graph.db.isNodeId(appTree.externalId)) {
// If the modified application is a node root, preserve its identity and metadata.
appTree.replaceValue(appTree.function.take())
} else {
appTree.replace(appTree.function.take())
}
props.onUpdate({ edit })
return true
} else {

View File

@ -72,6 +72,7 @@ test.each`
if (astId === id('entireFunction')) {
return {
suggestion: callSuggestion,
methodCallSource: astId,
methodCall: {
notAppliedArguments: [],
methodPointer: entryMethodPointer(callSuggestion)!,

View File

@ -84,6 +84,7 @@ export const widgetDefinition = defineWidget(
@update:modelValue="setValue"
@click.stop
@focus="editHandler.start()"
@blur="editHandler.end()"
@input="editHandler.edit($event)"
/>
</template>

View File

@ -27,7 +27,8 @@ import { arrayEquals } from '@/util/data/array'
import type { Opt } from '@/util/data/opt'
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import { autoUpdate, offset, shift, size, useFloating } from '@floating-ui/vue'
import { computed, proxyRefs, ref, watch, type ComponentInstance, type RendererNode } from 'vue'
import type { Ref, RendererNode, VNode } from 'vue'
import { computed, proxyRefs, ref, shallowRef, watch } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
const suggestions = useSuggestionDbStore()
@ -36,49 +37,57 @@ const graph = useGraphStore()
const tree = injectWidgetTree()
const widgetRoot = ref<HTMLElement>()
const dropdownElement = ref<ComponentInstance<typeof DropdownWidget>>()
const dropdownElement = ref<HTMLElement>()
const activityElement = ref<HTMLElement>()
const editedWidget = ref<string>()
const editedValue = ref<Ast.Owned | string | undefined>()
const isHovered = ref(false)
/** See @{link Actions.setActivity} */
const activity = shallowRef<VNode>()
// How much wider a dropdown can be than a port it is attached to, when a long text is present.
// Any text beyond that limit will receive an ellipsis and sliding animation on hover.
const MAX_DROPDOWN_OVERSIZE_PX = 150
const { floatingStyles } = useFloating(widgetRoot, dropdownElement, {
middleware: computed(() => {
return [
offset((state) => {
const NODE_HEIGHT = 32
return {
mainAxis: (NODE_HEIGHT - state.rects.reference.height) / 2,
}
}),
size(() => ({
elementContext: 'reference',
apply({ elements, rects, availableWidth }) {
const PORT_PADDING_X = 8
const screenOverflow = Math.max(
(rects.floating.width - availableWidth) / 2 + PORT_PADDING_X,
0,
)
const portWidth = rects.reference.width + PORT_PADDING_X * 2
function dropdownStyles(dropdownElement: Ref<HTMLElement | undefined>, limitWidth: boolean) {
return useFloating(widgetRoot, dropdownElement, {
middleware: computed(() => {
return [
offset((state) => {
const NODE_HEIGHT = 32
return {
mainAxis: (NODE_HEIGHT - state.rects.reference.height) / 2,
}
}),
size(() => ({
elementContext: 'reference',
apply({ elements, rects, availableWidth }) {
const PORT_PADDING_X = 8
const screenOverflow = Math.max(
(rects.floating.width - availableWidth) / 2 + PORT_PADDING_X,
0,
)
const portWidth = rects.reference.width + PORT_PADDING_X * 2
const minWidth = `${Math.max(portWidth - screenOverflow, 0)}px`
const maxWidth = `${portWidth + MAX_DROPDOWN_OVERSIZE_PX}px`
const minWidth = `${Math.max(portWidth - screenOverflow, 0)}px`
const maxWidth = limitWidth ? `${portWidth + MAX_DROPDOWN_OVERSIZE_PX}px` : null
Object.assign(elements.floating.style, { minWidth, maxWidth })
elements.floating.style.setProperty('--dropdown-max-width', maxWidth)
},
})),
// Try to keep the dropdown within node's bounds.
shift(() => (tree.nodeElement ? { boundary: tree.nodeElement } : {})),
shift(), // Always keep within screen bounds, overriding node bounds.
]
}),
whileElementsMounted: autoUpdate,
})
Object.assign(elements.floating.style, { minWidth, maxWidth })
elements.floating.style.setProperty('--dropdown-max-width', maxWidth)
},
})),
// Try to keep the dropdown within node's bounds.
shift(() => (tree.nodeElement ? { boundary: tree.nodeElement } : {})),
shift(), // Always keep within screen bounds, overriding node bounds.
]
}),
whileElementsMounted: autoUpdate,
})
}
const { floatingStyles } = dropdownStyles(dropdownElement, true)
const { floatingStyles: activityStyles } = dropdownStyles(activityElement, false)
class ExpressionTag {
private cachedExpressionAst: Ast.Ast | undefined
@ -129,7 +138,7 @@ class ExpressionTag {
class ActionTag {
constructor(
readonly label: string,
readonly onClick: () => void,
readonly onClick: (dropdownActions: Actions) => void,
) {}
static FromItem(item: CustomDropdownItem): ActionTag {
@ -261,13 +270,18 @@ watch(showArrow, (arrowShown) => {
}
})
function onClose() {
activity.value = undefined
}
const isMulti = computed(() => props.input.dynamicConfig?.kind === 'Multiple_Choice')
const dropDownInteraction = WidgetEditHandler.New('WidgetSelection', props.input, {
cancel: () => {},
end: () => {},
cancel: onClose,
end: onClose,
pointerdown: (e, _) => {
if (
targetIsOutside(e, unrefElement(dropdownElement)) &&
targetIsOutside(e, unrefElement(activityElement)) &&
targetIsOutside(e, unrefElement(widgetRoot))
) {
dropDownInteraction.end()
@ -303,10 +317,17 @@ function toggleDropdownWidget() {
else dropDownInteraction.cancel()
}
const dropdownActions: Actions = {
setActivity: (newActivity) => {
activity.value = newActivity
},
close: dropDownInteraction.end.bind(dropDownInteraction),
}
function onClick(clickedEntry: Entry, keepOpen: boolean) {
if (clickedEntry.tag instanceof ActionTag) clickedEntry.tag.onClick()
if (clickedEntry.tag instanceof ActionTag) clickedEntry.tag.onClick(dropdownActions)
else expressionTagClicked(clickedEntry.tag, clickedEntry.selected)
if (!(keepOpen || isMulti.value)) {
if (!(keepOpen || isMulti.value || activity.value)) {
// We cancel interaction instead of ending it to restore the old value in the inner widget;
// if we clicked already selected entry, there would be no AST change, thus the inner
// widget's content would not be updated.
@ -371,6 +392,8 @@ const arrowLocation = ref()
</script>
<script lang="ts">
import { CustomDropdownItemsKeySymbol as CustomDropdownItemsKey } from '@/util/symbols'
function isHandledByCheckboxWidget(parameter: SuggestionEntryArgument | undefined): boolean {
return (
parameter?.tagValues != null &&
@ -405,10 +428,22 @@ export interface CustomDropdownItem {
/** Displayed label. */
label: string
/** Action to perform when clicked. */
onClick: () => void
onClick: (dropdownActions: Actions) => void
}
export const CustomDropdownItemsKey: unique symbol = Symbol('CustomDropdownItems')
/** Actions a {@link CustomDropdownItem} may perform when clicked. */
export interface Actions {
/**
* Provide an alternative dialog to be rendered in place of the dropdown.
*
* For example, the {@link WidgetCloudBrowser} installs a custom entry that, when clicked,
* opens a file browser where the dropdown was.
*/
setActivity: (activity: VNode) => void
close: () => void
}
export { CustomDropdownItemsKey }
declare module '@/providers/widgetRegistry' {
export interface WidgetInput {
[CustomDropdownItemsKey]?: readonly CustomDropdownItem[]
@ -434,16 +469,24 @@ declare module '@/providers/widgetRegistry' {
</Teleport>
<SvgIcon v-else-if="showArrow" name="arrow_right_head_only" class="arrow" />
<Teleport v-if="tree.nodeElement" :to="tree.nodeElement">
<SizeTransition height :duration="100">
<DropdownWidget
v-if="dropDownInteraction.isActive()"
ref="dropdownElement"
:style="floatingStyles"
:color="'var(--node-color-primary)'"
:entries="entries"
@clickEntry="onClick"
/>
</SizeTransition>
<div ref="dropdownElement" :style="floatingStyles">
<SizeTransition height :duration="100">
<div v-if="dropDownInteraction.isActive() && activity == null">
<DropdownWidget
color="var(--node-color-primary)"
:entries="entries"
@clickEntry="onClick"
/>
</div>
</SizeTransition>
</div>
<div ref="activityElement" class="activityElement" :style="activityStyles">
<SizeTransition height :duration="100">
<div v-if="dropDownInteraction.isActive() && activity">
<component :is="activity" />
</div>
</SizeTransition>
</div>
</Teleport>
</div>
</template>
@ -467,4 +510,10 @@ declare module '@/providers/widgetRegistry' {
/* Prevent the parent from receiving a pointerout event if the mouse is over the arrow, which causes flickering. */
pointer-events: none;
}
.activityElement {
--background-color: var(--node-color-primary);
/* Above the circular menu. */
z-index: 26;
}
</style>

View File

@ -121,7 +121,7 @@ export const widgetDefinition = defineWidget(
justify-content: center;
align-items: center;
&:has(> .AutoSizedInput:focus) {
&:has(> :focus) {
outline: none;
background: var(--color-widget-focus);
}

View File

@ -6,6 +6,7 @@ import type { PortId } from '@/providers/portInfo'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
import { Ast } from '@/util/ast'
import { isAstId } from 'shared/ast'
import { computed, shallowRef, toRef, toValue, watchEffect, type WatchSource } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
@ -46,7 +47,7 @@ const navigator = injectGraphNavigator(true)
function useChildEditForwarding(input: WatchSource<Ast.Ast | unknown>) {
let editStarted = false
const childEdit = shallowRef<{ astId: Ast.AstId; editedValue: Ast.Owned | string }>()
const childEdit = shallowRef<{ origin: PortId; editedValue: Ast.Owned | string }>()
watchEffect(() => {
if (!editStarted && !childEdit.value) return
@ -55,7 +56,8 @@ function useChildEditForwarding(input: WatchSource<Ast.Ast | unknown>) {
const editedAst = Ast.copyIntoNewModule(inputValue)
if (childEdit.value) {
const module = editedAst.module
const ast = module.tryGet(childEdit.value.astId)
const origin = childEdit.value.origin
const ast = isAstId(origin) ? module.tryGet(origin) : undefined
if (ast) {
const replacement = childEdit.value.editedValue
ast.replace(typeof replacement === 'string' ? Ast.parse(replacement, module) : replacement)
@ -67,12 +69,11 @@ function useChildEditForwarding(input: WatchSource<Ast.Ast | unknown>) {
return {
childEnded: (origin: PortId) => {
if (childEdit.value?.astId === origin) childEdit.value = undefined
if (childEdit.value?.origin === origin) childEdit.value = undefined
},
edit: (origin: PortId, value: Ast.Owned | string) => {
// The ID is used to locate a subtree; if the port isn't identified by an AstId, the lookup will simply fail.
const astId = origin as Ast.AstId
childEdit.value = { astId, editedValue: value }
childEdit.value = { origin, editedValue: value }
},
}
}

View File

@ -49,11 +49,10 @@ function onClick() {
border-radius: var(--radius-full);
border: none;
transition: background-color 0.3s;
&:hover {
background-color: var(--color-menu-entry-hover-bg);
}
&:hover,
&:focus,
&:active {
background-color: var(--color-menu-entry-active-bg);
background-color: var(--color-menu-entry-hover-bg);
}
&.disabled {
cursor: default;

View File

@ -59,10 +59,6 @@ const project = useProjectStore()
}
}
.toggledOn {
color: #ba4c40;
}
.iconButton:active {
color: #ba4c40;
}

View File

@ -56,7 +56,7 @@ const emit = defineEmits<{
backdrop-filter: var(--blur-app-bg);
}
.toggledOff {
.toggledOff svg {
opacity: 0.6;
}

View File

@ -10,19 +10,17 @@ import type { Icon } from '@/util/iconName'
const props = defineProps<{
name: Icon | URLString
title?: string
}>()
</script>
<template>
<svg viewBox="0 0 16 16" preserveAspectRatio="xMidYMid slice">
<title v-if="title" v-text="title"></title>
<svg class="SvgIcon" viewBox="0 0 16 16" preserveAspectRatio="xMidYMid slice">
<use :href="props.name.includes(':') ? props.name : `${icons}#${props.name}`"></use>
</svg>
</template>
<style scoped>
svg {
svg.SvgIcon {
overflow: visible; /* Prevent slight cutting off icons that are using all available space. */
width: var(--icon-width, var(--icon-size, 16px));
height: var(--icon-height, var(--icon-size, 16px));

View File

@ -11,13 +11,13 @@ import SvgIcon from '@/components/SvgIcon.vue'
import type { Icon } from '@/util/iconName'
const toggledOn = defineModel<boolean>({ default: false })
const _props = defineProps<{ icon: Icon; label?: string }>()
const props = defineProps<{ icon: Icon; label?: string }>()
</script>
<template>
<MenuButton v-model="toggledOn" class="ToggleIcon">
<SvgIcon :name="icon" />
<div v-if="label" v-text="label" />
<div v-if="props.label" v-text="props.label" />
</MenuButton>
</template>
@ -27,7 +27,20 @@ const _props = defineProps<{ icon: Icon; label?: string }>()
gap: 4px;
}
.toggledOff {
.toggledOff svg {
opacity: 0.4;
}
:is(.toggledOff, .toggledOn):active svg {
opacity: 0.7;
}
.record {
&.toggledOn {
color: red;
}
&.toggledOff svg {
opacity: unset;
}
}
</style>

View File

@ -176,7 +176,6 @@ function $createAutoLinkNode_(
if (nodes.length === 1) {
let remainingTextNode: TextNode | undefined = nodes[0]!
let linkTextNode: TextNode | undefined
console.log('startIndex', startIndex)
if (startIndex === 0) {
;[linkTextNode, remainingTextNode] = remainingTextNode.splitText(endIndex)
} else {

View File

@ -39,7 +39,7 @@ export const defaultPreprocessor = [
'1000',
] as const
type Data = Error | Matrix | ObjectMatrix | UnknownTable | Excel_Workbook
type Data = number | string | Error | Matrix | ObjectMatrix | UnknownTable | Excel_Workbook
interface Error {
type: undefined
@ -330,7 +330,7 @@ function toField(name: string, valueType?: ValueType | null | undefined): ColDef
const menu = `<span ref="eMenu" class="ag-header-icon ag-header-cell-menu-button"> </span>`
const sort = `
<span ref="eFilter" class="ag-header-icon ag-header-label-icon ag-filter-icon" aria-hidden="true"></span>
<span ref="eSortOrder" class="ag-header-icon ag-sort-order" aria-hidden="true">1</span>
<span ref="eSortOrder" class="ag-header-icon ag-sort-order" aria-hidden="true"></span>
<span ref="eSortAsc" class="ag-header-icon ag-sort-ascending-icon" aria-hidden="true"></span>
<span ref="eSortDesc" class="ag-header-icon ag-sort-descending-icon" aria-hidden="true"></span>
<span ref="eSortNone" class="ag-header-icon ag-sort-none-icon" aria-hidden="true"></span>
@ -343,7 +343,6 @@ function toField(name: string, valueType?: ValueType | null | undefined): ColDef
field: name,
headerComponentParams: {
template,
enableSorting: true,
setAriaSort: () => {},
},
headerTooltip: displayValue ? displayValue : '',
@ -406,7 +405,7 @@ function toLinkField(fieldName: string): ColDef {
field: fieldName,
onCellDoubleClicked: (params) => createNode(params),
tooltipValueGetter: () => {
return `Double click to view this ${newNodeSelectorValues.value.tooltipValue} in a separate node`
return `Double click to view this ${newNodeSelectorValues.value.tooltipValue} in a separate component`
},
cellRenderer: (params: any) => `<a href='#'> ${params.value} </a>`,
}
@ -657,7 +656,7 @@ onUnmounted(() => {
v-text="limit"
></option>
</select>
<div v-if="showRowCount">
<template v-if="showRowCount">
<span
v-if="isRowCountSelectorVisible && isTruncated"
v-text="` of ${rowCount} rows (Sorting/Filtering disabled).`"
@ -665,7 +664,7 @@ onUnmounted(() => {
<span v-else-if="isRowCountSelectorVisible" v-text="' rows.'"></span>
<span v-else-if="rowCount === 1" v-text="'1 row.'"></span>
<span v-else v-text="`${rowCount} rows.`"></span>
</div>
</template>
</div>
<div ref="tableNode" class="scrollable ag-theme-alpine"></div>
</div>

View File

@ -0,0 +1,233 @@
<script setup lang="ts">
import LoadingSpinner from '@/components/LoadingSpinner.vue'
import SvgButton from '@/components/SvgButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { useBackendQuery, useBackendQueryPrefetching } from '@/composables/backend'
import type { ToValue } from '@/util/reactivity'
import type {
DirectoryAsset,
DirectoryId,
FileAsset,
FileId,
} from 'enso-common/src/services/Backend'
import Backend, { assetIsDirectory, assetIsFile } from 'enso-common/src/services/Backend'
import { computed, ref, toValue, watch } from 'vue'
const emit = defineEmits<{
pathSelected: [path: string]
}>()
const { prefetch, ensureQueryData } = useBackendQueryPrefetching()
// === Current Directory ===
interface Directory {
id: DirectoryId | null
title: string
}
const directoryStack = ref<Directory[]>([
{
id: null,
title: 'Cloud',
},
])
const currentDirectory = computed(() => directoryStack.value[directoryStack.value.length - 1]!)
// === Directory Contents ===
function listDirectoryArgs(params: ToValue<Directory | undefined>) {
return computed<Parameters<Backend['listDirectory']> | undefined>(() => {
const paramsValue = toValue(params)
return paramsValue ?
[
{
parentId: paramsValue.id,
filterBy: null,
labels: null,
recentProjects: false,
},
paramsValue.title,
]
: undefined
})
}
const { isPending, isError, data, error } = useBackendQuery(
'listDirectory',
listDirectoryArgs(currentDirectory),
)
const compareTitle = (a: { title: string }, b: { title: string }) => a.title.localeCompare(b.title)
const directories = computed(
() => data.value && data.value.filter<DirectoryAsset>(assetIsDirectory).sort(compareTitle),
)
const files = computed(
() => data.value && data.value.filter<FileAsset>(assetIsFile).sort(compareTitle),
)
const isEmpty = computed(() => directories.value?.length === 0 && files.value?.length === 0)
// === Selected File ===
interface File {
id: FileId
title: string
}
const selectedFile = ref<File>()
function getFileDetailsArgs(parameters: ToValue<File | undefined>) {
return computed<Parameters<Backend['getFileDetails']> | undefined>(() => {
const paramsValue = toValue(parameters)
return paramsValue ? [paramsValue.id, paramsValue.title] : undefined
})
}
const selectedFileDetails = useBackendQuery('getFileDetails', getFileDetailsArgs(selectedFile))
// === Prefetching ===
watch(directories, (directories) => {
// Prefetch directories to avoid lag when the user navigates, but only if we don't already have stale data.
// When the user opens a directory with stale data, it will refresh and the animation will show what files have
// changed since they last viewed.
for (const directory of directories ?? [])
ensureQueryData('listDirectory', listDirectoryArgs(directory))
})
watch(files, (files) => {
// Prefetch file info to avoid lag when the user makes a selection.
for (const file of files ?? []) prefetch('getFileDetails', getFileDetailsArgs(file))
})
// === Interactivity ===
function enterDir(dir: DirectoryAsset) {
directoryStack.value.push(dir)
}
function popTo(index: number) {
directoryStack.value.splice(index + 1)
}
function chooseFile(file: FileAsset) {
selectedFile.value = file
}
const isBusy = computed(
() => isPending.value || (selectedFile.value && selectedFileDetails.isPending.value),
)
const anyError = computed(() =>
isError.value ? error
: selectedFileDetails.isError.value ? selectedFileDetails.error
: undefined,
)
watch(selectedFileDetails.data, (details) => {
if (details) emit('pathSelected', details.file.path)
})
</script>
<template>
<div class="FileBrowserWidget">
<div class="directoryStack">
<TransitionGroup>
<template v-for="(directory, index) in directoryStack" :key="directory.id ?? 'root'">
<SvgIcon v-if="index > 0" name="arrow_right_head_only" />
<div
class="clickable"
:class="{ nonInteractive: index === directoryStack.length - 1 }"
@click.stop="popTo(index)"
v-text="directory.title"
></div>
</template>
</TransitionGroup>
</div>
<div v-if="isBusy" class="contents centerContent"><LoadingSpinner /></div>
<div v-else-if="anyError" class="contents centerContent">Error: {{ anyError }}</div>
<div v-else-if="isEmpty" class="contents centerContent">Directory is empty</div>
<div v-else :key="currentDirectory.id ?? 'root'" class="contents listing">
<TransitionGroup>
<div v-for="entry in directories" :key="entry.id">
<SvgButton :label="entry.title" name="folder" class="entry" @click="enterDir(entry)" />
</div>
<div v-for="entry in files" :key="entry.id">
<SvgButton :label="entry.title" name="text2" class="entry" @click="chooseFile(entry)" />
</div>
</TransitionGroup>
</div>
</div>
</template>
<style scoped>
.FileBrowserWidget {
--border-width: 2px;
--border-radius-inner: calc(var(--radius-default) - var(--border-width));
background-color: var(--background-color);
padding: var(--border-width);
border-radius: 0 0 var(--radius-default) var(--radius-default);
min-width: 400px;
min-height: 200px;
max-height: 600px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.directoryStack {
--transition-duration: 0.1s;
color: white;
padding: 2px;
gap: 2px;
background-color: var(--background-color);
display: flex;
align-items: center;
}
.contents {
flex: 1;
width: 100%;
background-color: var(--color-frame-selected-bg);
border-radius: 0 0 var(--border-radius-inner) var(--border-radius-inner);
}
.listing {
--transition-duration: 0.5s;
padding: 8px;
display: flex;
height: 100%;
flex-direction: column;
align-items: start;
justify-content: start;
gap: 8px;
}
.centerContent {
display: flex;
align-items: center;
justify-content: center;
}
.entry {
width: 100%;
justify-content: start;
}
.nonInteractive {
pointer-events: none;
}
.v-move,
.v-enter-active,
.v-leave-active {
transition: all var(--transition-duration) ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-leave-active {
position: absolute;
}
</style>

View File

@ -0,0 +1,65 @@
import { injectBackend } from '@/providers/backend'
import type { ToValue } from '@/util/reactivity'
import {
useQuery,
useQueryClient,
type UseQueryOptions,
type UseQueryReturnType,
} from '@tanstack/vue-query'
import type { BackendMethods } from 'enso-common/src/backendQuery'
import { backendBaseOptions, backendQueryKey } from 'enso-common/src/backendQuery'
import Backend from 'enso-common/src/services/Backend'
import { computed, toValue } from 'vue'
type ExtraOptions = Omit<UseQueryOptions, 'queryKey' | 'queryFn' | 'enabled' | 'networkMode'>
const noPersist = { meta: { persist: false } }
const noFresh = { staleTime: 0 }
const methodDefaultOptions: Partial<Record<BackendMethods, ExtraOptions>> = {
listDirectory: { ...noPersist, ...noFresh },
getFileDetails: { ...noPersist },
}
function backendQueryOptions<Method extends BackendMethods>(
method: Method,
args: ToValue<Parameters<Backend[Method]> | undefined>,
backend: Backend,
) {
return {
...backendBaseOptions(backend),
...(methodDefaultOptions[method] ?? {}),
queryKey: computed(() => {
const argsValue = toValue(args)
return argsValue ? backendQueryKey(backend, method, argsValue) : []
}),
queryFn: () => (backend[method] as any).apply(backend, toValue(args)!),
enabled: computed(() => !!toValue(args)),
}
}
export function useBackendQuery<Method extends BackendMethods>(
method: Method,
args: ToValue<Parameters<Backend[Method]> | undefined>,
): UseQueryReturnType<Awaited<ReturnType<Backend[Method]>>, Error> {
const { backend } = injectBackend()
return useQuery(backendQueryOptions(method, args, backend))
}
export function useBackendQueryPrefetching() {
const queryClient = useQueryClient()
const { backend } = injectBackend()
function prefetch<Method extends BackendMethods>(
method: Method,
args: ToValue<Parameters<Backend[Method]> | undefined>,
) {
return queryClient.prefetchQuery(backendQueryOptions(method, args, backend))
}
function ensureQueryData<Method extends BackendMethods>(
method: Method,
args: ToValue<Parameters<Backend[Method]> | undefined>,
): Promise<Awaited<ReturnType<Backend[Method]>>> {
return queryClient.ensureQueryData(backendQueryOptions(method, args, backend))
}
return { prefetch, ensureQueryData }
}

View File

@ -15,7 +15,6 @@ export function useKeyboard() {
state.shift.value = e.shiftKey
state.meta.value = e.metaKey
state.ctrl.value = e.ctrlKey
return false
}
useEvent(window, 'keydown', updateState, { capture: true })
useEvent(window, 'keyup', updateState, { capture: true })

View File

@ -187,7 +187,7 @@ export function useNodeCreation(
rhs.setNodeMetadata(options.metadata ?? {})
const assignment = Ast.Assignment.new(edit, ident, rhs)
afterCreation(edit, assignment, ident, options, identifiersRenameMap)
const id = asNodeId(rhs.id)
const id = asNodeId(rhs.externalId)
const rootExpression =
options.documentation != null ?
Ast.Documented.new(options.documentation, assignment)

View File

@ -1,5 +1,5 @@
import type { BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
import { type GraphStore } from '@/stores/graph'
import { type GraphStore, type NodeId } from '@/stores/graph'
import { type ProjectStore } from '@/stores/project'
import type { AstId } from '@/util/ast/abstract.ts'
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
@ -44,13 +44,8 @@ export function useStackNavigator(projectStore: ProjectStore, graphStore: GraphS
graphStore.updateState()
}
function enterNode(id: AstId) {
const externalId = graphStore.db.idToExternal(id)
if (externalId == null) {
console.debug("Cannot enter node that hasn't been committed yet.")
return
}
const expressionInfo = graphStore.db.getExpressionInfo(externalId)
function enterNode(id: NodeId) {
const expressionInfo = graphStore.db.getExpressionInfo(id)
if (expressionInfo == null || expressionInfo.methodCall == null) {
console.debug('Cannot enter node that has no method call.')
return
@ -65,7 +60,7 @@ export function useStackNavigator(projectStore: ProjectStore, graphStore: GraphS
console.debug('Cannot enter node that is not defined on current module.')
return
}
projectStore.executionContext.push(externalId)
projectStore.executionContext.push(id)
graphStore.updateState()
breadcrumbs.value = projectStore.executionContext.desiredStack.slice()
}

View File

@ -0,0 +1,18 @@
import type { ToValue } from '@/util/reactivity'
import type { QueryKey } from '@tanstack/vue-query'
import { computed, toValue } from 'vue'
export function useQueryOptions<Parameters, Data>(
parameters: ToValue<Parameters | undefined>,
queryKey: (parameters: Parameters) => QueryKey,
queryFn: (parameters: Parameters) => Data,
) {
return {
queryKey: computed(() => {
const paramsValue = toValue(parameters)
return paramsValue ? queryKey(paramsValue) : []
}),
queryFn: () => queryFn(toValue(parameters)!),
enabled: computed(() => !!toValue(parameters)),
}
}

View File

@ -8,8 +8,8 @@ import { isDevMode } from 'shared/util/detect'
import { lazyVueInReact } from 'veaury'
import { type App } from 'vue'
import type { EditorRunner } from 'enso-common/src/types'
import 'enso-dashboard/src/tailwind.css'
import type { EditorRunner } from '../../ide-desktop/lib/types/types'
import { AsyncApp } from './asyncApp'
const INITIAL_URL_KEY = `Enso-initial-url`

View File

@ -0,0 +1,11 @@
import { createContextStore } from '@/providers'
import type { ToValue } from '@/util/reactivity'
import type Backend from 'enso-common/src/services/Backend'
import { proxyRefs, toRef } from 'vue'
export { injectFn as injectBackend, provideFn as provideBackend }
const { provideFn, injectFn } = createContextStore('backend', (backend: ToValue<Backend>) =>
proxyRefs({
backend: toRef(backend),
}),
)

View File

@ -56,7 +56,7 @@ test('Reading graph from definition', () => {
node3Content: [65, 74] as [number, number],
}
const { ast, id, toRaw, getSpan } = parseWithSpans(code, spans)
const { ast, id, eid, toRaw, getSpan } = parseWithSpans(code, spans)
const db = GraphDb.Mock()
const expressions = Array.from(ast.statements())
@ -67,30 +67,30 @@ test('Reading graph from definition', () => {
db.readFunctionAst(func, rawFunc, code, getSpan, new Set())
expect(Array.from(db.nodeIdToNode.keys())).toEqual([
id('node1Content'),
id('node2Content'),
id('node3Content'),
eid('node1Content'),
eid('node2Content'),
eid('node3Content'),
])
expect(db.getExpressionNodeId(id('node1Content'))).toBe(id('node1Content'))
expect(db.getExpressionNodeId(id('node1LParam'))).toBe(id('node1Content'))
expect(db.getExpressionNodeId(id('node1RParam'))).toBe(id('node1Content'))
expect(db.getExpressionNodeId(id('node1Content'))).toBe(eid('node1Content'))
expect(db.getExpressionNodeId(id('node1LParam'))).toBe(eid('node1Content'))
expect(db.getExpressionNodeId(id('node1RParam'))).toBe(eid('node1Content'))
expect(db.getExpressionNodeId(id('node2Id'))).toBeUndefined()
expect(db.getExpressionNodeId(id('node2LParam'))).toBe(id('node2Content'))
expect(db.getExpressionNodeId(id('node2RParam'))).toBe(id('node2Content'))
expect(db.getPatternExpressionNodeId(id('node1Id'))).toBe(id('node1Content'))
expect(db.getExpressionNodeId(id('node2LParam'))).toBe(eid('node2Content'))
expect(db.getExpressionNodeId(id('node2RParam'))).toBe(eid('node2Content'))
expect(db.getPatternExpressionNodeId(id('node1Id'))).toBe(eid('node1Content'))
expect(db.getPatternExpressionNodeId(id('node1Content'))).toBeUndefined()
expect(db.getPatternExpressionNodeId(id('node2Id'))).toBe(id('node2Content'))
expect(db.getPatternExpressionNodeId(id('node2Id'))).toBe(eid('node2Content'))
expect(db.getPatternExpressionNodeId(id('node2RParam'))).toBeUndefined()
expect(db.getIdentDefiningNode('node1')).toBe(id('node1Content'))
expect(db.getIdentDefiningNode('node2')).toBe(id('node2Content'))
expect(db.getIdentDefiningNode('node1')).toBe(eid('node1Content'))
expect(db.getIdentDefiningNode('node2')).toBe(eid('node2Content'))
expect(db.getIdentDefiningNode('function')).toBeUndefined()
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(asNodeId(id('node1Content'))))).toBe(
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(asNodeId(eid('node1Content'))))).toBe(
'node1',
)
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(asNodeId(id('node2Content'))))).toBe(
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(asNodeId(eid('node2Content'))))).toBe(
'node2',
)
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(asNodeId(id('node1Id'))))).toBe(
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(asNodeId(eid('node1Id'))))).toBe(
'node1',
)
@ -103,11 +103,11 @@ test('Reading graph from definition', () => {
expect(Array.from(db.connections.lookup(id('node1Id')))).toEqual([id('node2LParam')])
// expect(db.getOutputPortIdentifier(id('parameter'))).toBe('a')
expect(db.getOutputPortIdentifier(id('node1Id'))).toBe('node1')
expect(Array.from(db.nodeDependents.lookup(asNodeId(id('node1Content'))))).toEqual([
id('node2Content'),
expect(Array.from(db.nodeDependents.lookup(asNodeId(eid('node1Content'))))).toEqual([
eid('node2Content'),
])
expect(Array.from(db.nodeDependents.lookup(asNodeId(id('node2Content'))))).toEqual([
id('node3Content'),
expect(Array.from(db.nodeDependents.lookup(asNodeId(eid('node2Content'))))).toEqual([
eid('node3Content'),
])
expect(Array.from(db.nodeDependents.lookup(asNodeId(id('node3Content'))))).toEqual([])
expect(Array.from(db.nodeDependents.lookup(asNodeId(eid('node3Content'))))).toEqual([])
})

View File

@ -22,6 +22,7 @@ import { reactive, ref, type Ref } from 'vue'
export interface MethodCallInfo {
methodCall: MethodCall
methodCallSource: Ast.AstId
suggestion: SuggestionEntry
}
@ -182,7 +183,7 @@ export class GraphDb {
private *connectionsFromBindings(
info: BindingInfo,
alias: AstId,
srcNode: AstId | undefined,
srcNode: NodeId | undefined,
): Generator<[AstId, AstId]> {
for (const usage of info.usages) {
const targetNode = this.getExpressionNodeId(usage)
@ -223,8 +224,8 @@ export class GraphDb {
)
})
getNodeFirstOutputPort(id: NodeId): AstId {
return set.first(this.nodeOutputPorts.lookup(id)) ?? id
getNodeFirstOutputPort(id: NodeId | undefined): AstId | undefined {
return id ? set.first(this.nodeOutputPorts.lookup(id)) ?? this.idFromExternal(id) : undefined
}
*getNodeUsages(id: NodeId): IterableIterator<AstId> {
@ -251,13 +252,13 @@ export class GraphDb {
return this.getPatternExpressionNodeId(binding)
}
getExpressionInfo(id: AstId | ExternalId): ExpressionInfo | undefined {
getExpressionInfo(id: AstId | ExternalId | undefined): ExpressionInfo | undefined {
const externalId = isUuid(id) ? id : this.idToExternal(id)
return externalId && this.valuesRegistry.getExpressionInfo(externalId)
}
getOutputPortIdentifier(source: AstId): string | undefined {
return this.bindings.bindings.get(source)?.identifier
getOutputPortIdentifier(source: AstId | undefined): string | undefined {
return source ? this.bindings.bindings.get(source)?.identifier : undefined
}
allIdentifiers(): string[] {
@ -272,6 +273,10 @@ export class GraphDb {
return this.nodeIdToNode.keys()
}
isNodeId(externalId: ExternalId): boolean {
return this.nodeIdToNode.has(asNodeId(externalId))
}
isKnownFunctionCall(id: AstId): boolean {
return this.getMethodCallInfo(id) != null
}
@ -295,7 +300,7 @@ export class GraphDb {
if (suggestionId == null) return
const suggestion = this.suggestionDb.get(suggestionId)
if (suggestion == null) return
return { methodCall, suggestion }
return { methodCall, methodCallSource: id, suggestion }
}
getNodeColorStyle(id: NodeId): string {
@ -344,7 +349,7 @@ export class GraphDb {
for (const nodeAst of functionAst_.bodyExpressions()) {
const newNode = nodeFromAst(nodeAst)
if (!newNode) continue
const nodeId = asNodeId(newNode.rootExpr.id)
const nodeId = asNodeId(newNode.rootExpr.externalId)
const node = this.nodeIdToNode.get(nodeId)
currentNodeIds.add(nodeId)
if (node == null) {
@ -400,6 +405,7 @@ export class GraphDb {
this.nodeIdToNode.delete(nodeId)
}
}
this.updateExternalIds(functionAst_)
this.bindings.readFunctionAst(functionAst_, rawFunction, moduleCode, getSpan)
return currentNodeIds
}
@ -419,8 +425,8 @@ export class GraphDb {
updateMap(this.idFromExternalMap, idFromExternalNew)
}
updateMetadata(id: Ast.AstId, changes: NodeMetadata) {
const node = this.nodeIdToNode.get(id as NodeId)
updateMetadata(astId: Ast.AstId, changes: NodeMetadata) {
const node = this.nodeByRootAstId(astId)
if (!node) return
const newPos = changes.get('position')
const newPosVec = newPos && new Vec2(newPos.x, newPos.y)
@ -434,9 +440,14 @@ export class GraphDb {
}
}
nodeByRootAstId(astId: Ast.AstId): Node | undefined {
const nodeId = asNodeId(this.idToExternal(astId))
return nodeId != null ? this.nodeIdToNode.get(nodeId) : undefined
}
/** Get the ID of the `Ast` corresponding to the given `ExternalId` as of the last synchronization. */
idFromExternal(id: ExternalId): AstId | undefined {
return this.idFromExternalMap.get(id)
idFromExternal(id: ExternalId | undefined): AstId | undefined {
return id ? this.idFromExternalMap.get(id) : id
}
/** Get the external ID corresponding to the given `AstId` as of the last synchronization.
*
@ -452,15 +463,15 @@ export class GraphDb {
* - If the data should be associated with the `Ast` that the engine was referring to, use `idToExternal`.
* Either choice is an approximation that will be used until the engine provides an update after processing the edit.
*/
idToExternal(id: AstId): ExternalId | undefined {
return this.idToExternalMap.get(id)
idToExternal(id: AstId | undefined): ExternalId | undefined {
return id ? this.idToExternalMap.get(id) : undefined
}
static Mock(registry = ComputedValueRegistry.Mock(), db = new SuggestionDb()): GraphDb {
return new GraphDb(db, ref([]), registry)
}
mockNode(binding: string, id: Ast.AstId, code?: string): Node {
mockNode(binding: string, id: NodeId, code?: string): Node {
const edit = MutableModule.Transient()
const pattern = Ast.parse(binding, edit)
const expression = Ast.parse(code ?? '0', edit)
@ -480,17 +491,19 @@ export class GraphDb {
zIndex: this.highestZIndex,
}
const bindingId = pattern.id
this.nodeIdToNode.set(asNodeId(id), node)
this.nodeIdToNode.set(id, node)
this.bindings.bindings.set(bindingId, { identifier: binding, usages: new Set() })
return node
}
}
declare const brandNodeId: unique symbol
export type NodeId = AstId & { [brandNodeId]: never }
export function asNodeId(id: Ast.AstId): NodeId
export function asNodeId(id: Ast.AstId | undefined): NodeId | undefined
export function asNodeId(id: Ast.AstId | undefined): NodeId | undefined {
/** An unique node identifier, shared across all clients. It is the ExternalId of node's root expression. */
export type NodeId = string & ExternalId & { [brandNodeId]: never }
export function asNodeId(id: ExternalId): NodeId
export function asNodeId(id: ExternalId | undefined): NodeId | undefined
export function asNodeId(id: ExternalId | undefined): NodeId | undefined {
return id != null ? (id as NodeId) : undefined
}

View File

@ -18,7 +18,7 @@ import { type SuggestionDbStore } from '@/stores/suggestionDatabase'
import { assert, bail } from '@/util/assert'
import { Ast } from '@/util/ast'
import type { AstId } from '@/util/ast/abstract'
import { MutableModule, isIdentifier, type Identifier } from '@/util/ast/abstract'
import { MutableModule, isAstId, isIdentifier, type Identifier } from '@/util/ast/abstract'
import { RawAst, visitRecursive } from '@/util/ast/raw'
import { partition } from '@/util/data/array'
import { Rect } from '@/util/data/rect'
@ -380,7 +380,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
}
function setNodePosition(nodeId: NodeId, position: Vec2) {
const nodeAst = syncModule.value?.tryGet(nodeId)
const nodeAst = syncModule.value?.tryGet(db.idFromExternal(nodeId))
if (!nodeAst) return
const oldPos = nodeAst.nodeMetadata.get('position')
if (oldPos?.x !== position.x || oldPos?.y !== position.y) {
@ -391,7 +391,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
}
function overrideNodeColor(nodeId: NodeId, color: string | undefined) {
const nodeAst = syncModule.value?.tryGet(nodeId)
const nodeAst = syncModule.value?.tryGet(db.idFromExternal(nodeId))
if (!nodeAst) return
editNodeMetadata(nodeAst, (metadata) => {
metadata.set('colorOverride', color)
@ -418,7 +418,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
}
function setNodeVisualization(nodeId: NodeId, vis: Partial<VisualizationMetadata>) {
const nodeAst = syncModule.value?.tryGet(nodeId)
const nodeAst = syncModule.value?.tryGet(db.idFromExternal(nodeId))
if (!nodeAst) return
editNodeMetadata(nodeAst, (metadata) => {
const data: Partial<VisualizationMetadata> = {
@ -452,7 +452,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
nodesToPlace.length = 0
batchEdits(() => {
for (const nodeId of nodesToProcess) {
const nodeAst = syncModule.value?.get(nodeId)
const nodeAst = syncModule.value?.get(db.idFromExternal(nodeId))
const rect = nodeRects.get(nodeId)
if (!rect || !nodeAst || nodeAst.nodeMetadata.get('position') != null) continue
const position = placeNode([], rect.size)
@ -515,7 +515,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
}
function getPortNodeId(id: PortId): NodeId | undefined {
return db.getExpressionNodeId(id as string as Ast.AstId) ?? getPortPrimaryInstance(id)?.nodeId
return (isAstId(id) && db.getExpressionNodeId(id)) || getPortPrimaryInstance(id)?.nodeId
}
/**
@ -615,7 +615,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
}
})
} else {
exprId = nodeId
exprId = db.idFromExternal(nodeId)
}
if (exprId == null) {
@ -709,7 +709,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
}
function isConnectedTarget(portId: PortId): boolean {
return db.connections.reverseLookup(portId as AstId).size > 0
return isAstId(portId) && db.connections.reverseLookup(portId).size > 0
}
const modulePath: Ref<LsPath | undefined> = computedAsync(async () => {

View File

@ -1,11 +1,11 @@
import { partitionPoint } from '@/util/data/array'
import { fc, test as fcTest } from '@fast-check/vitest'
import { fc, test } from '@fast-check/vitest'
import { expect } from 'vitest'
const isEven = (n: number) => n % 2 === 0
const isOdd = (n: number) => n % 2 === 1
fcTest.prop({
test.prop({
evens: fc.array(fc.nat(1_000_000_000)).map((a) => a.map((n) => n * 2)),
odds: fc.array(fc.nat(1_000_000_000)).map((a) => a.map((n) => n * 2 + 1)),
})('partitionPoint (even/odd)', ({ evens, odds }) => {
@ -13,7 +13,7 @@ fcTest.prop({
expect(partitionPoint([...odds, ...evens], isOdd)).toEqual(odds.length)
})
fcTest.prop({
test.prop({
arr: fc.array(fc.float({ noNaN: true })).chain((a) => {
const sorted = a.sort((a, b) => a - b)
return fc.record({
@ -26,7 +26,7 @@ fcTest.prop({
expect(partitionPoint(arr, (n) => n < target)).toEqual(i)
})
fcTest.prop({
test.prop({
arr: fc.array(fc.float({ noNaN: true })).chain((a) => {
const sorted = a.sort((a, b) => b - a)
return fc.record({

View File

@ -211,9 +211,9 @@ test.each([
const { db, expectedMethodCall, expectedSuggestion, setExpressionInfo } =
prepareMocksForGetMethodCallTest()
const ast = Ast.parse(code)
db.updateExternalIds(ast)
const subApplication = nthSubapplication(ast, subapplicationIndex)
assert(subApplication)
db.updateExternalIds(ast)
setExpressionInfo(subApplication.id, {
typename: undefined,
methodCall: { ...expectedMethodCall, notAppliedArguments },
@ -360,7 +360,7 @@ test.each([
})
const info = getMethodCallInfoRecursively(ast, db)
const interpreted = interpretCall(ast, true)
const interpreted = interpretCall(ast)
const res = ArgumentApplication.collectArgumentNamesAndUuids(interpreted, info)
if (expectedSameIds) {

View File

@ -502,6 +502,7 @@ export function getMethodCallInfoRecursively(
...info.methodCall,
notAppliedArguments: withoutNamed.sort().slice(appliedArgs),
},
methodCallSource: ast.id,
suggestion: info.suggestion,
}
}

View File

@ -0,0 +1,11 @@
/**
* @file This file provides a place to define symbols so that they are not redefined during HMR.
*
* If a module needs to expose a symbol to other modules, to enable that symbol to be stable across HMR:
* - Define the symbol here, named with the `Symbol` suffix.
* - Import it to the exposing module.
* - Re-export it without the `Symbol` suffix.
* (Using a different name prevents accidentally importing from this module.)
*/
export const CustomDropdownItemsKeySymbol: unique symbol = Symbol('CustomDropdownItems')

View File

@ -2,6 +2,7 @@
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"lib0-ext.d.ts",
"src/**/*",
"src/**/*.vue",
"shared/**/*",

View File

@ -2,6 +2,7 @@
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"lib0-ext.d.ts",
"env.story.d.ts",
"src/**/*",
"src/**/*.json",

View File

@ -63,6 +63,9 @@ export default defineConfig({
// Single hardcoded usage of `global` in aws-amplify.
'global.TYPED_ARRAY_SUPPORT': true,
},
esbuild: {
dropLabels: process.env.NODE_ENV === 'development' ? [] : ['DEV'],
},
assetsInclude: ['**/*.yaml', '**/*.svg'],
css: {
postcss: {

View File

@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url'
import { configDefaults, defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
const config = mergeConfig(
viteConfig,
defineConfig({
test: {
@ -17,3 +17,5 @@ export default mergeConfig(
},
}),
)
config.esbuild.dropLabels = config.esbuild.dropLabels.filter((label: string) => label != 'DEV')
export default config

View File

@ -11,14 +11,17 @@
"./src/detect": "./src/detect.ts",
"./src/gtag": "./src/gtag.ts",
"./src/load": "./src/load.ts",
"./src/backendQuery": "./src/backendQuery.ts",
"./src/queryClient": "./src/queryClient.ts",
"./src/utilities/data/array": "./src/utilities/data/array.ts",
"./src/utilities/data/dateTime": "./src/utilities/data/dateTime.ts",
"./src/utilities/data/newtype": "./src/utilities/data/newtype.ts",
"./src/utilities/data/object": "./src/utilities/data/object.ts",
"./src/utilities/uniqueString": "./src/utilities/uniqueString.ts",
"./src/text": "./src/text/index.ts",
"./src/utilities/permissions": "./src/utilities/permissions.ts",
"./src/services/Backend": "./src/services/Backend.ts"
"./src/services/Backend": "./src/services/Backend.ts",
"./src/types": "./src/types.d.ts"
},
"peerDependencies": {
"@tanstack/query-core": "5.45.0",
@ -26,6 +29,7 @@
},
"dependencies": {
"idb-keyval": "^6.2.1",
"react": "^18.3.1",
"@tanstack/query-persist-client-core": "^5.45.0",
"@tanstack/vue-query": ">= 5.45.0 < 5.46.0",
"vue": "^3.4.19"

View File

@ -0,0 +1,63 @@
/** @file Framework-independent helpers for constructing backend Tanstack queries. */
import type * as queryCore from '@tanstack/query-core'
import type Backend from './services/Backend'
import * as backendModule from './services/Backend'
import * as object from './utilities/data/object'
/** The properties of the Backend type that are methods. */
export type BackendMethods = object.ExtractKeys<Backend, object.MethodOf<Backend>>
/** For each backend method, an optional function defining how to create a query key from its arguments. */
type BackendQueryNormalizers = {
[Method in BackendMethods]?: (...args: Parameters<Backend[Method]>) => queryCore.QueryKey
}
const NORMALIZE_METHOD_QUERY: BackendQueryNormalizers = {
listDirectory: query => [query.parentId, object.omit(query, 'parentId')],
getFileDetails: fileId => [fileId],
}
/** Creates a partial query key representing the given method and arguments. */
function normalizeMethodQuery<Method extends BackendMethods>(
method: Method,
args: Parameters<Backend[Method]>
) {
return NORMALIZE_METHOD_QUERY[method]?.(...args) ?? args
}
/** Returns query options to use for the given backend method invocation. */
export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
keyExtra?: queryCore.QueryKey | undefined
): {
queryKey: queryCore.QueryKey
networkMode: queryCore.NetworkMode
} {
return {
...backendBaseOptions(backend),
queryKey: backendQueryKey(backend, method, args, keyExtra),
}
}
/** Returns the QueryKey to use for the given backend method invocation. */
export function backendQueryKey<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
keyExtra?: queryCore.QueryKey | undefined
): queryCore.QueryKey {
return [backend?.type, method, ...normalizeMethodQuery(method, args), ...(keyExtra ?? [])]
}
/** Returns options applicable to any method of the given backend. */
export function backendBaseOptions(backend: Backend | null): {
networkMode: queryCore.NetworkMode
} {
return {
networkMode: backend?.type === backendModule.BackendType.local ? 'always' : 'online',
}
}

View File

@ -1,6 +1,8 @@
/** @file Interfaces common to multiple modules. */
import type * as React from 'react'
import type Backend from './services/Backend'
// ======================================
// === Globally accessible interfaces ===
// ======================================
@ -22,6 +24,7 @@ interface EditorProps {
metadata?: object | null
) => void
readonly renameProject: (newName: string) => void
readonly backend: Backend | null
}
/** The value passed from the entrypoint to the dashboard, which enables the dashboard to

View File

@ -1,11 +1,13 @@
/** @file Emulates `newtype`s in TypeScript. */
/* eslint-disable @typescript-eslint/consistent-type-definitions */
// ===============
// === Newtype ===
// ===============
/** An interface specifying the variant of a newtype. */
export interface NewtypeVariant<TypeName extends string> {
type NewtypeVariant<TypeName extends string> = {
// eslint-disable-next-line @typescript-eslint/naming-convention
readonly _$type: TypeName
}
@ -14,7 +16,7 @@ export interface NewtypeVariant<TypeName extends string> {
* This is safe, as the discriminator should be a string literal type anyway. */
// This is required for compatibility with the dependency `enso-chat`.
// eslint-disable-next-line no-restricted-syntax
export interface MutableNewtypeVariant<TypeName extends string> {
type MutableNewtypeVariant<TypeName extends string> = {
// eslint-disable-next-line @typescript-eslint/naming-convention
_$type: TypeName
}
@ -36,15 +38,14 @@ export type Newtype<T, TypeName extends string> = NewtypeVariant<TypeName> & T
/** Extracts the original type out of a {@link Newtype}.
* Its only use is in {@link newtypeConstructor}. */
export type UnNewtype<T extends Newtype<unknown, string>> = T extends infer U &
NewtypeVariant<T['_$type']>
type UnNewtype<T extends Newtype<unknown, string>> = T extends infer U & NewtypeVariant<T['_$type']>
? U extends infer V & MutableNewtypeVariant<T['_$type']>
? V
: U
: NotNewtype & Omit<T, '_$type'>
/** An interface that matches a type if and only if it is not a newtype. */
export interface NotNewtype {
type NotNewtype = {
// eslint-disable-next-line @typescript-eslint/naming-convention
readonly _$type?: never
}

View File

@ -0,0 +1,141 @@
/** @file Functions related to manipulating objects. */
// ===============
// === Mutable ===
// ===============
/** Remove the `readonly` modifier from all fields in a type. */
export type Mutable<T> = {
-readonly [K in keyof T]: T[K]
}
// =============
// === merge ===
// =============
/** Prevents generic parameter inference by hiding the type parameter behind a conditional type. */
type NoInfer<T> = [T][T extends T ? 0 : never]
/** Immutably shallowly merge an object with a partial update.
* Does not preserve classes. Useful for preserving order of properties. */
export function merge<T extends object>(object: T, update: Partial<T>): T {
for (const [key, value] of Object.entries(update)) {
// eslint-disable-next-line no-restricted-syntax
if (!Object.is(value, (object as Record<string, unknown>)[key])) {
// This is FINE, as the matching `return` is below this `return`.
// eslint-disable-next-line no-restricted-syntax
return Object.assign({ ...object }, update)
}
}
return object
}
/** Return a function to update an object with the given partial update. */
export function merger<T extends object>(update: Partial<NoInfer<T>>): (object: T) => T {
return object => merge(object, update)
}
// ================
// === readonly ===
// ================
/** Makes all properties readonly at the type level. They are still mutable at the runtime level. */
export function readonly<T extends object>(object: T): Readonly<T> {
return object
}
// =====================
// === unsafeMutable ===
// =====================
/** Removes the readonly modifier from all properties on the object. UNSAFE. */
export function unsafeMutable<T extends object>(object: T): { -readonly [K in keyof T]: T[K] } {
return object
}
// =====================
// === unsafeEntries ===
// =====================
/** Return the entries of an object. UNSAFE only when it is possible for an object to have
* extra keys. */
export function unsafeEntries<T extends object>(
object: T
): readonly { [K in keyof T]: readonly [K, T[K]] }[keyof T][] {
// @ts-expect-error This is intentionally a wrapper function with a different type.
return Object.entries(object)
}
// ==================
// === mapEntries ===
// ==================
/** Return the entries of an object. UNSAFE only when it is possible for an object to have
* extra keys. */
export function mapEntries<K extends PropertyKey, V, W>(
object: Record<K, V>,
map: (key: K, value: V) => W
): Readonly<Record<K, W>> {
// @ts-expect-error It is known that the set of keys is the same for the input and the output,
// because the output is dynamically generated based on the input.
return Object.fromEntries(
unsafeEntries(object).map<[K, W]>(kv => {
const [k, v] = kv
return [k, map(k, v)]
})
)
}
// ================
// === asObject ===
// ================
/** Either return the object unchanged, if the input was an object, or `null`. */
export function asObject(value: unknown): object | null {
return typeof value === 'object' && value != null ? value : null
}
// =============================
// === singletonObjectOrNull ===
// =============================
/** Either return a singleton object, if the input was an object, or an empty array. */
export function singletonObjectOrNull(value: unknown): [] | [object] {
return typeof value === 'object' && value != null ? [value] : []
}
// ============
// === omit ===
// ============
/** UNSAFE when `Ks` contains strings that are not in the runtime array. */
export function omit<T, Ks extends readonly (string & keyof T)[] | []>(
object: T,
...keys: Ks
): Omit<T, Ks[number]> {
const keysSet = new Set<string>(keys)
// eslint-disable-next-line no-restricted-syntax
return Object.fromEntries(
// This is SAFE, as it is a reaonly upcast.
// eslint-disable-next-line no-restricted-syntax
Object.entries(object as Readonly<Record<string, unknown>>).flatMap(kv =>
!keysSet.has(kv[0]) ? [kv] : []
)
) as Omit<T, Ks[number]>
}
// ===================
// === ExtractKeys ===
// ===================
/** Filter a type `T` to include only the properties extending the given type `U`. */
export type ExtractKeys<T, U> = {
[K in keyof T]: T[K] extends U ? K : never
}[keyof T]
// ================
// === MethodOf ===
// ================
/** An instance method of the given type. */
export type MethodOf<T> = (this: T, ...args: never) => unknown

View File

@ -47,16 +47,6 @@ export enum Permission {
delete = 'delete',
}
/** CSS classes for each permission. */
export const PERMISSION_CLASS_NAME: Readonly<Record<Permission, string>> = {
[Permission.owner]: 'text-tag-text bg-permission-owner',
[Permission.admin]: 'text-tag-text bg-permission-admin',
[Permission.edit]: 'text-tag-text bg-permission-edit',
[Permission.read]: 'text-tag-text bg-permission-read',
[Permission.view]: 'text-tag-text-2 bg-permission-view',
[Permission.delete]: 'text-tag-text bg-delete',
}
/** Precedences for each permission. A lower number means a higher priority. */
export const PERMISSION_PRECEDENCE: Readonly<Record<Permission, number>> = {
// These are not magic numbers - they are just a sequence of numbers.
@ -86,11 +76,6 @@ export const PERMISSION_ACTION_PRECEDENCE: Readonly<Record<PermissionAction, num
/* eslint-enable @typescript-eslint/no-magic-numbers */
}
/** CSS classes for the docs permission. */
export const DOCS_CLASS_NAME = 'text-tag-text bg-permission-docs'
/** CSS classes for the execute permission. */
export const EXEC_CLASS_NAME = 'text-tag-text bg-permission-exec'
/** The corresponding {@link Permissions} for each {@link PermissionAction}. */
export const FROM_PERMISSION_ACTION: Readonly<Record<PermissionAction, Permissions>> = {
[PermissionAction.own]: { type: Permission.owner },

View File

@ -55,7 +55,8 @@
"tiny-invariant": "^1.3.3",
"ts-results": "^3.3.0",
"validator": "^13.12.0",
"zod": "^3.23.8"
"zod": "^3.23.8",
"zustand": "^4.5.4"
},
"devDependencies": {
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",

View File

@ -40,6 +40,7 @@ import * as router from 'react-router-dom'
import * as toastify from 'react-toastify'
import * as detect from 'enso-common/src/detect'
import type * as types from 'enso-common/src/types'
import * as appUtils from '#/appUtils'
@ -95,8 +96,6 @@ import * as object from '#/utilities/object'
import * as authServiceModule from '#/authentication/service'
import type * as types from '../../types/types'
// ============================
// === Global configuration ===
// ============================
@ -438,40 +437,48 @@ function AppRouter(props: AppRouterProps) {
{/* Protected pages are visible to authenticated users. */}
<router.Route element={<authProvider.NotDeletedUserLayout />}>
<router.Route element={<authProvider.ProtectedLayout />}>
{detect.IS_DEV_MODE && <router.Route element={<devtools.EnsoDevtools />} />}
<router.Route
element={
detect.IS_DEV_MODE ? (
<devtools.EnsoDevtools>
<router.Outlet />
</devtools.EnsoDevtools>
) : null
}
>
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
<router.Route
path={appUtils.DASHBOARD_PATH}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
<router.Route
path={appUtils.DASHBOARD_PATH}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
<router.Route
path={appUtils.SUBSCRIBE_PATH}
element={
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
<subscribe.Subscribe />
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
<router.Route
path={appUtils.SUBSCRIBE_PATH}
element={
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
<subscribe.Subscribe />
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
</router.Route>
</router.Route>
</router.Route>
</router.Route>
<router.Route
path={appUtils.SUBSCRIBE_SUCCESS_PATH}
element={
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
<subscribeSuccess.SubscribeSuccess />
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
<router.Route
path={appUtils.SUBSCRIBE_SUCCESS_PATH}
element={
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
<subscribeSuccess.SubscribeSuccess />
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
</router.Route>
</router.Route>
</router.Route>

View File

@ -1,6 +1,6 @@
/** @file Placeholder component for GUI used during e2e tests. */
import type * as types from '../../types/types'
import type * as types from 'enso-common/src/types'
/** Placeholder component for GUI used during e2e tests. */
export function TestAppRunner(props: types.EditorProps) {

View File

@ -16,6 +16,12 @@ import * as eventModule from '#/utilities/event'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// =================
// === Constants ===
// =================
const WIDTH_CLASS_NAME = 'max-w-60'
// ====================
// === EditableSpan ===
// ====================
@ -75,7 +81,7 @@ export default function EditableSpan(props: EditableSpanProps) {
if (editable) {
return (
<form
className="flex grow gap-1.5"
className={tailwindMerge.twMerge('flex grow gap-1.5', WIDTH_CLASS_NAME)}
onBlur={event => {
const currentTarget = event.currentTarget
if (!currentTarget.contains(event.relatedTarget)) {
@ -162,7 +168,11 @@ export default function EditableSpan(props: EditableSpanProps) {
)
} else {
return (
<ariaComponents.Text data-testid={props['data-testid']} className={className}>
<ariaComponents.Text
data-testid={props['data-testid']}
truncate="1"
className={tailwindMerge.twMerge(WIDTH_CLASS_NAME, className)}
>
{children}
</ariaComponents.Text>
)

View File

@ -5,7 +5,6 @@ import BlankIcon from 'enso-assets/blank.svg'
import * as backendHooks from '#/hooks/backendHooks'
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -20,6 +19,7 @@ import type * as dashboard from '#/pages/dashboard/Dashboard'
import AssetContextMenu from '#/layouts/AssetContextMenu'
import type * as assetsTable from '#/layouts/AssetsTable'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import Category from '#/layouts/CategorySwitcher/Category'
import * as aria from '#/components/aria'
@ -110,15 +110,17 @@ export default function AssetRow(props: AssetRowProps) {
} = props
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
const { grabKeyboardFocus, doOpenProject, doCloseProject } = props
const { backend, visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
const { visibilities } = state
const draggableProps = dragAndDropHooks.useDraggable()
const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const [item, setItem] = React.useState(rawItem)
const rootRef = React.useRef<HTMLElement | null>(null)
@ -434,7 +436,7 @@ export default function AssetRow(props: AssetRowProps) {
)
}, [setModal, asset.description, setAsset, backend, item.item.id, item.item.title])
eventHooks.useEventHandler(assetEvents, async event => {
eventListProvider.useAssetEventListener(async event => {
if (state.category === Category.trash) {
switch (event.type) {
case AssetEventType.deleteForever: {
@ -696,7 +698,7 @@ export default function AssetRow(props: AssetRowProps) {
}
}
}
})
}, item.initialAssetEvents)
const clearDragState = React.useCallback(() => {
setIsDraggedOver(false)

View File

@ -4,7 +4,6 @@ import * as React from 'react'
import DatalinkIcon from 'enso-assets/datalink.svg'
import * as backendHooks from '#/hooks/backendHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -13,6 +12,8 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan'
@ -36,9 +37,10 @@ export interface DatalinkNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */
export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { backend, assetEvents, dispatchAssetListEvent, setIsAssetPanelTemporarilyVisible } = state
const { backend, setIsAssetPanelTemporarilyVisible } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.datalink) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`DatalinkNameColumn` can only display Datalinks.')
@ -61,9 +63,8 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
await Promise.resolve(null)
}
eventHooks.useEventHandler(
assetEvents,
async event => {
eventListProvider.useAssetEventListener(async event => {
if (isEditable) {
switch (event.type) {
case AssetEventType.newProject:
case AssetEventType.newFolder:
@ -118,9 +119,8 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
break
}
}
},
{ isDisabled: !isEditable }
)
}
}, item.initialAssetEvents)
const handleClick = inputBindings.handler({
editName: () => {

View File

@ -5,7 +5,6 @@ import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
import FolderIcon from 'enso-assets/folder.svg'
import * as backendHooks from '#/hooks/backendHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -15,6 +14,8 @@ import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan'
@ -42,11 +43,12 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { backend, selectedKeys, assetEvents, dispatchAssetListEvent, nodeMap } = state
const { backend, selectedKeys, nodeMap } = state
const { doToggleDirectoryExpansion } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.directory) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`DirectoryNameColumn` can only display folders.')
@ -87,9 +89,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
}
}
eventHooks.useEventHandler(
assetEvents,
async event => {
eventListProvider.useAssetEventListener(async event => {
if (isEditable) {
switch (event.type) {
case AssetEventType.newProject:
case AssetEventType.uploadFiles:
@ -138,9 +139,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
break
}
}
},
{ isDisabled: !isEditable }
)
}
}, item.initialAssetEvents)
const handleClick = inputBindings.handler({
editName: () => {

View File

@ -2,7 +2,6 @@
import * as React from 'react'
import * as backendHooks from '#/hooks/backendHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -11,6 +10,8 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan'
import SvgMask from '#/components/SvgMask'
@ -37,9 +38,10 @@ export interface FileNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */
export default function FileNameColumn(props: FileNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { backend, nodeMap, assetEvents, dispatchAssetListEvent } = state
const { backend, nodeMap } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.file) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`FileNameColumn` can only display files.')
@ -78,9 +80,8 @@ export default function FileNameColumn(props: FileNameColumnProps) {
}
}
eventHooks.useEventHandler(
assetEvents,
async event => {
eventListProvider.useAssetEventListener(async event => {
if (isEditable) {
switch (event.type) {
case AssetEventType.newProject:
case AssetEventType.newFolder:
@ -138,9 +139,8 @@ export default function FileNameColumn(props: FileNameColumnProps) {
break
}
}
},
{ isDisabled: !isEditable }
)
}
}, item.initialAssetEvents)
const handleClick = inputBindings.handler({
editName: () => {

View File

@ -4,7 +4,6 @@ import * as React from 'react'
import NetworkIcon from 'enso-assets/network.svg'
import * as backendHooks from '#/hooks/backendHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -15,6 +14,8 @@ import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import type * as column from '#/components/dashboard/column'
import ProjectIcon from '#/components/dashboard/ProjectIcon'
import EditableSpan from '#/components/EditableSpan'
@ -57,12 +58,13 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
backendType,
isOpened,
} = props
const { backend, selectedKeys, assetEvents, dispatchAssetListEvent } = state
const { backend, selectedKeys } = state
const { nodeMap, doOpenEditor } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.project) {
// eslint-disable-next-line no-restricted-syntax
@ -123,9 +125,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}
}
eventHooks.useEventHandler(
assetEvents,
async event => {
eventListProvider.useAssetEventListener(async event => {
if (isEditable) {
switch (event.type) {
case AssetEventType.newFolder:
case AssetEventType.newDatalink:
@ -243,7 +244,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
setAsset(object.merge(asset, { title: listedProject.packageName, id: projectId }))
} else {
const createdFile = await uploadFileMutation.mutateAsync([
{ fileId, fileName: `${title}.${extension}`, parentDirectoryId: asset.parentId },
{
fileId,
fileName: `${title}.${extension}`,
parentDirectoryId: asset.parentId,
},
file,
])
const project = createdFile.project
@ -278,9 +283,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
break
}
}
},
{ isDisabled: !isEditable }
)
}
}, item.initialAssetEvents)
const handleClick = inputBindings.handler({
editName: () => {

View File

@ -4,7 +4,6 @@ import * as React from 'react'
import KeyIcon from 'enso-assets/key.svg'
import * as backendHooks from '#/hooks/backendHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -14,6 +13,8 @@ import * as modalProvider from '#/providers/ModalProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column'
import SvgMask from '#/components/SvgMask'
@ -40,10 +41,11 @@ export interface SecretNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */
export default function SecretNameColumn(props: SecretNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { backend, assetEvents, dispatchAssetListEvent } = state
const { backend } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { setModal } = modalProvider.useSetModal()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.secret) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`SecretNameColumn` can only display secrets.')
@ -61,9 +63,8 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
eventHooks.useEventHandler(
assetEvents,
async event => {
eventListProvider.useAssetEventListener(async event => {
if (isEditable) {
switch (event.type) {
case AssetEventType.newProject:
case AssetEventType.newFolder:
@ -120,9 +121,8 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
break
}
}
},
{ isDisabled: !isEditable }
)
}
}, item.initialAssetEvents)
const handleClick = inputBindings.handler({
editName: () => {

View File

@ -10,6 +10,7 @@ import * as modalProvider from '#/providers/ModalProvider'
import AssetEventType from '#/events/AssetEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import Category from '#/layouts/CategorySwitcher/Category'
import * as ariaComponents from '#/components/AriaComponents'
@ -29,8 +30,7 @@ import * as uniqueString from '#/utilities/uniqueString'
// ========================
/** The type of the `state` prop of a {@link SharedWithColumn}. */
interface SharedWithColumnStateProp
extends Pick<column.AssetColumnProps['state'], 'category' | 'dispatchAssetEvent'> {
interface SharedWithColumnStateProp extends Pick<column.AssetColumnProps['state'], 'category'> {
readonly setQuery: column.AssetColumnProps['state']['setQuery'] | null
}
@ -43,9 +43,10 @@ interface SharedWithColumnPropsInternal extends Pick<column.AssetColumnProps, 'i
/** A column listing the users with which this asset is shared. */
export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { item, setItem, state, isReadonly = false } = props
const { category, dispatchAssetEvent, setQuery } = state
const { category, setQuery } = state
const asset = item.item
const { user } = authProvider.useNonPartialUserSession()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })

View File

@ -3,15 +3,6 @@ import type AssetEventType from '#/events/AssetEventType'
import type * as backend from '#/services/Backend'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '#/hooks/eventHooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
readonly assetEvent: AssetEvent
}
}
// ==================
// === AssetEvent ===
// ==================

View File

@ -3,15 +3,6 @@ import type AssetListEventType from '#/events/AssetListEventType'
import type * as backend from '#/services/Backend'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '#/hooks/eventHooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
readonly assetListEvent: AssetListEvent
}
}
// ======================
// === AssetListEvent ===
// ======================

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as backendQuery from 'enso-common/src/backendQuery'
import * as authProvider from '#/providers/AuthProvider'
import type Backend from '#/services/Backend'
@ -75,19 +77,17 @@ function revokeOrganizationPictureUrl(backend: Backend | null) {
export function useObserveBackend(backend: Backend | null) {
const queryClient = reactQuery.useQueryClient()
const [seen] = React.useState(new WeakSet())
const useObserveMutations = <Method extends keyof Backend>(
const useObserveMutations = <Method extends backendQuery.BackendMethods>(
method: Method,
onSuccess: (
state: reactQuery.MutationState<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
Parameters<Backend[Method]>
>
) => void
) => {
const states = reactQuery.useMutationState<
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
>({
const states = reactQuery.useMutationState<Parameters<Backend[Method]>>({
// Errored mutations can be safely ignored as they should not change the state.
filters: { mutationKey: [backend?.type, method], status: 'success' },
// eslint-disable-next-line no-restricted-syntax
@ -102,15 +102,15 @@ export function useObserveBackend(backend: Backend | null) {
}
}
}
const setQueryData = <Method extends keyof Backend>(
const setQueryData = <Method extends backendQuery.BackendMethods>(
method: Method,
updater: (
variable: Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>
) => Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>
variable: Awaited<ReturnType<Backend[Method]>>
) => Awaited<ReturnType<Backend[Method]>>
) => {
queryClient.setQueryData<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>
>([backend?.type, method], data => (data == null ? data : updater(data)))
queryClient.setQueryData<Awaited<ReturnType<Backend[Method]>>>([backend?.type, method], data =>
data == null ? data : updater(data)
)
}
useObserveMutations('uploadUserPicture', state => {
revokeUserPictureUrl(backend)
@ -162,65 +162,62 @@ export function useObserveBackend(backend: Backend | null) {
// === useBackendQuery ===
// =======================
export function useBackendQuery<Method extends keyof Backend>(
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
backend: Backend,
method: Method,
args: Parameters<Extract<Backend[Method], (...args: never) => unknown>>,
args: Parameters<Backend[Method]>,
options?: Omit<
reactQuery.UseQueryOptions<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
readonly unknown[]
>,
'queryFn'
>
): reactQuery.UseQueryResult<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>
>
export function useBackendQuery<Method extends keyof Backend>(
): reactQuery.UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Extract<Backend[Method], (...args: never) => unknown>>,
args: Parameters<Backend[Method]>,
options?: Omit<
reactQuery.UseQueryOptions<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
readonly unknown[]
>,
'queryFn'
>
): reactQuery.UseQueryResult<
// eslint-disable-next-line no-restricted-syntax
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>> | undefined
Awaited<ReturnType<Backend[Method]>> | undefined
>
/** Wrap a backend method call in a React Query. */
export function useBackendQuery<Method extends keyof Backend>(
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Extract<Backend[Method], (...args: never) => unknown>>,
args: Parameters<Backend[Method]>,
options?: Omit<
reactQuery.UseQueryOptions<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
readonly unknown[]
>,
'queryFn'
>
) {
return reactQuery.useQuery<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
readonly unknown[]
>({
...options,
queryKey: [backend?.type, method, ...args, ...(options?.queryKey ?? [])],
...backendQuery.backendQueryOptions(backend, method, args, options?.queryKey),
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
queryFn: () => (backend?.[method] as any)?.(...args),
networkMode: backend?.type === backendModule.BackendType.local ? 'always' : 'online',
})
}
@ -228,56 +225,56 @@ export function useBackendQuery<Method extends keyof Backend>(
// === useBackendMutation ===
// ==========================
export function useBackendMutation<Method extends keyof Backend>(
export function useBackendMutation<Method extends backendQuery.BackendMethods>(
backend: Backend,
method: Method,
options?: Omit<
reactQuery.UseMutationOptions<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
Parameters<Backend[Method]>
>,
'mutationFn'
>
): reactQuery.UseMutationResult<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
Parameters<Backend[Method]>
>
export function useBackendMutation<Method extends keyof Backend>(
export function useBackendMutation<Method extends backendQuery.BackendMethods>(
backend: Backend | null,
method: Method,
options?: Omit<
reactQuery.UseMutationOptions<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
Parameters<Backend[Method]>
>,
'mutationFn'
>
): reactQuery.UseMutationResult<
// eslint-disable-next-line no-restricted-syntax
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>> | undefined,
Awaited<ReturnType<Backend[Method]>> | undefined,
Error,
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
Parameters<Backend[Method]>
>
/** Wrap a backend method call in a React Query Mutation. */
export function useBackendMutation<Method extends keyof Backend>(
export function useBackendMutation<Method extends backendQuery.BackendMethods>(
backend: Backend | null,
method: Method,
options?: Omit<
reactQuery.UseMutationOptions<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
Parameters<Backend[Method]>
>,
'mutationFn'
>
) {
return reactQuery.useMutation<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
Parameters<Backend[Method]>
>({
...options,
mutationKey: [backend?.type, method, ...(options?.mutationKey ?? [])],
@ -292,14 +289,12 @@ export function useBackendMutation<Method extends keyof Backend>(
// ===================================
/** Access mutation variables from a React Query Mutation. */
export function useBackendMutationVariables<Method extends keyof Backend>(
export function useBackendMutationVariables<Method extends backendQuery.BackendMethods>(
backend: Backend | null,
method: Method,
mutationKey?: readonly unknown[]
) {
return reactQuery.useMutationState<
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
>({
return reactQuery.useMutationState<Parameters<Backend[Method]>>({
filters: {
mutationKey: [backend?.type, method, ...(mutationKey ?? [])],
status: 'pending',
@ -314,14 +309,14 @@ export function useBackendMutationVariables<Method extends keyof Backend>(
// =======================================
/** Wrap a backend method call in a React Query Mutation, and access its variables. */
export function useBackendMutationWithVariables<Method extends keyof Backend>(
export function useBackendMutationWithVariables<Method extends backendQuery.BackendMethods>(
backend: Backend,
method: Method,
options?: Omit<
reactQuery.UseMutationOptions<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
Parameters<Backend[Method]>
>,
'mutationFn'
>

View File

@ -1,80 +0,0 @@
/** @file Reactive events. */
import * as React from 'react'
import * as detect from 'enso-common/src/detect'
import * as syncRef from '#/hooks/syncRefHooks'
// =======================
// === Reactive Events ===
// =======================
/**
* Parameters for the `useEventHandler` hook.
*/
export interface UseEventHandlerOptions {
/**
* Disable the event handler.
*/
readonly isDisabled?: boolean
}
/** A map containing all known event types. Names MUST be chosen carefully to avoid conflicts.
* The simplest way to achieve this is by namespacing names using a prefix. */
export interface KnownEventsMap {}
/** A union of all known events. */
type KnownEvent = KnownEventsMap[keyof KnownEventsMap]
/** A wrapper around `useState` that calls `flushSync` after every `setState`.
* This is required so that no events are dropped. */
export function useEvent<T extends KnownEvent>(): [events: T[], dispatchEvent: (event: T) => void] {
const [events, setEvents] = React.useState<T[]>([])
React.useEffect(() => {
if (events.length !== 0) {
// This must run after the current render, but before the next.
queueMicrotask(() => {
setEvents([])
})
}
}, [events])
const dispatchEvent = React.useCallback((event: T) => {
setEvents(oldEvents => [...oldEvents, event])
}, [])
return [events, dispatchEvent]
}
/** A wrapper around `useEffect` that has `event` as its sole dependency. */
export function useEventHandler<T extends KnownEvent>(
events: T[],
effect: (event: T) => Promise<void> | void,
params: UseEventHandlerOptions = {}
) {
const { isDisabled = false } = params
let hasEffectRun = false
const isDisabledRef = syncRef.useSyncRef(isDisabled)
React.useLayoutEffect(() => {
if (isDisabledRef.current) {
return
} else if (detect.IS_DEV_MODE) {
if (hasEffectRun) {
// This is the second time this event is being run in React Strict Mode.
// Event handlers are not supposed to be idempotent, so it is a mistake to execute it
// a second time.
// eslint-disable-next-line no-restricted-syntax
return
} else {
// eslint-disable-next-line react-hooks/exhaustive-deps
hasEffectRun = true
}
}
void (async () => {
for (const event of events) {
await effect(event)
}
})()
}, [events])
}

View File

@ -19,6 +19,7 @@ import AssetListEventType from '#/events/AssetListEventType'
import * as dashboard from '#/pages/dashboard/Dashboard'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
import GlobalContextMenu from '#/layouts/GlobalContextMenu'
@ -66,13 +67,15 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const { innerProps, rootDirectoryId, event, eventTarget, hidden = false } = props
const { doTriggerDescriptionEdit, doCopy, doCut, doPaste, doDelete } = props
const { item, setItem, state, setRowState } = innerProps
const { backend, category, hasPasteData, dispatchAssetEvent, dispatchAssetListEvent } = state
const { backend, category, hasPasteData } = state
const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const remoteBackend = backendProvider.useRemoteBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const asset = item.item
const self = asset.permissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId)
@ -474,7 +477,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
item.key as backendModule.DirectoryId
}
directoryId={asset.id}
dispatchAssetListEvent={dispatchAssetListEvent}
doPaste={doPaste}
/>
)}

View File

@ -4,9 +4,6 @@ import * as React from 'react'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetProjectSessions from '#/layouts/AssetProjectSessions'
import AssetProperties from '#/layouts/AssetProperties'
import AssetVersions from '#/layouts/AssetVersions/AssetVersions'
@ -65,14 +62,11 @@ export interface AssetPanelProps extends AssetPanelRequiredProps {
readonly isVisible: boolean
readonly isReadonly?: boolean
readonly category: Category
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
}
/** A panel containing the description and settings for an asset. */
export default function AssetPanel(props: AssetPanelProps) {
const { isVisible, backend, isReadonly = false, item, setItem, category } = props
const { dispatchAssetEvent, dispatchAssetListEvent } = props
const isCloud = backend?.type === backendModule.BackendType.remote
const { getText } = textProvider.useText()
@ -125,7 +119,7 @@ export default function AssetPanel(props: AssetPanelProps) {
event.stopPropagation()
}}
>
<ariaComponents.ButtonGroup className="grow-0 basis-8">
<ariaComponents.ButtonGroup className="mt-0.5 grow-0 basis-8">
{isCloud &&
item != null &&
item.item.type !== backendModule.AssetType.secret &&
@ -184,16 +178,9 @@ export default function AssetPanel(props: AssetPanelProps) {
item={item}
setItem={setItem}
category={category}
dispatchAssetEvent={dispatchAssetEvent}
/>
)}
{tab === AssetPanelTab.versions && (
<AssetVersions
backend={backend}
item={item}
dispatchAssetListEvent={dispatchAssetListEvent}
/>
)}
{tab === AssetPanelTab.versions && <AssetVersions backend={backend} item={item} />}
{tab === AssetPanelTab.projectSessions &&
item.type === backendModule.AssetType.project && (
<AssetProjectSessions backend={backend} item={item} />

View File

@ -11,8 +11,6 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import type Category from '#/layouts/CategorySwitcher/Category'
import * as aria from '#/components/aria'
@ -40,14 +38,13 @@ export interface AssetPropertiesProps {
readonly item: assetTreeNode.AnyAssetTreeNode
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
readonly category: Category
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
readonly isReadonly?: boolean
}
/** Display and modify the properties of an asset. */
export default function AssetProperties(props: AssetPropertiesProps) {
const { backend, item: itemRaw, setItem: setItemRaw, category } = props
const { isReadonly = false, dispatchAssetEvent } = props
const { isReadonly = false } = props
const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
@ -200,7 +197,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
</form>
)}
</div>
</div>{' '}
</div>
{!isCloud && (
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section">
<aria.Heading
@ -245,7 +242,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
isReadonly={isReadonly}
item={item}
setItem={setItem}
state={{ category, dispatchAssetEvent, setQuery: () => {} }}
state={{ category, setQuery: () => {} }}
/>
</td>
</tr>

View File

@ -7,10 +7,10 @@ import RestoreIcon from 'enso-assets/restore.svg'
import * as textProvider from '#/providers/TextProvider'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'
import * as assetDiffView from '#/layouts/AssetDiffView'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as ariaComponents from '#/components/AriaComponents'
@ -33,15 +33,14 @@ export interface AssetVersionProps {
readonly version: backendService.S3ObjectVersion
readonly latestVersion: backendService.S3ObjectVersion
readonly backend: Backend
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
readonly doRestore: () => Promise<void> | void
}
/** Displays information describing a specific version of an asset. */
export default function AssetVersion(props: AssetVersionProps) {
const { placeholder = false, number, version, item, backend, latestVersion } = props
const { dispatchAssetListEvent, doRestore } = props
const { placeholder = false, number, version, item, backend, latestVersion, doRestore } = props
const { getText } = textProvider.useText()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const asset = item.item
const isProject = asset.type === backendService.AssetType.project

View File

@ -7,8 +7,6 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as textProvider from '#/providers/TextProvider'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetVersion from '#/layouts/AssetVersions/AssetVersion'
import * as useAssetVersions from '#/layouts/AssetVersions/useAssetVersions'
@ -40,12 +38,11 @@ interface AddNewVersionVariables {
export interface AssetVersionsProps {
readonly backend: Backend
readonly item: AssetTreeNode
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
}
/** A list of previous versions of an asset. */
export default function AssetVersions(props: AssetVersionsProps) {
const { backend, item, dispatchAssetListEvent } = props
const { backend, item } = props
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [placeholderVersions, setPlaceholderVersions] = React.useState<
@ -118,7 +115,6 @@ export default function AssetVersions(props: AssetVersionsProps) {
item={item}
backend={backend}
latestVersion={latestVersion}
dispatchAssetListEvent={dispatchAssetListEvent}
doRestore={() => {}}
/>
)),
@ -130,7 +126,6 @@ export default function AssetVersions(props: AssetVersionsProps) {
item={item}
backend={backend}
latestVersion={latestVersion}
dispatchAssetListEvent={dispatchAssetListEvent}
doRestore={() =>
restoreMutation.mutateAsync({
versionId: version.versionId,

View File

@ -8,7 +8,6 @@ import DropFilesImage from 'enso-assets/drop_files.svg'
import * as mimeTypes from '#/data/mimeTypes'
import * as backendHooks from '#/hooks/backendHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as intersectionHooks from '#/hooks/intersectionHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import useOnScroll from '#/hooks/useOnScroll'
@ -30,6 +29,7 @@ import type * as dashboard from '#/pages/dashboard/Dashboard'
import type * as assetPanel from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
import Category from '#/layouts/CategorySwitcher/Category'
@ -205,9 +205,10 @@ const SUGGESTIONS_FOR_NEGATIVE_TYPE: assetSearchBar.Suggestion[] = [
* All children MUST have the same asset type. */
function insertAssetTreeNodeChildren(
item: assetTreeNode.AnyAssetTreeNode,
children: backendModule.AnyAsset[],
children: readonly backendModule.AnyAsset[],
directoryKey: backendModule.DirectoryId,
directoryId: backendModule.DirectoryId
directoryId: backendModule.DirectoryId,
getInitialAssetEvents: (id: backendModule.AssetId) => readonly assetEvent.AssetEvent[] | null
): assetTreeNode.AnyAssetTreeNode {
const depth = item.depth + 1
const typeOrder = children[0] != null ? backendModule.ASSET_TYPE_ORDER[children[0].type] : 0
@ -215,7 +216,14 @@ function insertAssetTreeNodeChildren(
node => node.item.type !== backendModule.AssetType.specialEmpty
)
const nodesToInsert = children.map(asset =>
AssetTreeNode.fromAsset(asset, directoryKey, directoryId, depth, `${item.path}/${asset.title}`)
AssetTreeNode.fromAsset(
asset,
directoryKey,
directoryId,
depth,
`${item.path}/${asset.title}`,
getInitialAssetEvents(asset.id)
)
)
const newNodes = array.splicedBefore(
nodes,
@ -232,7 +240,10 @@ function insertArbitraryAssetTreeNodeChildren(
children: backendModule.AnyAsset[],
directoryKey: backendModule.DirectoryId,
directoryId: backendModule.DirectoryId,
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null,
getInitialAssetEvents: (
id: backendModule.AssetId
) => readonly assetEvent.AssetEvent[] | null = () => null
): assetTreeNode.AnyAssetTreeNode {
const depth = item.depth + 1
const nodes = (item.children ?? []).filter(
@ -262,6 +273,7 @@ function insertArbitraryAssetTreeNodeChildren(
directoryId,
depth,
`${item.path}/${asset.title}`,
getInitialAssetEvents(asset.id),
getKey?.(asset) ?? asset.id
)
)
@ -315,9 +327,6 @@ export interface AssetsTableState {
readonly setSortInfo: (sortInfo: sorting.SortInfo<columnUtils.SortableColumn> | null) => void
readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
readonly assetEvents: assetEvent.AssetEvent[]
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
readonly nodeMap: Readonly<
@ -359,10 +368,6 @@ export interface AssetsTableProps {
readonly setCanDownload: (canDownload: boolean) => void
readonly category: Category
readonly initialProjectName: string | null
readonly assetListEvents: assetListEvent.AssetListEvent[]
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
readonly assetEvents: assetEvent.AssetEvent[]
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
@ -395,7 +400,6 @@ export default function AssetsTable(props: AssetsTableProps) {
assetManagementApiRef,
} = props
const { setSuggestions, initialProjectName } = props
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
const { doOpenEditor, doOpenProject, doCloseProject } = props
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
@ -408,6 +412,8 @@ export default function AssetsTable(props: AssetsTableProps) {
const inputBindings = inputBindingsProvider.useInputBindings()
const navigator2D = navigator2DProvider.useNavigator2D()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [initialized, setInitialized] = React.useState(false)
const initializedRef = React.useRef(initialized)
initializedRef.current = initialized
@ -438,7 +444,8 @@ export default function AssetsTable(props: AssetsTableProps) {
rootParentDirectoryId,
rootParentDirectoryId,
-1,
backend.rootPath
backend.rootPath,
null
)
})
const [isDraggingFiles, setIsDraggingFiles] = React.useState(false)
@ -973,11 +980,13 @@ export default function AssetsTable(props: AssetsTableProps) {
rootDirectory.id,
rootDirectory.id,
0,
`${backend.rootPath}/${asset.title}`
`${backend.rootPath}/${asset.title}`,
null
)
),
-1,
backend.rootPath,
null,
rootDirectory.id,
true
)
@ -1115,7 +1124,8 @@ export default function AssetsTable(props: AssetsTableProps) {
key,
directoryId,
item.depth + 1,
''
'',
null
),
],
})
@ -1162,7 +1172,8 @@ export default function AssetsTable(props: AssetsTableProps) {
key,
directoryId,
item.depth + 1,
`${item.path}/${child.title}`
`${item.path}/${child.title}`,
null
)
)
const specialEmptyAsset: backendModule.SpecialEmptyAsset | null =
@ -1178,7 +1189,8 @@ export default function AssetsTable(props: AssetsTableProps) {
key,
directoryId,
item.depth + 1,
''
'',
null
),
]
: initialChildren == null || initialChildren.length === 0
@ -1437,9 +1449,10 @@ export default function AssetsTable(props: AssetsTableProps) {
/** All items must have the same type. */
const insertAssets = React.useCallback(
(
assets: backendModule.AnyAsset[],
assets: readonly backendModule.AnyAsset[],
parentKey: backendModule.DirectoryId | null,
parentId: backendModule.DirectoryId | null
parentId: backendModule.DirectoryId | null,
getInitialAssetEvents: (id: backendModule.AssetId) => readonly assetEvent.AssetEvent[] | null
) => {
const actualParentKey = parentKey ?? rootDirectoryId
const actualParentId = parentId ?? rootDirectoryId
@ -1447,7 +1460,13 @@ export default function AssetsTable(props: AssetsTableProps) {
oldAssetTree.map(item =>
item.key !== actualParentKey
? item
: insertAssetTreeNodeChildren(item, assets, actualParentKey, actualParentId)
: insertAssetTreeNodeChildren(
item,
assets,
actualParentKey,
actualParentId,
getInitialAssetEvents
)
)
)
},
@ -1459,7 +1478,10 @@ export default function AssetsTable(props: AssetsTableProps) {
assets: backendModule.AnyAsset[],
parentKey: backendModule.DirectoryId | null,
parentId: backendModule.DirectoryId | null,
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null,
getInitialAssetEvents: (
id: backendModule.AssetId
) => readonly assetEvent.AssetEvent[] | null = () => null
) => {
const actualParentKey = parentKey ?? rootDirectoryId
const actualParentId = parentId ?? rootDirectoryId
@ -1472,7 +1494,8 @@ export default function AssetsTable(props: AssetsTableProps) {
assets,
actualParentKey,
actualParentId,
getKey
getKey,
getInitialAssetEvents
)
)
})
@ -1505,11 +1528,9 @@ export default function AssetsTable(props: AssetsTableProps) {
description: null,
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true)
insertAssets([placeholderItem], event.parentKey, event.parentId)
dispatchAssetEvent({
type: AssetEventType.newFolder,
placeholderId: placeholderItem.id,
})
insertAssets([placeholderItem], event.parentKey, event.parentId, () => [
{ type: AssetEventType.newFolder, placeholderId: placeholderItem.id },
])
break
}
case AssetListEventType.newProject: {
@ -1534,15 +1555,16 @@ export default function AssetsTable(props: AssetsTableProps) {
description: null,
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true)
insertAssets([placeholderItem], event.parentKey, event.parentId)
dispatchAssetEvent({
type: AssetEventType.newProject,
placeholderId: dummyId,
templateId: event.templateId,
datalinkId: event.datalinkId,
originalId: null,
versionId: null,
})
insertAssets([placeholderItem], event.parentKey, event.parentId, () => [
{
type: AssetEventType.newProject,
placeholderId: dummyId,
templateId: event.templateId,
datalinkId: event.datalinkId,
originalId: null,
versionId: null,
},
])
break
}
case AssetListEventType.uploadFiles: {
@ -1561,35 +1583,40 @@ export default function AssetsTable(props: AssetsTableProps) {
siblingProjectTitles.has(backendModule.stripProjectExtension(project.name))
)
const ownerPermission = permissions.tryGetSingletonOwnerPermission(user)
const fileMap = new Map<backendModule.AssetId, File>()
const getInitialAssetEvents = (
id: backendModule.AssetId
): readonly assetEvent.AssetEvent[] | null => {
const file = fileMap.get(id)
return file == null
? null
: [{ type: AssetEventType.uploadFiles, files: new Map([[id, file]]) }]
}
if (duplicateFiles.length === 0 && duplicateProjects.length === 0) {
const placeholderFiles = files.map(file =>
backendModule.createPlaceholderFileAsset(file.name, event.parentId, ownerPermission)
)
const placeholderFiles = files.map(file => {
const asset = backendModule.createPlaceholderFileAsset(
file.name,
event.parentId,
ownerPermission
)
fileMap.set(asset.id, file)
return asset
})
const placeholderProjects = projects.map(project => {
const basename = backendModule.stripProjectExtension(project.name)
return backendModule.createPlaceholderProjectAsset(
const asset = backendModule.createPlaceholderProjectAsset(
basename,
event.parentId,
ownerPermission,
user,
localBackend?.joinPath(event.parentId, basename) ?? null
)
fileMap.set(asset.id, project)
return asset
})
doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true)
insertAssets(placeholderFiles, event.parentKey, event.parentId)
insertAssets(placeholderProjects, event.parentKey, event.parentId)
dispatchAssetEvent({
type: AssetEventType.uploadFiles,
files: new Map(
[...placeholderFiles, ...placeholderProjects].map((placeholderItem, i) => [
placeholderItem.id,
// This is SAFE, as `placeholderItems` is created using a map on
// `event.files`.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
event.files[i]!,
])
),
})
insertAssets(placeholderFiles, event.parentKey, event.parentId, getInitialAssetEvents)
insertAssets(placeholderProjects, event.parentKey, event.parentId, getInitialAssetEvents)
} else {
const siblingFilesByName = new Map(siblingFiles.map(file => [file.title, file]))
const siblingProjectsByName = new Map(
@ -1630,15 +1657,12 @@ export default function AssetsTable(props: AssetsTableProps) {
parentId={event.parentId}
conflictingFiles={conflictingFiles}
conflictingProjects={conflictingProjects}
dispatchAssetEvent={dispatchAssetEvent}
dispatchAssetListEvent={dispatchAssetListEvent}
siblingFileNames={siblingFilesByName.keys()}
siblingProjectNames={siblingProjectsByName.keys()}
nonConflictingFileCount={files.length - conflictingFiles.length}
nonConflictingProjectCount={projects.length - conflictingProjects.length}
doUploadNonConflicting={() => {
doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true)
const fileMap = new Map<backendModule.AssetId, File>()
const newFiles = files
.filter(file => !siblingFileTitles.has(file.name))
.map(file => {
@ -1667,12 +1691,8 @@ export default function AssetsTable(props: AssetsTableProps) {
fileMap.set(asset.id, project)
return asset
})
insertAssets(newFiles, event.parentKey, event.parentId)
insertAssets(newProjects, event.parentKey, event.parentId)
dispatchAssetEvent({
type: AssetEventType.uploadFiles,
files: fileMap,
})
insertAssets(newFiles, event.parentKey, event.parentId, getInitialAssetEvents)
insertAssets(newProjects, event.parentKey, event.parentId, getInitialAssetEvents)
}}
/>
)
@ -1692,12 +1712,13 @@ export default function AssetsTable(props: AssetsTableProps) {
description: null,
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true)
insertAssets([placeholderItem], event.parentKey, event.parentId)
dispatchAssetEvent({
type: AssetEventType.newDatalink,
placeholderId: placeholderItem.id,
value: event.value,
})
insertAssets([placeholderItem], event.parentKey, event.parentId, () => [
{
type: AssetEventType.newDatalink,
placeholderId: placeholderItem.id,
value: event.value,
},
])
break
}
case AssetListEventType.newSecret: {
@ -1713,12 +1734,9 @@ export default function AssetsTable(props: AssetsTableProps) {
description: null,
}
doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true)
insertAssets([placeholderItem], event.parentKey, event.parentId)
dispatchAssetEvent({
type: AssetEventType.newSecret,
placeholderId: placeholderItem.id,
value: event.value,
})
insertAssets([placeholderItem], event.parentKey, event.parentId, () => [
{ type: AssetEventType.newSecret, placeholderId: placeholderItem.id, value: event.value },
])
break
}
case AssetListEventType.insertAssets: {
@ -1752,15 +1770,16 @@ export default function AssetsTable(props: AssetsTableProps) {
labels: [],
description: null,
}
insertAssets([placeholderItem], event.parentKey, event.parentId)
dispatchAssetEvent({
type: AssetEventType.newProject,
placeholderId: placeholderItem.id,
templateId: null,
datalinkId: null,
originalId: event.original.id,
versionId: event.versionId,
})
insertAssets([placeholderItem], event.parentKey, event.parentId, () => [
{
type: AssetEventType.newProject,
placeholderId: placeholderItem.id,
templateId: null,
datalinkId: null,
originalId: event.original.id,
versionId: event.versionId,
},
])
break
}
case AssetListEventType.willDelete: {
@ -1778,18 +1797,26 @@ export default function AssetsTable(props: AssetsTableProps) {
ids.add(newId)
return newId
}
insertArbitraryAssets(event.items, event.newParentKey, event.newParentId, getKey)
dispatchAssetEvent({
type: AssetEventType.copy,
ids,
newParentKey: event.newParentKey,
newParentId: event.newParentId,
})
const assetEvents: readonly assetEvent.AssetEvent[] = [
{
type: AssetEventType.copy,
ids,
newParentKey: event.newParentKey,
newParentId: event.newParentId,
},
]
insertArbitraryAssets(
event.items,
event.newParentKey,
event.newParentId,
getKey,
() => assetEvents
)
break
}
case AssetListEventType.move: {
deleteAsset(event.key)
insertAssets([event.item], event.newParentKey, event.newParentId)
insertAssets([event.item], event.newParentKey, event.newParentId, () => null)
break
}
case AssetListEventType.delete: {
@ -1820,7 +1847,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
const onAssetListEventRef = React.useRef(onAssetListEvent)
onAssetListEventRef.current = onAssetListEvent
eventHooks.useEventHandler(assetListEvents, event => {
eventListProvider.useAssetListEventListener(event => {
if (!isLoading) {
onAssetListEvent(event)
} else {
@ -1892,8 +1919,6 @@ export default function AssetsTable(props: AssetsTableProps) {
nodeMapRef={nodeMapRef}
rootDirectoryId={rootDirectoryId}
event={{ pageX: 0, pageY: 0 }}
dispatchAssetEvent={dispatchAssetEvent}
dispatchAssetListEvent={dispatchAssetListEvent}
doCopy={doCopy}
doCut={doCut}
doPaste={doPaste}
@ -1909,8 +1934,6 @@ export default function AssetsTable(props: AssetsTableProps) {
doCut,
doPaste,
clearSelectedKeys,
dispatchAssetEvent,
dispatchAssetListEvent,
]
)
@ -1946,9 +1969,6 @@ export default function AssetsTable(props: AssetsTableProps) {
setSortInfo,
query,
setQuery,
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
setAssetPanelProps,
setIsAssetPanelTemporarilyVisible,
nodeMap: nodeMapRef,
@ -1966,7 +1986,6 @@ export default function AssetsTable(props: AssetsTableProps) {
category,
pasteData,
sortInfo,
assetEvents,
query,
doToggleDirectoryExpansion,
doOpenEditor,
@ -1977,8 +1996,6 @@ export default function AssetsTable(props: AssetsTableProps) {
setAssetPanelProps,
setIsAssetPanelTemporarilyVisible,
setQuery,
dispatchAssetEvent,
dispatchAssetListEvent,
]
)
@ -2452,8 +2469,6 @@ export default function AssetsTable(props: AssetsTableProps) {
nodeMapRef={nodeMapRef}
event={event}
rootDirectoryId={rootDirectoryId}
dispatchAssetEvent={dispatchAssetEvent}
dispatchAssetListEvent={dispatchAssetListEvent}
doCopy={doCopy}
doCut={doCut}
doPaste={doPaste}

View File

@ -0,0 +1,193 @@
/** @file The React provider (and associated hooks) for providing reactive events. */
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as zustand from 'zustand'
import type * as assetEvent from '#/events/assetEvent'
import type * as assetListEvent from '#/events/assetListEvent'
// ======================
// === EventListStore ===
// ======================
/** The state of this zustand store. */
interface EventListStore {
readonly assetEvents: readonly assetEvent.AssetEvent[]
readonly assetListEvents: readonly assetListEvent.AssetListEvent[]
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
}
// ========================
// === EventListContext ===
// ========================
/** State contained in a `EventListContext`. */
export interface EventListContextType extends zustand.StoreApi<EventListStore> {}
const EventListContext = React.createContext<EventListContextType | null>(null)
/** Props for a {@link EventListProvider}. */
export interface EventListProviderProps extends Readonly<React.PropsWithChildren> {}
// =========================
// === EventListProvider ===
// =========================
/** A React provider (and associated hooks) for determining whether the current area
* containing the current element is focused. */
export default function EventListProvider(props: EventListProviderProps) {
const { children } = props
const [store] = React.useState(() =>
zustand.createStore<EventListStore>((set, get) => ({
assetEvents: [],
dispatchAssetEvent: event => {
set({ assetEvents: [...get().assetEvents, event] })
},
assetListEvents: [],
dispatchAssetListEvent: event => {
set({ assetListEvents: [...get().assetListEvents, event] })
},
}))
)
React.useEffect(
() =>
store.subscribe(state => {
// Run after the next render.
setTimeout(() => {
if (state.assetEvents.length) {
store.setState({ assetEvents: [] })
}
if (state.assetListEvents.length) {
store.setState({ assetListEvents: [] })
}
})
}),
[store]
)
return <EventListContext.Provider value={store}>{children}</EventListContext.Provider>
}
// ====================
// === useEventList ===
// ====================
/** Functions for getting and setting the event list. */
function useEventList() {
const store = React.useContext(EventListContext)
invariant(store, 'Event list store can only be used inside an `EventListProvider`.')
return store
}
// =============================
// === useDispatchAssetEvent ===
// =============================
/** A function to add a new reactive event. */
export function useDispatchAssetEvent() {
const store = useEventList()
return zustand.useStore(store, state => state.dispatchAssetEvent)
}
// =================================
// === useDispatchAssetListEvent ===
// =================================
/** A function to add a new reactive event. */
export function useDispatchAssetListEvent() {
const store = useEventList()
return zustand.useStore(store, state => state.dispatchAssetListEvent)
}
// =============================
// === useAssetEventListener ===
// =============================
/** Execute a callback for every new asset event. */
export function useAssetEventListener(
callback: (event: assetEvent.AssetEvent) => Promise<void> | void,
initialEvents?: readonly assetEvent.AssetEvent[] | null
) {
const callbackRef = React.useRef(callback)
callbackRef.current = callback
const store = useEventList()
const seen = React.useRef(new WeakSet())
const initialEventsRef = React.useRef(initialEvents)
let alreadyRun = false
React.useEffect(() => {
const events = initialEventsRef.current
if (events && !alreadyRun) {
// Event handlers are not idempotent and MUST NOT be handled twice.
// eslint-disable-next-line react-hooks/exhaustive-deps
alreadyRun = true
for (const event of events) {
void callbackRef.current(event)
}
}
}, [])
React.useEffect(
() =>
store.subscribe((state, prevState) => {
if (state.assetEvents !== prevState.assetEvents) {
for (const event of state.assetEvents) {
if (!seen.current.has(event)) {
seen.current.add(event)
void callbackRef.current(event)
}
}
}
}),
[store]
)
}
// =================================
// === useAssetListEventListener ===
// =================================
/** Execute a callback for every new asset list event. */
export function useAssetListEventListener(
callback: (event: assetListEvent.AssetListEvent) => Promise<void> | void,
initialEvents?: readonly assetListEvent.AssetListEvent[] | null
) {
const callbackRef = React.useRef(callback)
callbackRef.current = callback
const store = useEventList()
const seen = React.useRef(new WeakSet())
const initialEventsRef = React.useRef(initialEvents)
let alreadyRun = false
React.useEffect(() => {
const events = initialEventsRef.current
if (events && !alreadyRun) {
// Event handlers are not idempotent and MUST NOT be handled twice.
// eslint-disable-next-line react-hooks/exhaustive-deps
alreadyRun = true
for (const event of events) {
void callbackRef.current(event)
}
}
}, [])
React.useEffect(
() =>
store.subscribe((state, prevState) => {
if (state.assetListEvents !== prevState.assetListEvents) {
for (const event of state.assetListEvents) {
if (!seen.current.has(event)) {
seen.current.add(event)
void callbackRef.current(event)
}
}
}
}),
[store]
)
}

View File

@ -6,10 +6,9 @@ import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
import type * as assetListEvent from '#/events/assetListEvent'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
import GlobalContextMenu from '#/layouts/GlobalContextMenu'
@ -44,8 +43,6 @@ export interface AssetsTableContextMenuProps {
ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>
>
readonly event: Pick<React.MouseEvent<Element, MouseEvent>, 'pageX' | 'pageY'>
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
readonly doCopy: () => void
readonly doCut: () => void
readonly doPaste: (
@ -58,11 +55,12 @@ export interface AssetsTableContextMenuProps {
* are selected. */
export default function AssetsTableContextMenu(props: AssetsTableContextMenuProps) {
const { hidden = false, backend, category, pasteData, selectedKeys, clearSelectedKeys } = props
const { nodeMapRef, event, dispatchAssetEvent, dispatchAssetListEvent, rootDirectoryId } = props
const { nodeMapRef, event, rootDirectoryId } = props
const { doCopy, doCut, doPaste } = props
const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const isCloud = categoryModule.isCloud(category)
// This works because all items are mutated, ensuring their value stays
@ -207,7 +205,6 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
rootDirectoryId={rootDirectoryId}
directoryKey={null}
directoryId={null}
dispatchAssetListEvent={dispatchAssetListEvent}
doPaste={doPaste}
/>
</ContextMenus>

View File

@ -16,9 +16,9 @@ import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import Category from '#/layouts/CategorySwitcher/Category'
import * as aria from '#/components/aria'
@ -157,17 +157,16 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
export interface CategorySwitcherProps {
readonly category: Category
readonly setCategory: (category: Category) => void
readonly dispatchAssetEvent: (directoryEvent: assetEvent.AssetEvent) => void
}
/** A switcher to choose the currently visible assets table category. */
export default function CategorySwitcher(props: CategorySwitcherProps) {
const { category, setCategory } = props
const { dispatchAssetEvent } = props
const { user } = authProvider.useNonPartialUserSession()
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const { isOffline } = offlineHooks.useOffline()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const localBackend = backendProvider.useLocalBackend()
/** The list of *visible* categories. */
@ -280,7 +279,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
/>
)
return data.nested ? (
<div className="flex">
<div key={data.category} className="flex">
<div className="ml-[15px] mr-1 border-r border-primary/20" />
{element}
</div>

View File

@ -11,8 +11,6 @@ import * as backendProvider from '#/providers/BackendProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'
import type * as dashboard from '#/pages/dashboard/Dashboard'
@ -22,6 +20,7 @@ import AssetPanel from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import type * as assetsTable from '#/layouts/AssetsTable'
import AssetsTable from '#/layouts/AssetsTable'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import CategorySwitcher from '#/layouts/CategorySwitcher'
import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
import DriveBar from '#/layouts/DriveBar'
@ -68,10 +67,6 @@ export interface DriveProps {
readonly setCategory: (category: Category) => void
readonly hidden: boolean
readonly initialProjectName: string | null
readonly assetListEvents: assetListEvent.AssetListEvent[]
readonly dispatchAssetListEvent: (directoryEvent: assetListEvent.AssetListEvent) => void
readonly assetEvents: assetEvent.AssetEvent[]
readonly dispatchAssetEvent: (directoryEvent: assetEvent.AssetEvent) => void
readonly doOpenEditor: (id: dashboard.ProjectId) => void
readonly doOpenProject: (project: dashboard.Project) => void
readonly doCloseProject: (project: dashboard.Project) => void
@ -89,10 +84,6 @@ export default function Drive(props: DriveProps) {
hidden,
initialProjectName,
doOpenProject,
assetListEvents,
dispatchAssetListEvent,
assetEvents,
dispatchAssetEvent,
assetsManagementApiRef,
} = props
@ -103,6 +94,7 @@ export default function Drive(props: DriveProps) {
const localBackend = backendProvider.useLocalBackend()
const backend = backendProvider.useBackend(category)
const { getText } = textProvider.useText()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
const [suggestions, setSuggestions] = React.useState<readonly assetSearchBar.Suggestion[]>([])
const [canDownload, setCanDownload] = React.useState(false)
@ -296,16 +288,11 @@ export default function Drive(props: DriveProps) {
doCreateDirectory={doCreateDirectory}
doCreateSecret={doCreateSecret}
doCreateDatalink={doCreateDatalink}
dispatchAssetEvent={dispatchAssetEvent}
/>
<div className="flex flex-1 gap-drive overflow-hidden">
<div className="flex w-drive-sidebar flex-col gap-drive-sidebar py-drive-sidebar-y">
<CategorySwitcher
category={category}
setCategory={setCategory}
dispatchAssetEvent={dispatchAssetEvent}
/>
<CategorySwitcher category={category} setCategory={setCategory} />
{isCloud && (
<Labels
backend={backend}
@ -347,10 +334,6 @@ export default function Drive(props: DriveProps) {
category={category}
setSuggestions={setSuggestions}
initialProjectName={initialProjectName}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
setAssetPanelProps={setAssetPanelProps}
setIsAssetPanelTemporarilyVisible={setIsAssetPanelTemporarilyVisible}
targetDirectoryNodeRef={targetDirectoryNodeRef}
@ -374,8 +357,6 @@ export default function Drive(props: DriveProps) {
item={assetPanelProps?.item ?? null}
setItem={assetPanelProps?.setItem ?? null}
category={category}
dispatchAssetEvent={dispatchAssetEvent}
dispatchAssetListEvent={dispatchAssetListEvent}
isReadonly={category === Category.trash}
/>
</div>

View File

@ -15,11 +15,11 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import AssetSearchBar from '#/layouts/AssetSearchBar'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
import StartModal from '#/layouts/StartModal'
@ -34,7 +34,6 @@ import type Backend from '#/services/Backend'
import type AssetQuery from '#/utilities/AssetQuery'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// ================
// === DriveBar ===
@ -56,7 +55,6 @@ export interface DriveBarProps {
readonly doCreateSecret: (name: string, value: string) => void
readonly doCreateDatalink: (name: string, value: unknown) => void
readonly doUploadFiles: (files: File[]) => void
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
}
/** Displays the current directory path and permissions, upload and download buttons,
@ -64,11 +62,12 @@ export interface DriveBarProps {
export default function DriveBar(props: DriveBarProps) {
const { backend, query, setQuery, suggestions, category, canDownload } = props
const { doEmptyTrash, doCreateProject, doCreateDirectory } = props
const { doCreateSecret, doCreateDatalink, doUploadFiles, dispatchAssetEvent } = props
const { doCreateSecret, doCreateDatalink, doUploadFiles } = props
const { isAssetPanelOpen, setIsAssetPanelOpen } = props
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
const isCloud = categoryModule.isCloud(category)
const { isOffline } = offlineHooks.useOffline()
@ -106,13 +105,8 @@ export default function DriveBar(props: DriveBarProps) {
const assetPanelToggle = (
<>
{/* Spacing. */}
<div
className={tailwindMerge.twMerge(
'transition-width duration-side-panel',
!isAssetPanelOpen && 'w-8'
)}
/>
<div className="absolute right-[15px] top-[25px] z-1">
<div className={!isAssetPanelOpen ? 'w-5' : 'hidden'} />
<div className="absolute right-[15px] top-[27px] z-1">
<ariaComponents.Button
size="medium"
variant="custom"

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import type * as types from 'enso-common/src/types'
import * as appUtils from '#/appUtils'
import * as gtagHooks from '#/hooks/gtagHooks'
@ -19,8 +21,6 @@ import * as backendModule from '#/services/Backend'
import * as twMerge from '#/utilities/tailwindMerge'
import type * as types from '../../../types/types'
// =================
// === Constants ===
// =================
@ -108,7 +108,11 @@ export default function Editor(props: EditorProps) {
return (
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
<EditorInternal {...props} openedProject={projectQuery.data} />
<EditorInternal
{...props}
openedProject={projectQuery.data}
backendType={project.type}
/>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
)
@ -125,15 +129,17 @@ export default function Editor(props: EditorProps) {
/** Props for an {@link EditorInternal}. */
interface EditorInternalProps extends Omit<EditorProps, 'project'> {
readonly openedProject: backendModule.Project
readonly backendType: backendModule.BackendType
}
/** An internal editor. */
function EditorInternal(props: EditorInternalProps) {
const { hidden, ydocUrl, appRunner: AppRunner, renameProject, openedProject } = props
const { hidden, ydocUrl, appRunner: AppRunner, renameProject, openedProject, backendType } = props
const { getText } = textProvider.useText()
const gtagEvent = gtagHooks.useGtagEvent()
const localBackend = backendProvider.useLocalBackend()
const remoteBackend = backendProvider.useRemoteBackend()
const logEvent = React.useCallback(
@ -157,6 +163,7 @@ function EditorInternal(props: EditorInternalProps) {
const jsonAddress = openedProject.jsonAddress
const binaryAddress = openedProject.binaryAddress
const ydocAddress = ydocUrl ?? ''
const backend = backendType === backendModule.BackendType.remote ? remoteBackend : localBackend
if (jsonAddress == null) {
throw new Error(getText('noJSONEndpointError'))
@ -174,9 +181,20 @@ function EditorInternal(props: EditorInternalProps) {
ignoreParamsRegex: IGNORE_PARAMS_REGEX,
logEvent,
renameProject,
backend,
}
}
}, [openedProject, ydocUrl, getText, hidden, logEvent, renameProject])
}, [
openedProject,
ydocUrl,
getText,
hidden,
logEvent,
renameProject,
backendType,
localBackend,
remoteBackend,
])
// Currently the GUI component needs to be fully rerendered whenever the project is changed. Once
// this is no longer necessary, the `key` could be removed.

View File

@ -4,9 +4,10 @@ import * as React from 'react'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetListEventModule from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as aria from '#/components/aria'
import ContextMenu from '#/components/ContextMenu'
import ContextMenuEntry from '#/components/ContextMenuEntry'
@ -25,7 +26,6 @@ export interface GlobalContextMenuProps {
readonly rootDirectoryId: backendModule.DirectoryId
readonly directoryKey: backendModule.DirectoryId | null
readonly directoryId: backendModule.DirectoryId | null
readonly dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
readonly doPaste: (
newParentKey: backendModule.DirectoryId,
newParentId: backendModule.DirectoryId
@ -35,10 +35,10 @@ export interface GlobalContextMenuProps {
/** A context menu available everywhere in the directory. */
export default function GlobalContextMenu(props: GlobalContextMenuProps) {
const { hidden = false, backend, hasPasteData, directoryKey, directoryId } = props
const { rootDirectoryId, dispatchAssetListEvent } = props
const { doPaste } = props
const { rootDirectoryId, doPaste } = props
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const filesInputRef = React.useRef<HTMLInputElement>(null)
const isCloud = backend.type === backendModule.BackendType.remote

View File

@ -232,7 +232,9 @@ export default function Settings() {
context={context}
data={data}
onInteracted={() => {
setTab(effectiveTab)
if (effectiveTab !== tab) {
setTab(effectiveTab)
}
}}
/>
</div>

View File

@ -4,11 +4,11 @@ import * as React from 'react'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import AssetSummary from '#/components/dashboard/AssetSummary'
@ -45,8 +45,6 @@ export interface DuplicateAssetsModalProps {
readonly parentId: backendModule.DirectoryId
readonly conflictingFiles: readonly ConflictingAsset<backendModule.FileAsset>[]
readonly conflictingProjects: readonly ConflictingAsset<backendModule.ProjectAsset>[]
readonly dispatchAssetEvent: (assetEvent: assetEvent.AssetEvent) => void
readonly dispatchAssetListEvent: (assetListEvent: assetListEvent.AssetListEvent) => void
readonly siblingFileNames: Iterable<string>
readonly siblingProjectNames: Iterable<string>
readonly nonConflictingFileCount: number
@ -58,12 +56,13 @@ export interface DuplicateAssetsModalProps {
export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
const { parentKey, parentId, conflictingFiles: conflictingFilesRaw } = props
const { conflictingProjects: conflictingProjectsRaw } = props
const { dispatchAssetEvent, dispatchAssetListEvent } = props
const { siblingFileNames: siblingFileNamesRaw } = props
const { siblingProjectNames: siblingProjectNamesRaw } = props
const { nonConflictingFileCount, nonConflictingProjectCount, doUploadNonConflicting } = props
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [conflictingFiles, setConflictingFiles] = React.useState(conflictingFilesRaw)
const [conflictingProjects, setConflictingProjects] = React.useState(conflictingProjectsRaw)
const [didUploadNonConflicting, setDidUploadNonConflicting] = React.useState(false)

View File

@ -11,9 +11,9 @@ import DriveIcon from 'enso-assets/drive.svg'
import EditorIcon from 'enso-assets/network.svg'
import SettingsIcon from 'enso-assets/settings.svg'
import * as detect from 'enso-common/src/detect'
import type * as types from 'enso-common/src/types'
import * as eventCallbacks from '#/hooks/eventCallbackHooks'
import * as eventHooks from '#/hooks/eventHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -23,12 +23,11 @@ import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'
import type * as assetTable from '#/layouts/AssetsTable'
import EventListProvider, * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
import Chat from '#/layouts/Chat'
import ChatPlaceholder from '#/layouts/ChatPlaceholder'
@ -53,8 +52,6 @@ import * as array from '#/utilities/array'
import LocalStorage from '#/utilities/LocalStorage'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import type * as types from '../../../../types/types'
// ============================
// === Global configuration ===
// ============================
@ -209,10 +206,17 @@ createGetProjectDetailsQuery.createPassiveListener = (id: Project['id']) =>
/** The component that contains the entire UI. */
export default function Dashboard(props: DashboardProps) {
return (
<EventListProvider>
<DashboardInner {...props} />
</EventListProvider>
)
}
/** The component that contains the entire UI. */
function DashboardInner(props: DashboardProps) {
const { appRunner, initialProjectName: initialProjectNameRaw, ydocUrl } = props
const { user, ...session } = authProvider.useFullUserSession()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
const { getText } = textProvider.useText()
@ -222,6 +226,7 @@ export default function Dashboard(props: DashboardProps) {
const inputBindings = inputBindingsProvider.useInputBindings()
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const assetManagementApiRef = React.useRef<assetTable.AssetManagementApi | null>(null)
const initialLocalProjectId =
@ -229,6 +234,7 @@ export default function Dashboard(props: DashboardProps) {
? localBackendModule.newProjectId(projectManager.UUID(initialProjectNameRaw))
: null
const initialProjectName = initialLocalProjectId ?? initialProjectNameRaw
const isUserEnabled = user.isEnabled
const defaultCategory = initialLocalProjectId == null ? Category.cloud : Category.local
@ -243,6 +249,7 @@ export default function Dashboard(props: DashboardProps) {
}
}
)
const isCloud = categoryModule.isCloud(category)
const [launchedProjects, privateSetLaunchedProjects] = React.useState<Project[]>(
() => localStorage.get('launchedProjects') ?? []
@ -259,6 +266,8 @@ export default function Dashboard(props: DashboardProps) {
}
)
const selectedProject = launchedProjects.find(p => p.id === page) ?? null
const setLaunchedProjects = eventCallbacks.useEventCallback(
(fn: (currentState: Project[]) => Project[]) => {
React.startTransition(() => {
@ -288,15 +297,6 @@ export default function Dashboard(props: DashboardProps) {
localStorage.set('page', nextPage)
})
const [assetListEvents, dispatchAssetListEvent] =
eventHooks.useEvent<assetListEvent.AssetListEvent>()
const [assetEvents, dispatchAssetEvent] = eventHooks.useEvent<assetEvent.AssetEvent>()
const isCloud = categoryModule.isCloud(category)
const isUserEnabled = user.isEnabled
const selectedProject = launchedProjects.find(p => p.id === page) ?? null
if (isCloud && !isUserEnabled && localBackend != null) {
setTimeout(() => {
// This sets `BrowserRouter`, so it must not be set synchronously.
@ -385,7 +385,7 @@ export default function Dashboard(props: DashboardProps) {
}),
})
eventHooks.useEventHandler(assetEvents, event => {
eventListProvider.useAssetEventListener(event => {
switch (event.type) {
case AssetEventType.openProject: {
const { title, parentId, backendType, id, runInBackground } = event
@ -645,10 +645,6 @@ export default function Dashboard(props: DashboardProps) {
setCategory={setCategory}
hidden={page !== TabType.drive}
initialProjectName={initialProjectName}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
doOpenProject={doOpenProject}
doOpenEditor={doOpenEditor}
doCloseProject={doCloseProject}

View File

@ -1,4 +1,6 @@
/** @file A node in the drive's item tree. */
import type * as assetEvent from '#/events/assetEvent'
import * as backendModule from '#/services/Backend'
// =====================
@ -14,6 +16,7 @@ export interface AssetTreeNodeData
| 'depth'
| 'directoryId'
| 'directoryKey'
| 'initialAssetEvents'
| 'isExpanded'
| 'item'
| 'key'
@ -44,6 +47,7 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
public readonly children: AnyAssetTreeNode[] | null,
public readonly depth: number,
public readonly path: string,
public readonly initialAssetEvents: readonly assetEvent.AssetEvent[] | null,
/** The internal (to the frontend) id of the asset (or the placeholder id for new assets).
* This must never change, otherwise the component's state is lost when receiving the real id
* from the backend. */
@ -74,9 +78,19 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
directoryId: backendModule.DirectoryId,
depth: number,
path: string,
initialAssetEvents: readonly assetEvent.AssetEvent[] | null,
key: Asset['id'] = asset.id
): AnyAssetTreeNode {
return new AssetTreeNode(asset, directoryKey, directoryId, null, depth, path, key).asUnion()
return new AssetTreeNode(
asset,
directoryKey,
directoryId,
null,
depth,
path,
initialAssetEvents,
key
).asUnion()
}
/** Return `this`, coerced into an {@link AnyAssetTreeNode}. */
@ -105,6 +119,7 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
update.children === null ? update.children : update.children ?? this.children,
update.depth ?? this.depth,
update.path ?? this.path,
update.initialAssetEvents ?? this.initialAssetEvents,
update.key ?? this.key,
update.isExpanded ?? this.isExpanded,
update.createdAt ?? this.createdAt

View File

@ -1,3 +1,2 @@
/** @file Emulates `newtype`s in TypeScript. */
export * from 'enso-common/src/utilities/data/newtype'

View File

@ -1,125 +1,3 @@
/** @file Functions related to manipulating objects. */
// ===============
// === Mutable ===
// ===============
/** Remove the `readonly` modifier from all fields in a type. */
export type Mutable<T> = {
-readonly [K in keyof T]: T[K]
}
// =============
// === merge ===
// =============
/** Prevents generic parameter inference by hiding the type parameter behind a conditional type. */
type NoInfer<T> = [T][T extends T ? 0 : never]
/** Immutably shallowly merge an object with a partial update.
* Does not preserve classes. Useful for preserving order of properties. */
export function merge<T extends object>(object: T, update: Partial<T>): T {
for (const [key, value] of Object.entries(update)) {
// eslint-disable-next-line no-restricted-syntax
if (!Object.is(value, (object as Record<string, unknown>)[key])) {
// This is FINE, as the matching `return` is below this `return`.
// eslint-disable-next-line no-restricted-syntax
return Object.assign({ ...object }, update)
}
}
return object
}
/** Return a function to update an object with the given partial update. */
export function merger<T extends object>(update: Partial<NoInfer<T>>): (object: T) => T {
return object => merge(object, update)
}
// ================
// === readonly ===
// ================
/** Makes all properties readonly at the type level. They are still mutable at the runtime level. */
export function readonly<T extends object>(object: T): Readonly<T> {
return object
}
// =====================
// === unsafeMutable ===
// =====================
/** Removes the readonly modifier from all properties on the object. UNSAFE. */
export function unsafeMutable<T extends object>(object: T): { -readonly [K in keyof T]: T[K] } {
return object
}
// =====================
// === unsafeEntries ===
// =====================
/** Return the entries of an object. UNSAFE only when it is possible for an object to have
* extra keys. */
export function unsafeEntries<T extends object>(
object: T
): readonly { [K in keyof T]: readonly [K, T[K]] }[keyof T][] {
// @ts-expect-error This is intentionally a wrapper function with a different type.
return Object.entries(object)
}
// ==================
// === mapEntries ===
// ==================
/** Return the entries of an object. UNSAFE only when it is possible for an object to have
* extra keys. */
export function mapEntries<K extends PropertyKey, V, W>(
object: Record<K, V>,
map: (key: K, value: V) => W
): Readonly<Record<K, W>> {
// @ts-expect-error It is known that the set of keys is the same for the input and the output,
// because the output is dynamically generated based on the input.
return Object.fromEntries(
unsafeEntries(object).map<[K, W]>(kv => {
const [k, v] = kv
return [k, map(k, v)]
})
)
}
// ================
// === asObject ===
// ================
/** Either return the object unchanged, if the input was an object, or `null`. */
export function asObject(value: unknown): object | null {
return typeof value === 'object' && value != null ? value : null
}
// =============================
// === singletonObjectOrNull ===
// =============================
/** Either return a singleton object, if the input was an object, or an empty array. */
export function singletonObjectOrNull(value: unknown): [] | [object] {
return typeof value === 'object' && value != null ? [value] : []
}
// ============
// === omit ===
// ============
/** UNSAFE when `Ks` contains strings that are not in the runtime array. */
export function omit<T, Ks extends readonly (string & keyof T)[] | []>(
object: T,
...keys: Ks
): Omit<T, Ks[number]> {
const keysSet = new Set<string>(keys)
// eslint-disable-next-line no-restricted-syntax
return Object.fromEntries(
// This is SAFE, as it is a reaonly upcast.
// eslint-disable-next-line no-restricted-syntax
Object.entries(object as Readonly<Record<string, unknown>>).flatMap(kv =>
!keysSet.has(kv[0]) ? [kv] : []
)
) as Omit<T, Ks[number]>
}
export * from 'enso-common/src/utilities/data/object'

Some files were not shown because too many files have changed in this diff Show More