mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 02:21:54 +03:00
Merge branch 'wip/gmt/10480-tweaks' into wip/gmt/10163-d-comp
This commit is contained in:
commit
3fa7cc42ff
@ -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
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -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 }) => {
|
||||
|
@ -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> {
|
||||
|
@ -52,6 +52,7 @@ const conf = [
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'no-unused-labels': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
7
app/gui2/lib0-ext.d.ts
vendored
Normal file
7
app/gui2/lib0-ext.d.ts
vendored
Normal 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
|
||||
}
|
@ -47,6 +47,7 @@ export default defineConfig({
|
||||
reporter: 'html',
|
||||
use: {
|
||||
headless: !DEBUG,
|
||||
actionTimeout: 5000,
|
||||
trace: 'retain-on-failure',
|
||||
viewport: { width: 1920, height: 1750 },
|
||||
...(DEBUG ?
|
||||
|
13
app/gui2/shared/__tests__/mutableModule.test.ts
Normal file
13
app/gui2/shared/__tests__/mutableModule.test.ts
Normal 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)
|
||||
})
|
@ -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
101
app/gui2/shortcuts.md
Normal 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 |
|
@ -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()
|
||||
|
||||
|
@ -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 |
@ -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
|
||||
|
@ -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),
|
||||
)
|
||||
|
@ -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' },
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
})
|
||||
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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. */
|
||||
|
@ -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
|
||||
|
@ -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>
|
@ -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 {
|
||||
|
@ -72,6 +72,7 @@ test.each`
|
||||
if (astId === id('entireFunction')) {
|
||||
return {
|
||||
suggestion: callSuggestion,
|
||||
methodCallSource: astId,
|
||||
methodCall: {
|
||||
notAppliedArguments: [],
|
||||
methodPointer: entryMethodPointer(callSuggestion)!,
|
||||
|
@ -84,6 +84,7 @@ export const widgetDefinition = defineWidget(
|
||||
@update:modelValue="setValue"
|
||||
@click.stop
|
||||
@focus="editHandler.start()"
|
||||
@blur="editHandler.end()"
|
||||
@input="editHandler.edit($event)"
|
||||
/>
|
||||
</template>
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -59,10 +59,6 @@ const project = useProjectStore()
|
||||
}
|
||||
}
|
||||
|
||||
.toggledOn {
|
||||
color: #ba4c40;
|
||||
}
|
||||
|
||||
.iconButton:active {
|
||||
color: #ba4c40;
|
||||
}
|
||||
|
@ -56,7 +56,7 @@ const emit = defineEmits<{
|
||||
backdrop-filter: var(--blur-app-bg);
|
||||
}
|
||||
|
||||
.toggledOff {
|
||||
.toggledOff svg {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
|
@ -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));
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
233
app/gui2/src/components/widgets/FileBrowserWidget.vue
Normal file
233
app/gui2/src/components/widgets/FileBrowserWidget.vue
Normal 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>
|
65
app/gui2/src/composables/backend.ts
Normal file
65
app/gui2/src/composables/backend.ts
Normal 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 }
|
||||
}
|
@ -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 })
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
||||
|
18
app/gui2/src/composables/vueQuery.ts
Normal file
18
app/gui2/src/composables/vueQuery.ts
Normal 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)),
|
||||
}
|
||||
}
|
@ -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`
|
||||
|
11
app/gui2/src/providers/backend.ts
Normal file
11
app/gui2/src/providers/backend.ts
Normal 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),
|
||||
}),
|
||||
)
|
@ -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([])
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 () => {
|
||||
|
@ -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({
|
||||
|
@ -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) {
|
||||
|
@ -502,6 +502,7 @@ export function getMethodCallInfoRecursively(
|
||||
...info.methodCall,
|
||||
notAppliedArguments: withoutNamed.sort().slice(appliedArgs),
|
||||
},
|
||||
methodCallSource: ast.id,
|
||||
suggestion: info.suggestion,
|
||||
}
|
||||
}
|
||||
|
11
app/gui2/src/util/symbols.ts
Normal file
11
app/gui2/src/util/symbols.ts
Normal 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')
|
@ -2,6 +2,7 @@
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"lib0-ext.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"shared/**/*",
|
||||
|
@ -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",
|
||||
|
@ -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: {
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
63
app/ide-desktop/lib/common/src/backendQuery.ts
Normal file
63
app/ide-desktop/lib/common/src/backendQuery.ts
Normal 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',
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
||||
}
|
||||
|
141
app/ide-desktop/lib/common/src/utilities/data/object.ts
Normal file
141
app/ide-desktop/lib/common/src/utilities/data/object.ts
Normal 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
|
@ -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 },
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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: () => {
|
||||
|
@ -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: () => {
|
||||
|
@ -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: () => {
|
||||
|
@ -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: () => {
|
||||
|
@ -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: () => {
|
||||
|
@ -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 })
|
||||
|
||||
|
@ -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 ===
|
||||
// ==================
|
||||
|
@ -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 ===
|
||||
// ======================
|
||||
|
@ -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'
|
||||
>
|
||||
|
@ -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])
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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}
|
||||
|
@ -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]
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
||||
|
@ -232,7 +232,9 @@ export default function Settings() {
|
||||
context={context}
|
||||
data={data}
|
||||
onInteracted={() => {
|
||||
setTab(effectiveTab)
|
||||
if (effectiveTab !== tab) {
|
||||
setTab(effectiveTab)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -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)
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -1,3 +1,2 @@
|
||||
/** @file Emulates `newtype`s in TypeScript. */
|
||||
|
||||
export * from 'enso-common/src/utilities/data/newtype'
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user