Even more dashboard fixes (#10541)

- Fix https://github.com/enso-org/cloud-v2/issues/1383
- Fix file download - both on Electron, and in browser
- Refresh versions list when uploading neww file version
- Fix app crashing when asset is opened in asset panel while switching to Local Backend
- Don't show asset id when asset is opened in asset panel, but user is on the Local backend, resulting in the internal Asset ID being shown
- Fix drag-n-drop
- ⚠️ `npm run dev` is NOT fixed in this PR - however it should already be fixed in another PR which has already been merged. This needs testing to confirm whether it is fixed though.

Other changes:
- Add support for "duplicate project" endpoint on Local Backend
- Fix downloading project from nested directory on Local Backend (not working)
- Refactor more E2E tests to use the "new" architecture
- Simplify "new" E2E architecture to minimize boilerplate

# Important Notes
- When testing downloads, both Electron and browser should be tested as they use completely separate implementations for how files are downloaded.
This commit is contained in:
somebody1234 2024-07-16 19:55:45 +10:00 committed by GitHub
parent a30b0c60eb
commit cf9d757457
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 2157 additions and 1447 deletions

View File

@ -36,7 +36,7 @@
"@babel/plugin-syntax-import-attributes": "^7.24.7",
"@electron/notarize": "2.1.0",
"@types/node": "^20.11.21",
"electron": "25.7.0",
"electron": "31.2.0",
"electron-builder": "^24.13.3",
"enso-common": "workspace:*",
"enso-gui2": "workspace:*",

View File

@ -38,8 +38,7 @@ import * as urlAssociations from 'url-associations'
const logger = contentConfig.logger
if (process.env.ELECTRON_DEV_MODE === 'true' && process.env.NODE_MODULES_PATH != null) {
require.main?.paths.unshift(process.env.NODE_MODULES_PATH)
console.log(require.main?.paths)
module.paths.unshift(process.env.NODE_MODULES_PATH)
}
// ===========
@ -451,6 +450,15 @@ class App {
event.reply(ipc.Channel.importProjectFromPath, path, info)
}
)
electron.ipcMain.on(
ipc.Channel.downloadURL,
(_event, url: string, headers?: Record<string, string>) => {
electron.BrowserWindow.getFocusedWindow()?.webContents.downloadURL(
url,
headers ? { headers } : {}
)
}
)
electron.ipcMain.on(ipc.Channel.showItemInFolder, (_event, fullPath: string) => {
electron.shell.showItemInFolder(fullPath)
})

View File

@ -29,5 +29,7 @@ export enum Channel {
openFileBrowser = 'open-file-browser',
/** Show a file or folder in the system file browser. */
showItemInFolder = 'show-item-in-folder',
/** Download a file using its URL. */
downloadURL = 'download-url',
showAboutModal = 'show-about-modal',
}

View File

@ -203,6 +203,9 @@ electron.contextBridge.exposeInMainWorld(MENU_API_KEY, MENU_API)
// ==================
const SYSTEM_API = {
downloadURL: (url: string, headers?: Record<string, string>) => {
electron.ipcRenderer.send(ipc.Channel.downloadURL, url, headers)
},
showItemInFolder: (fullPath: string) => {
electron.ipcRenderer.send(ipc.Channel.showItemInFolder, fullPath)
},

View File

@ -15,6 +15,11 @@ const TRUSTED_HOSTS = [
'production-enso-domain.auth.eu-west-1.amazoncognito.com',
'production-enso-organizations-files.s3.amazonaws.com',
'pb-enso-domain.auth.eu-west-1.amazoncognito.com',
'7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com',
'lkxuay3ha1.execute-api.eu-west-1.amazonaws.com',
'8rf1a7iy49.execute-api.eu-west-1.amazonaws.com',
'opk1cxpwec.execute-api.eu-west-1.amazonaws.com',
'xw0g8j3tsb.execute-api.eu-west-1.amazonaws.com',
's3.eu-west-1.amazonaws.com',
// This (`localhost`) is required to access Project Manager HTTP endpoints.
// This should be changed appropriately if the Project Manager's port number becomes dynamic.

View File

@ -212,8 +212,6 @@ export interface ProjectStateType {
readonly ec2PublicIpAddress?: string
readonly currentSessionId?: string
readonly openedBy?: EmailAddress
/** Only present on the Local backend. */
readonly path?: Path
}
export const IS_OPENING: Readonly<Record<ProjectState, boolean>> = {
@ -242,7 +240,7 @@ export const IS_OPENING_OR_OPENED: Readonly<Record<ProjectState, boolean>> = {
/** Common `Project` fields returned by all `Project`-related endpoints. */
export interface BaseProject {
readonly organizationId: string
readonly organizationId: OrganizationId
readonly projectId: ProjectId
readonly name: string
}
@ -1053,15 +1051,11 @@ export interface UpdateFileRequestBody {
export interface UpdateAssetRequestBody {
readonly parentDirectoryId: DirectoryId | null
readonly description: string | null
/** Only present on the Local backend. */
readonly projectPath?: Path
}
/** HTTP request body for the "delete asset" endpoint. */
export interface DeleteAssetRequestBody {
readonly force: boolean
/** Only used by the Local backend. */
readonly parentId: DirectoryId
}
/** HTTP request body for the "create project" endpoint. */
@ -1078,8 +1072,6 @@ export interface UpdateProjectRequestBody {
readonly projectName: string | null
readonly ami: Ami | null
readonly ideVersion: VersionNumber | null
/** Only used by the Local backend. */
readonly parentId: DirectoryId
}
/** HTTP request body for the "open project" endpoint. */
@ -1463,11 +1455,6 @@ export default abstract class Backend {
projectId?: string | null,
metadata?: object | null
): Promise<void>
/** Return a {@link Promise} that resolves only when a project is ready to open. */
abstract waitUntilProjectIsReady(
projectId: ProjectId,
directory: DirectoryId | null,
title: string,
abortSignal?: AbortSignal
): Promise<Project>
/** Download from an arbitrary URL that is assumed to originate from this backend. */
abstract download(url: string, name?: string): Promise<void>
}

View File

@ -375,8 +375,7 @@ export function locateNewUserGroupModal(page: test.Page) {
/** Find a user menu (if any) on the current page. */
export function locateUserMenu(page: test.Page) {
// This has no identifying features.
return page.getByTestId('user-menu')
return page.getByAltText('User Settings').locator('visible=true')
}
/** Find a "set username" panel (if any) on the current page. */
@ -463,7 +462,8 @@ export namespace settings {
/** Navigate so that the "user account" settings section is visible. */
export async function go(page: test.Page) {
await test.test.step('Go to "user account" settings section', async () => {
await press(page, 'Mod+,')
await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
})
}
@ -482,7 +482,8 @@ export namespace settings {
/** Navigate so that the "change password" settings section is visible. */
export async function go(page: test.Page) {
await test.test.step('Go to "change password" settings section', async () => {
await press(page, 'Mod+,')
await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
})
}
@ -516,7 +517,8 @@ export namespace settings {
/** Navigate so that the "profile picture" settings section is visible. */
export async function go(page: test.Page) {
await test.test.step('Go to "profile picture" settings section', async () => {
await press(page, 'Mod+,')
await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
})
}
@ -535,7 +537,8 @@ export namespace settings {
/** Navigate so that the "organization" settings section is visible. */
export async function go(page: test.Page) {
await test.test.step('Go to "organization" settings section', async () => {
await press(page, 'Mod+,')
await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
await settings.tab.organization.locate(page).click()
})
}
@ -571,7 +574,8 @@ export namespace settings {
/** Navigate so that the "organization profile picture" settings section is visible. */
export async function go(page: test.Page) {
await test.test.step('Go to "organization profile picture" settings section', async () => {
await press(page, 'Mod+,')
await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
await settings.tab.organization.locate(page).click()
})
}
@ -591,7 +595,8 @@ export namespace settings {
/** Navigate so that the "members" settings section is visible. */
export async function go(page: test.Page, force = false) {
await test.test.step('Go to "members" settings section', async () => {
await press(page, 'Mod+,')
await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
await settings.tab.members.locate(page).click({ force })
})
}
@ -876,11 +881,10 @@ export const mockApi = apiModule.mockApi
/** Set up all mocks, without logging in. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
export async function mockAll({ page, setupAPI }: MockParams) {
return await test.test.step('Execute all mocks', async () => {
const api = await mockApi({ page, setupAPI })
export function mockAll({ page, setupAPI }: MockParams) {
return new LoginPageActions(page).step('Execute all mocks', async () => {
await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
return { api, pageActions: new LoginPageActions(page) }
})
}
@ -891,10 +895,28 @@ export async function mockAll({ page, setupAPI }: MockParams) {
/** Set up all mocks, and log in with dummy credentials. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
export async function mockAllAndLogin({ page, setupAPI }: MockParams) {
export function mockAllAndLogin({ page, setupAPI }: MockParams) {
return new DrivePageActions(page)
.step('Execute all mocks', async () => {
await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
})
.do(thePage => login({ page: thePage, setupAPI }))
}
// ===================================
// === mockAllAndLoginAndExposeAPI ===
// ===================================
/** Set up all mocks, and log in with dummy credentials.
* @deprecated Prefer {@link mockAllAndLogin}. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
export async function mockAllAndLoginAndExposeAPI({ page, setupAPI }: MockParams) {
return await test.test.step('Execute all mocks and login', async () => {
const mocks = await mockAll({ page, setupAPI })
const api = await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
await login({ page, setupAPI })
return { ...mocks, pageActions: new DrivePageActions(page) }
return api
})
}

View File

@ -1,6 +1,10 @@
/** @file The base class from which all `Actions` classes are derived. */
import * as test from '@playwright/test'
import type * as inputBindings from '#/utilities/inputBindings'
import * as actions from '../actions'
// ====================
// === PageCallback ===
// ====================
@ -29,13 +33,19 @@ export interface LocatorCallback {
*
* [`thenable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables
*/
export default class BaseActions implements PromiseLike<void> {
export default class BaseActions implements Promise<void> {
/** Create a {@link BaseActions}. */
constructor(
protected readonly page: test.Page,
private readonly promise = Promise.resolve()
) {}
/** Get the string name of the class of this instance. Required for this class to implement
* {@link Promise}. */
get [Symbol.toStringTag]() {
return this.constructor.name
}
/** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
* on all other platforms. */
static press(page: test.Page, keyOrShortcut: string): Promise<void> {
@ -55,7 +65,6 @@ export default class BaseActions implements PromiseLike<void> {
}
})
}
/** Proxies the `then` method of the internal {@link Promise}. */
async then<T, E>(
// The following types are copied almost verbatim from the type definitions for `Promise`.
@ -72,10 +81,17 @@ export default class BaseActions implements PromiseLike<void> {
* to treat this class as a {@link Promise}. */
// The following types are copied almost verbatim from the type definitions for `Promise`.
// eslint-disable-next-line no-restricted-syntax
async catch<T>(onrejected?: ((reason: unknown) => T) | null | undefined) {
async catch<T>(onrejected?: ((reason: unknown) => PromiseLike<T> | T) | null | undefined) {
return await this.promise.catch(onrejected)
}
/** Proxies the `catch` method of the internal {@link Promise}.
* This method is not required for this to be a `thenable`, but it is still useful
* to treat this class as a {@link Promise}. */
async finally(onfinally?: (() => void) | null | undefined): Promise<void> {
await this.promise.finally(onfinally)
}
/** Return a {@link BaseActions} with the same {@link Promise} but a different type. */
into<
T extends new (page: test.Page, promise: Promise<void>, ...args: Args) => InstanceType<T>,
@ -98,13 +114,45 @@ export default class BaseActions implements PromiseLike<void> {
}
/** Perform an action on the current page. */
step(name: string, callback: PageCallback): this {
step(name: string, callback: PageCallback) {
return this.do(() => test.test.step(name, () => callback(this.page)))
}
/** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
* on all other platforms. */
press(keyOrShortcut: string): this {
press<Key extends string>(keyOrShortcut: inputBindings.AutocompleteKeybind<Key>) {
return this.do(page => BaseActions.press(page, keyOrShortcut))
}
/** Perform actions until a predicate passes. */
retry(
callback: (actions: this) => this,
predicate: (page: test.Page) => Promise<boolean>,
options: { retries?: number; delay?: number } = {}
) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const { retries = 3, delay = 1_000 } = options
return this.step('Perform actions with retries', async thePage => {
for (let i = 0; i < retries; i += 1) {
await callback(this)
if (await predicate(thePage)) {
// eslint-disable-next-line no-restricted-syntax
return
}
await thePage.waitForTimeout(delay)
}
throw new Error('This action did not succeed.')
})
}
/** Perform actions with the "Mod" modifier key pressed. */
withModPressed<R extends BaseActions>(callback: (actions: this) => R) {
return callback(
this.step('Press "Mod"', async page => {
await page.keyboard.down(await actions.modModifier(page))
})
).step('Release "Mod"', async page => {
await page.keyboard.up(await actions.modModifier(page))
})
}
}

View File

@ -10,6 +10,22 @@ import NewDataLinkModalActions from './NewDataLinkModalActions'
import PageActions from './PageActions'
import StartModalActions from './StartModalActions'
// =================
// === Constants ===
// =================
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
// =======================
// === locateAssetRows ===
// =======================
/** Find all assets table rows (if any). */
function locateAssetRows(page: test.Page) {
return actions.locateAssetsTable(page).locator('tbody').getByRole('row')
}
// ========================
// === DrivePageActions ===
// ========================
@ -80,47 +96,111 @@ export default class DrivePageActions extends PageActions {
},
/** Click to select a specific row. */
clickRow(index: number) {
return self.step('Click drive table row', page =>
actions
.locateAssetRows(page)
.nth(index)
.click({ position: actions.ASSET_ROW_SAFE_POSITION })
return self.step(`Click drive table row #${index}`, page =>
locateAssetRows(page).nth(index).click({ position: actions.ASSET_ROW_SAFE_POSITION })
)
},
/** Right click a specific row to bring up its context menu, or the context menu for multiple
* assets when right clicking on a selected asset when multiple assets are selected. */
rightClickRow(index: number) {
return self.step('Click drive table row', page =>
actions
.locateAssetRows(page)
return self.step(`Right click drive table row #${index}`, page =>
locateAssetRows(page)
.nth(index)
.click({ button: 'right', position: actions.ASSET_ROW_SAFE_POSITION })
)
},
/** Double click a row. */
doubleClickRow(index: number) {
return self.step(`Double dlick drive table row #${index}`, page =>
locateAssetRows(page).nth(index).dblclick({ position: actions.ASSET_ROW_SAFE_POSITION })
)
},
/** Interact with the set of all rows in the Drive table. */
withRows(callback: baseActions.LocatorCallback) {
return self.step('Interact with drive table rows', async page => {
await callback(actions.locateAssetRows(page))
await callback(locateAssetRows(page))
})
},
/** Drag a row onto another row. */
dragRowToRow(from: number, to: number) {
return self.step(`Drag drive table row #${from} to row #${to}`, async page => {
const rows = locateAssetRows(page)
await rows.nth(from).dragTo(rows.nth(to), {
sourcePosition: ASSET_ROW_SAFE_POSITION,
targetPosition: ASSET_ROW_SAFE_POSITION,
})
})
},
/** Drag a row onto another row. */
dragRow(from: number, to: test.Locator, force?: boolean) {
return self.step(`Drag drive table row #${from} to custom locator`, page =>
locateAssetRows(page)
.nth(from)
.dragTo(to, {
sourcePosition: ASSET_ROW_SAFE_POSITION,
...(force == null ? {} : { force }),
})
)
},
/** A test assertion to confirm that there is only one row visible, and that row is the
* placeholder row displayed when there are no assets to show. */
expectPlaceholderRow() {
return self.step('Expect placeholder row', async page => {
const assetRows = actions.locateAssetRows(page)
await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows).toHaveText(/You have no files/)
const rows = locateAssetRows(page)
await test.expect(rows).toHaveCount(1)
await test.expect(rows).toHaveText(/You have no files/)
})
},
/** A test assertion to confirm that there is only one row visible, and that row is the
* placeholder row displayed when there are no assets in Trash. */
expectTrashPlaceholderRow() {
return self.step('Expect trash placeholder row', async page => {
const assetRows = actions.locateAssetRows(page)
await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows).toHaveText(/Your trash is empty/)
const rows = locateAssetRows(page)
await test.expect(rows).toHaveCount(1)
await test.expect(rows).toHaveText(/Your trash is empty/)
})
},
/** Toggle a column's visibility. */
get toggleColumn() {
return {
/** Toggle visibility for the "modified" column. */
modified() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Modified').click()
)
},
/** Toggle visibility for the "shared with" column. */
sharedWith() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Shared With').click()
)
},
/** Toggle visibility for the "labels" column. */
labels() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Labels').click()
)
},
/** Toggle visibility for the "accessed by projects" column. */
accessedByProjects() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Accessed By Projects').click()
)
},
/** Toggle visibility for the "accessed data" column. */
accessedData() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Accessed Data').click()
)
},
/** Toggle visibility for the "docs" column. */
docs() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Docs').click()
)
},
}
},
}
}
@ -181,6 +261,13 @@ export default class DrivePageActions extends PageActions {
)
}
/** Interact with the container element of the assets table. */
withAssetsTable(callback: baseActions.LocatorCallback) {
return this.step('Interact with drive table', async page => {
await callback(actions.locateAssetsTable(page))
})
}
/** Interact with the Asset Panel. */
withAssetPanel(callback: baseActions.LocatorCallback) {
return this.step('Interact with asset panel', async page => {

View File

@ -1,6 +1,7 @@
/** @file Actions for the context menu. */
import type * as baseActions from './BaseActions'
import type BaseActions from './BaseActions'
import EditorPageActions from './EditorPageActions'
// ==========================
// === ContextMenuActions ===
@ -19,9 +20,11 @@ export interface ContextMenuActions<T extends BaseActions> {
readonly share: () => T
readonly label: () => T
readonly duplicate: () => T
readonly duplicateProject: () => EditorPageActions
readonly copy: () => T
readonly cut: () => T
readonly paste: () => T
readonly copyAsPath: () => T
readonly download: () => T
readonly uploadFiles: () => T
readonly newFolder: () => T
@ -91,9 +94,13 @@ export function contextMenuActions<T extends BaseActions>(
step('Duplicate (context menu)', page =>
page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate').click()
),
duplicateProject: () =>
step('Duplicate project (context menu)', page =>
page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate').click()
).into(EditorPageActions),
copy: () =>
step('Copy (context menu)', page =>
page.getByRole('button', { name: 'Copy' }).getByText('Copy').click()
page.getByRole('button', { name: 'Copy' }).getByText('Copy', { exact: true }).click()
),
cut: () =>
step('Cut (context menu)', page =>
@ -103,6 +110,10 @@ export function contextMenuActions<T extends BaseActions>(
step('Paste (context menu)', page =>
page.getByRole('button', { name: 'Paste' }).getByText('Paste').click()
),
copyAsPath: () =>
step('Copy as path (context menu)', page =>
page.getByRole('button', { name: 'Copy As Path' }).getByText('Copy As Path').click()
),
download: () =>
step('Download (context menu)', page =>
page.getByRole('button', { name: 'Download' }).getByText('Download').click()

View File

@ -34,7 +34,7 @@ export function goToPageActions(
).into(DrivePageActions),
editor: () =>
step('Go to "Spatial Analysis" page', page =>
page.getByRole('button').and(page.getByLabel('Spatial Analysis')).click()
page.getByTestId('editor-tab-button').click()
).into(EditorPageActions),
settings: () =>
step('Go to "settings" page', page => BaseActions.press(page, 'Mod+,')).into(

View File

@ -4,6 +4,7 @@ import type * as test from 'playwright/test'
import type * as baseActions from './BaseActions'
import type BaseActions from './BaseActions'
import LoginPageActions from './LoginPageActions'
import SettingsPageActions from './SettingsPageActions'
// =======================
// === UserMenuActions ===
@ -12,6 +13,7 @@ import LoginPageActions from './LoginPageActions'
/** Actions for the user menu. */
export interface UserMenuActions<T extends BaseActions> {
readonly downloadApp: (callback: (download: test.Download) => Promise<void> | void) => T
readonly settings: () => SettingsPageActions
readonly logout: () => LoginPageActions
readonly goToLoginPage: () => LoginPageActions
}
@ -25,13 +27,16 @@ export function userMenuActions<T extends BaseActions>(
step: (name: string, callback: baseActions.PageCallback) => T
): UserMenuActions<T> {
return {
downloadApp: (callback: (download: test.Download) => Promise<void> | void) => {
return step('Download app (user menu)', async page => {
downloadApp: (callback: (download: test.Download) => Promise<void> | void) =>
step('Download app (user menu)', async page => {
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: 'Download App' }).getByText('Download App').click()
await callback(await downloadPromise)
})
},
}),
settings: () =>
step('Go to Settings (user menu)', async page => {
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
}).into(SettingsPageActions),
logout: () =>
step('Logout (user menu)', page =>
page.getByRole('button', { name: 'Logout' }).getByText('Logout').click()

View File

@ -38,7 +38,7 @@ const BASE_URL = 'https://mock/'
// ===============
/** Parameters for {@link mockApi}. */
interface MockParams {
export interface MockParams {
readonly page: test.Page
readonly setupAPI?: SetupAPI | null | undefined
}
@ -51,10 +51,17 @@ export interface SetupAPI {
(api: Awaited<ReturnType<typeof mockApi>>): Promise<void> | void
}
/** The return type of {@link mockApi}. */
export interface MockApi extends Awaited<ReturnType<typeof mockApiInternal>> {}
// This is a function, even though it does not contain function syntax.
// eslint-disable-next-line no-restricted-syntax
export const mockApi: (params: MockParams) => Promise<MockApi> = mockApiInternal
/** Add route handlers for the mock API to a page. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
export async function mockApi({ page, setupAPI }: MockParams) {
async function mockApiInternal({ page, setupAPI }: MockParams) {
// eslint-disable-next-line no-restricted-syntax
const defaultEmail = 'email@example.com' as backend.EmailAddress
const defaultUsername = 'user name'
@ -148,7 +155,7 @@ export async function mockApi({ page, setupAPI }: MockParams) {
type: backend.AssetType.project,
id: backend.ProjectId('project-' + uniqueString.uniqueString()),
projectState: {
type: backend.ProjectState.opened,
type: backend.ProjectState.closed,
volumeId: '',
},
title,
@ -479,16 +486,16 @@ export async function mockApi({ page, setupAPI }: MockParams) {
// === Endpoints with dummy implementations ===
await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => {
const projectId = request.url().match(/[/]projects[/](.+?)[/]copy/)?.[1] ?? ''
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const project = assetMap.get(projectId)
if (!project?.projectState) {
throw new Error('Attempting to get a project that does not exist.')
} else {
return {
organizationId: defaultOrganizationId,
projectId: backend.ProjectId(projectId),
projectId: projectId,
name: 'example project name',
state: {
type: backend.ProjectState.opened,
volumeId: '',
openedBy: defaultEmail,
},
state: project.projectState,
packageName: 'Project_root',
// eslint-disable-next-line @typescript-eslint/naming-convention
ide_version: null,
@ -497,8 +504,9 @@ export async function mockApi({ page, setupAPI }: MockParams) {
value: '2023.2.1-nightly.2023.9.29',
lifecycle: backend.VersionLifecycle.development,
},
address: backend.Address('ws://example.com/'),
address: backend.Address('ws://localhost/'),
} satisfies backend.ProjectRaw
}
})
// === Endpoints returning `void` ===
@ -508,7 +516,7 @@ export async function mockApi({ page, setupAPI }: MockParams) {
interface Body {
readonly parentDirectoryId: backend.DirectoryId
}
const assetId = request.url().match(/[/]assets[/](.+?)[/]copy/)?.[1]
const assetId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1]
// eslint-disable-next-line no-restricted-syntax
const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : null
if (asset == null) {
@ -559,10 +567,20 @@ export async function mockApi({ page, setupAPI }: MockParams) {
await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async route => {
await route.fulfill()
})
await post(remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), async route => {
await post(remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), async (route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const project = assetMap.get(projectId)
if (project?.projectState) {
object.unsafeMutable(project.projectState).type = backend.ProjectState.closed
}
await route.fulfill()
})
await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async route => {
await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async (route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const project = assetMap.get(projectId)
if (project?.projectState) {
object.unsafeMutable(project.projectState).type = backend.ProjectState.opened
}
await route.fulfill()
})
await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async route => {
@ -774,7 +792,7 @@ export async function mockApi({ page, setupAPI }: MockParams) {
organizationId: defaultOrganizationId,
packageName: 'Project_root',
projectId: id,
state: { type: backend.ProjectState.opened, volumeId: '' },
state: { type: backend.ProjectState.closed, volumeId: '' },
}
addProject(title, {
description: null,

View File

@ -23,9 +23,8 @@ const EMAIL = 'baz.quux@email.com'
// =============
test.test('open and close asset panel', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions
actions
.mockAllAndLogin({ page })
.createFolder()
.driveTable.clickRow(0)
.withAssetPanel(async assetPanel => {
@ -40,13 +39,12 @@ test.test('open and close asset panel', ({ page }) =>
await actions.expectNotOnScreen(assetPanel)
})
)
)
test.test('asset panel contents', ({ page }) =>
actions.mockAll({ page }).then(
async ({ pageActions, api }) =>
await pageActions
.do(() => {
actions
.mockAll({
page,
setupAPI: api => {
const { defaultOrganizationId, defaultUserId } = api
api.addProject('project', {
description: DESCRIPTION,
@ -63,6 +61,7 @@ test.test('asset panel contents', ({ page }) =>
},
],
})
},
})
.login()
.do(async thePage => {
@ -73,9 +72,6 @@ test.test('asset panel contents', ({ page }) =>
.do(async () => {
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
// `getByText` is required so that this assertion works if there are multiple permissions.
await test
.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME))
.toBeVisible()
await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible()
})
)
)

View File

@ -32,9 +32,9 @@ test.test('tags', async ({ page }) => {
})
test.test('labels', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const searchBarInput = actions.locateSearchBarInput(page)
const labels = actions.locateSearchBarLabels(page)
await actions.mockAllAndLogin({
page,
setupAPI: api => {
api.addLabel('aaaa', backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
@ -42,7 +42,10 @@ test.test('labels', async ({ page }) => {
api.addLabel('cccc', backend.COLORS[2]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('dddd', backend.COLORS[3]!)
await actions.reload({ page })
},
})
const searchBarInput = actions.locateSearchBarInput(page)
const labels = actions.locateSearchBarLabels(page)
await searchBarInput.click()
for (const label of await labels.all()) {

View File

@ -5,11 +5,13 @@ import * as actions from './actions'
const PASS_TIMEOUT = 5_000
test.test('extra columns should stick to right side of assets table', async ({ page }) => {
await actions.mockAllAndLogin({ page })
await actions.locateAccessedByProjectsColumnToggle(page).click()
await actions.locateAccessedDataColumnToggle(page).click()
await actions.locateAssetsTable(page).evaluate(element => {
test.test('extra columns should stick to right side of assets table', ({ page }) =>
actions
.mockAllAndLogin({ page })
.driveTable.toggleColumn.accessedByProjects()
.driveTable.toggleColumn.accessedData()
.withAssetsTable(async table => {
await table.evaluate(element => {
let scrollableParent: HTMLElement | SVGElement | null = element
while (
scrollableParent != null &&
@ -20,8 +22,10 @@ test.test('extra columns should stick to right side of assets table', async ({ p
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' })
})
const extraColumns = actions.locateExtraColumns(page)
const assetsTable = actions.locateAssetsTable(page)
})
.do(async thePage => {
const extraColumns = actions.locateExtraColumns(thePage)
const assetsTable = actions.locateAssetsTable(thePage)
await test
.expect(async () => {
const extraColumnsRight = await extraColumns.evaluate(
@ -34,13 +38,18 @@ test.test('extra columns should stick to right side of assets table', async ({ p
})
.toPass({ timeout: PASS_TIMEOUT })
})
)
test.test('extra columns should stick to top of scroll container', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
await actions.mockAllAndLogin({
page,
setupAPI: api => {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
for (let i = 0; i < 100; i += 1) {
api.addFile('a')
}
},
})
await actions.reload({ page })
await actions.locateAccessedByProjectsColumnToggle(page).click()
@ -78,19 +87,23 @@ test.test('extra columns should stick to top of scroll container', async ({ page
.toPass({ timeout: PASS_TIMEOUT })
})
test.test('can drop onto root directory dropzone', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const asset = api.addDirectory('a')
api.addFile('b', { parentId: asset.id })
await actions.reload({ page })
await assetRows.nth(0).dblclick()
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
await assetRows.nth(1).dragTo(actions.locateRootDirectoryDropzone(page), { force: true })
const firstLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
const secondLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
test.expect(firstLeft, 'siblings have same indentation').toEqual(secondLeft)
test.test('can drop onto root directory dropzone', ({ page }) =>
actions
.mockAllAndLogin({ page })
.createFolder()
.uploadFile('b', 'testing')
.driveTable.doubleClickRow(0)
.driveTable.withRows(async rows => {
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'Child is indented further than parent').toBeGreaterThan(parentLeft)
})
.driveTable.dragRow(1, actions.locateRootDirectoryDropzone(page))
.driveTable.withRows(async rows => {
const firstLeft = await actions.getAssetRowLeftPx(rows.nth(0))
// The second row is the indented child of the directory
// (the "this folder is empty" row).
const secondLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft)
})
)

View File

@ -2,220 +2,191 @@
import * as test from '@playwright/test'
import * as actions from './actions'
// =====================
// === Local actions ===
// =====================
// These actions have been migrated to the new API, and are included here as a temporary measure
// until this file is also migrated to the new API.
/** Find a "duplicate" button (if any) on the current page. */
export function locateDuplicateButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate')
}
/** Find a "copy" button (if any) on the current page. */
function locateCopyButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Copy' }).getByText('Copy', { exact: true })
}
/** Find a "cut" button (if any) on the current page. */
function locateCutButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Cut' }).getByText('Cut')
}
/** Find a "paste" button (if any) on the current page. */
function locatePasteButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Paste' }).getByText('Paste')
}
/** A test assertion to confirm that there is only one row visible, and that row is the
* placeholder row displayed when there are no assets to show. */
export async function expectPlaceholderRow(page: test.Page) {
const assetRows = actions.locateAssetRows(page)
await test.test.step('Expect placeholder row', async () => {
await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows).toHaveText(/You have no files/)
})
}
import EditorPageActions from './actions/EditorPageActions'
// =============
// === Tests ===
// =============
test.test.beforeEach(({ page }) => {
return actions.mockAllAndLogin({ page })
})
test.test('copy', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
test.test('copy', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: Folder 1]
await actions.locateNewFolderIcon(page).click()
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
await assetRows.nth(0).click({ button: 'right' })
await test.expect(actions.locateContextMenus(page)).toBeVisible()
await locateCopyButton(page).click()
.createFolder()
.driveTable.rightClickRow(0)
// Assets: [0: Folder 2 <copied>, 1: Folder 1]
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
await assetRows.nth(1).click({ button: 'right' })
await test.expect(actions.locateContextMenus(page)).toBeVisible()
await locatePasteButton(page).click()
.contextMenu.copy()
.driveTable.rightClickRow(1)
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
await test.expect(assetRows).toHaveCount(3)
await test.expect(assetRows.nth(2)).toBeVisible()
await test.expect(assetRows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(2))
.contextMenu.paste()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible()
await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
)
test.test('copy (keyboard)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
test.test('copy (keyboard)', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: Folder 1]
await actions.locateNewFolderIcon(page).click()
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
await actions.clickAssetRow(assetRows.nth(0))
await actions.press(page, 'Mod+C')
.createFolder()
.driveTable.clickRow(0)
// Assets: [0: Folder 2 <copied>, 1: Folder 1]
await actions.clickAssetRow(assetRows.nth(1))
await actions.press(page, 'Mod+V')
.press('Mod+C')
.driveTable.clickRow(1)
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
await test.expect(assetRows).toHaveCount(3)
await test.expect(assetRows.nth(2)).toBeVisible()
await test.expect(assetRows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(2))
.press('Mod+V')
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible()
await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
)
test.test('move', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
test.test('move', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: Folder 1]
await actions.locateNewFolderIcon(page).click()
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
await assetRows.nth(0).click({ button: 'right' })
await test.expect(actions.locateContextMenus(page)).toBeVisible()
await locateCutButton(page).click()
.createFolder()
.driveTable.rightClickRow(0)
// Assets: [0: Folder 2 <cut>, 1: Folder 1]
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
await assetRows.nth(1).click({ button: 'right' })
await test.expect(actions.locateContextMenus(page)).toBeVisible()
await locatePasteButton(page).click()
.contextMenu.cut()
.driveTable.rightClickRow(1)
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
await test.expect(assetRows).toHaveCount(2)
await test.expect(assetRows.nth(1)).toBeVisible()
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
.contextMenu.paste()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
)
test.test('move (drag)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
test.test('move (drag)', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: Folder 1]
await actions.locateNewFolderIcon(page).click()
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
await actions.dragAssetRowToAssetRow(assetRows.nth(0), assetRows.nth(1))
.createFolder()
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
await test.expect(assetRows).toHaveCount(2)
await test.expect(assetRows.nth(1)).toBeVisible()
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
.driveTable.dragRowToRow(0, 1)
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
)
test.test('move to trash', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
await actions.locateNewFolderIcon(page).click()
await page.keyboard.down(await actions.modModifier(page))
await actions.clickAssetRow(assetRows.nth(0))
await actions.clickAssetRow(assetRows.nth(1))
test.test('move to trash', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: Folder 1]
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
.createFolder()
// NOTE: For some reason, `react-aria-components` causes drag-n-drop to break if `Mod` is still
// held.
await page.keyboard.up(await actions.modModifier(page))
await actions.dragAssetRow(assetRows.nth(0), actions.locateTrashCategory(page))
await expectPlaceholderRow(page)
await actions.locateTrashCategory(page).click()
await test.expect(assetRows).toHaveCount(2)
await test.expect(assetRows.nth(0)).toBeVisible()
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1/)
await test.expect(assetRows.nth(1)).toBeVisible()
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
.withModPressed(modActions => modActions.driveTable.clickRow(0).driveTable.clickRow(1))
.driveTable.dragRow(0, actions.locateTrashCategory(page))
.driveTable.expectPlaceholderRow()
.goToCategory.trash()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveText([/^New Folder 1/, /^New Folder 2/])
})
)
test.test('move (keyboard)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
test.test('move (keyboard)', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: Folder 1]
await actions.locateNewFolderIcon(page).click()
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
await actions.clickAssetRow(assetRows.nth(0))
await actions.press(page, 'Mod+X')
.createFolder()
.driveTable.clickRow(0)
// Assets: [0: Folder 2 <cut>, 1: Folder 1]
await actions.clickAssetRow(assetRows.nth(1))
await actions.press(page, 'Mod+V')
.press('Mod+X')
.driveTable.clickRow(1)
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
await test.expect(assetRows).toHaveCount(2)
await test.expect(assetRows.nth(1)).toBeVisible()
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
.press('Mod+V')
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
)
test.test('cut (keyboard)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
await actions.clickAssetRow(assetRows.nth(0))
await actions.press(page, 'Mod+X')
test.test('cut (keyboard)', async ({ page }) =>
actions
.mockAllAndLogin({ page })
.createFolder()
.driveTable.clickRow(0)
.press('Mod+X')
.driveTable.withRows(async rows => {
// This action is not a builtin `expect` action, so it needs to be manually retried.
await test
.expect(async () => {
test
.expect(await assetRows.nth(0).evaluate(el => Number(getComputedStyle(el).opacity)))
.expect(await rows.nth(0).evaluate(el => Number(getComputedStyle(el).opacity)))
.toBeLessThan(1)
})
.toPass()
})
)
test.test('duplicate', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 1]
await assetRows.nth(0).click({ button: 'right' })
await test.expect(actions.locateContextMenus(page)).toBeVisible()
await locateDuplicateButton(page).click()
// Assets: [0: Folder 1 (copy), 1: Folder 1]
await test.expect(assetRows).toHaveCount(2)
test.test('duplicate', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: New Project 1]
.newEmptyProject()
.goToPage.drive()
.driveTable.rightClickRow(0)
.contextMenu.duplicateProject()
.goToPage.drive()
.driveTable.withRows(async rows => {
// Assets: [0: New Project 1 (copy), 1: New Project 1]
await test.expect(rows).toHaveCount(2)
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
await test.expect(assetRows.nth(0)).toBeVisible()
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/)
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
})
)
test.test('duplicate (keyboard)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 1]
await actions.clickAssetRow(assetRows.nth(0))
await actions.press(page, 'Mod+D')
// Assets: [0: Folder 1 (copy), 1: Folder 1]
await test.expect(assetRows).toHaveCount(2)
await test.expect(assetRows.nth(0)).toBeVisible()
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/)
test.test('duplicate (keyboard)', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: New Project 1]
.newEmptyProject()
.goToPage.drive()
.driveTable.clickRow(0)
.press('Mod+D')
.into(EditorPageActions)
.goToPage.drive()
.driveTable.withRows(async rows => {
// Assets: [0: New Project 1 (copy), 1: New Project 1]
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
})
)

View File

@ -21,45 +21,43 @@ const SECRET_VALUE = 'a secret value'
// =============
test.test('create folder', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions.createFolder().driveTable.withRows(async rows => {
actions
.mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(/^New Folder 1/)
})
)
)
test.test('create project', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions
actions
.mockAllAndLogin({ page })
.newEmptyProject()
.do(thePage => test.expect(actions.locateEditor(thePage)).toBeAttached())
.goToPage.drive()
.driveTable.withRows(rows => test.expect(rows).toHaveCount(1))
)
)
test.test('upload file', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions.uploadFile(FILE_NAME, FILE_CONTENTS).driveTable.withRows(async rows => {
actions
.mockAllAndLogin({ page })
.uploadFile(FILE_NAME, FILE_CONTENTS)
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME))
})
)
)
test.test('create secret', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions.createSecret(SECRET_NAME, SECRET_VALUE).driveTable.withRows(async rows => {
actions
.mockAllAndLogin({ page })
.createSecret(SECRET_NAME, SECRET_VALUE)
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME))
})
)
)

View File

@ -6,10 +6,10 @@ import * as actions from './actions'
const DATA_LINK_NAME = 'a data link'
test.test('data link editor', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions.openDataLinkModal().withNameInput(async input => {
actions
.mockAllAndLogin({ page })
.openDataLinkModal()
.withNameInput(async input => {
await input.fill(DATA_LINK_NAME)
})
)
)

View File

@ -4,9 +4,8 @@ import * as test from '@playwright/test'
import * as actions from './actions'
test.test('delete and restore', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions
actions
.mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1)
@ -26,12 +25,10 @@ test.test('delete and restore', ({ page }) =>
await test.expect(rows).toHaveCount(1)
})
)
)
test.test('delete and restore (keyboard)', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions
actions
.mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1)
@ -51,4 +48,3 @@ test.test('delete and restore (keyboard)', ({ page }) =>
await test.expect(rows).toHaveCount(1)
})
)
)

View File

@ -4,9 +4,8 @@ import * as test from '@playwright/test'
import * as actions from './actions'
test.test('drive view', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions
actions
.mockAllAndLogin({ page })
.withDriveView(async view => {
await test.expect(view).toBeVisible()
})
@ -36,17 +35,10 @@ test.test('drive view', ({ page }) =>
.driveTable.withRows(async rows => {
await actions.locateStopProjectButton(rows.nth(0)).click()
})
// FIXME(#10488): This test fails because the mock endpoint returns the project is opened,
// but it must be stopped first to delete the project.
// Project context menu
// .driveTable.rightClickRow(0)
// .withContextMenus(async menus => {
// // actions.locateContextMenus(page)
// await test.expect(menus).toBeVisible()
// })
// .contextMenu.moveToTrash()
// .driveTable.withRows(async rows => {
// await test.expect(rows).toHaveCount(1)
// })
)
.driveTable.rightClickRow(0)
.contextMenu.moveToTrash()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1)
})
)

View File

@ -6,10 +6,10 @@ import * as backend from '#/services/Backend'
import * as actions from './actions'
test.test('drag labels onto single row', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const label = 'aaaa'
const labelEl = actions.locateLabelsPanelLabels(page, label)
await actions.mockAllAndLogin({
page,
setupAPI: api => {
api.addLabel(label, backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
@ -21,6 +21,10 @@ test.test('drag labels onto single row', async ({ page }) => {
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
},
})
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label)
await actions.relog({ page })
await test.expect(labelEl).toBeVisible()
@ -32,10 +36,10 @@ test.test('drag labels onto single row', async ({ page }) => {
})
test.test('drag labels onto multiple rows', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const label = 'aaaa'
const labelEl = actions.locateLabelsPanelLabels(page, label)
await actions.mockAllAndLogin({
page,
setupAPI: api => {
api.addLabel(label, backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
@ -47,7 +51,10 @@ test.test('drag labels onto multiple rows', async ({ page }) => {
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
await actions.relog({ page })
},
})
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label)
await page.keyboard.down(await actions.modModifier(page))
await actions.clickAssetRow(assetRows.nth(0))

View File

@ -8,9 +8,8 @@ import * as actions from './actions'
// =============
test.test('login and logout', ({ page }) =>
actions.mockAll({ page }).then(
async ({ pageActions }) =>
await pageActions
actions
.mockAll({ page })
.login()
.do(async thePage => {
await actions.passTermsAndConditionsDialog({ page: thePage })
@ -24,4 +23,3 @@ test.test('login and logout', ({ page }) =>
await test.expect(actions.locateLoginButton(thePage)).toBeVisible()
})
)
)

View File

@ -4,7 +4,7 @@ import * as test from '@playwright/test'
import * as actions from './actions'
test.test('members settings', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.members
// Setup

View File

@ -4,7 +4,7 @@ import * as test from '@playwright/test'
import * as actions from './actions'
test.test('organization settings', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.organization
// Setup
@ -76,7 +76,7 @@ test.test('organization settings', async ({ page }) => {
})
test.test('upload organization profile picture', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.organizationProfilePicture
await localActions.go(page)

View File

@ -4,9 +4,8 @@ import * as test from '@playwright/test'
import * as actions from './actions'
test.test('page switcher', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions
actions
.mockAllAndLogin({ page })
// Create a new project so that the editor page can be switched to.
.newEmptyProject()
.goToPage.drive()
@ -20,4 +19,3 @@ test.test('page switcher', ({ page }) =>
await test.expect(actions.locateEditor(thePage)).toBeVisible()
})
)
)

View File

@ -2,6 +2,7 @@
import * as test from '@playwright/test'
import * as actions from './actions'
import type * as api from './api'
// =================
// === Constants ===
@ -75,16 +76,19 @@ test.test('sign up without organization id', async ({ page }) => {
.toBe(api.defaultOrganizationId)
})
test.test('sign up flow', ({ page }) =>
actions.mockAll({ page }).then(
async ({ pageActions, api }) =>
await pageActions
.do(() => {
api.setCurrentUser(null)
test.test('sign up flow', ({ page }) => {
let api!: api.MockApi
return actions
.mockAll({
page,
setupAPI: theApi => {
api = theApi
theApi.setCurrentUser(null)
// These values should be different, otherwise the email and name may come from the defaults.
test.expect(EMAIL).not.toStrictEqual(api.defaultEmail)
test.expect(NAME).not.toStrictEqual(api.defaultName)
test.expect(EMAIL).not.toStrictEqual(theApi.defaultEmail)
test.expect(NAME).not.toStrictEqual(theApi.defaultName)
},
})
.loginAsNewUser(EMAIL, actions.VALID_PASSWORD)
.do(async thePage => {
@ -116,5 +120,4 @@ test.test('sign up flow', ({ page }) =>
test.expect(api.currentUser()?.email, 'new user has correct email').toBe(EMAIL)
test.expect(api.currentUser()?.name, 'new user has correct name').toBe(NAME)
})
)
)
})

View File

@ -20,10 +20,9 @@ const MIN_MS = 60_000
// =============
test.test('sort', async ({ page }) => {
const { api } = await actions.mockAll({ page })
const assetRows = actions.locateAssetRows(page)
const nameHeading = actions.locateNameColumnHeading(page)
const modifiedHeading = actions.locateModifiedColumnHeading(page)
await actions.mockAll({
page,
setupAPI: api => {
const date1 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS))
const date2 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS))
const date3 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS))
@ -49,6 +48,11 @@ test.test('sort', async ({ page }) => {
// g directory
// c project
// d file
},
})
const assetRows = actions.locateAssetRows(page)
const nameHeading = actions.locateNameColumnHeading(page)
const modifiedHeading = actions.locateModifiedColumnHeading(page)
await actions.login({ page })
// By default, assets should be grouped by type.

View File

@ -4,9 +4,8 @@ import * as test from '@playwright/test'
import * as actions from './actions'
test.test('create project from template', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions
actions
.mockAllAndLogin({ page })
.openStartModal()
.createProjectFromTemplate(0)
.do(async thePage => {
@ -14,4 +13,3 @@ test.test('create project from template', ({ page }) =>
await test.expect(actions.locateSamples(page).first()).not.toBeVisible()
})
)
)

View File

@ -4,20 +4,20 @@ import * as test from '@playwright/test'
import * as actions from './actions'
test.test('user menu', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions.openUserMenu().do(async thePage => {
actions
.mockAllAndLogin({ page })
.openUserMenu()
.do(async thePage => {
await test.expect(actions.locateUserMenu(thePage)).toBeVisible()
})
)
)
test.test('download app', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions }) =>
await pageActions.openUserMenu().userMenu.downloadApp(async download => {
actions
.mockAllAndLogin({ page })
.openUserMenu()
.userMenu.downloadApp(async download => {
await download.cancel()
test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/)
})
)
)

View File

@ -4,7 +4,7 @@ import * as test from '@playwright/test'
import * as actions from './actions'
test.test('user settings', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.userAccount
test.expect(api.currentUser()?.name).toBe(api.defaultName)
@ -18,7 +18,7 @@ test.test('user settings', async ({ page }) => {
})
test.test('change password form', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.changePassword
await localActions.go(page)
@ -79,7 +79,7 @@ test.test('change password form', async ({ page }) => {
})
test.test('upload profile picture', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.profilePicture
await localActions.go(page)

View File

@ -15,8 +15,6 @@ import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
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'
@ -92,24 +90,14 @@ export interface AssetRowProps
props: AssetRowInnerProps,
event: React.MouseEvent<HTMLTableRowElement>
) => void
readonly doOpenProject: (project: dashboard.Project) => void
readonly doCloseProject: (project: dashboard.Project) => void
readonly updateAssetRef: React.Ref<(asset: backendModule.AnyAsset) => void>
}
/** A row containing an {@link backendModule.AnyAsset}. */
export default function AssetRow(props: AssetRowProps) {
const {
item: rawItem,
hidden: hiddenRaw,
selected,
isSoleSelected,
isKeyboardSelected,
isOpened,
updateAssetRef,
} = props
const { selected, isSoleSelected, isKeyboardSelected, isOpened } = props
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
const { grabKeyboardFocus, doOpenProject, doCloseProject } = props
const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
const { visibilities } = state
@ -164,8 +152,8 @@ export default function AssetRow(props: AssetRowProps) {
}, [rawItem])
React.useEffect(() => {
// Mutation is HIGHLY INADVISABLE in React, however it is useful here as we want to avoid
// re-rendering the parent.
// Mutation is HIGHLY INADVISABLE in React, however it is useful here as we want to update the
// parent's state while avoiding re-rendering the parent.
rawItem.item = asset
}, [asset, rawItem])
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
@ -242,20 +230,6 @@ export default function AssetRow(props: AssetRowProps) {
oldItem.with({ directoryKey: nonNullNewParentKey, directoryId: nonNullNewParentId })
)
const newParentPath = localBackend.extractTypeAndId(nonNullNewParentId).id
const newProjectState =
asset.projectState == null
? null
: object.merge(
asset.projectState,
asset.projectState.path == null
? {}
: {
path: projectManager.joinPath(
newParentPath,
fileInfo.fileName(asset.projectState.path)
),
}
)
let newId = asset.id
if (!isCloud) {
const oldPath = localBackend.extractTypeAndId(asset.id).id
@ -282,13 +256,9 @@ export default function AssetRow(props: AssetRowProps) {
}
}
}
const newAsset = object.merge(asset, {
// This is SAFE as the type of `newId` is not changed from its original type.
// eslint-disable-next-line no-restricted-syntax
id: newId as never,
parentId: nonNullNewParentId,
projectState: newProjectState,
})
const newAsset = object.merge(asset, { id: newId as never, parentId: nonNullNewParentId })
dispatchAssetListEvent({
type: AssetListEventType.move,
newParentKey: nonNullNewParentKey,
@ -299,11 +269,7 @@ export default function AssetRow(props: AssetRowProps) {
setAsset(newAsset)
await updateAssetMutate([
asset.id,
{
parentDirectoryId: newParentId ?? rootDirectoryId,
description: null,
...(asset.projectState?.path == null ? {} : { projectPath: asset.projectState.path }),
},
{ parentDirectoryId: newParentId ?? rootDirectoryId, description: null },
asset.title,
])
} catch (error) {
@ -381,11 +347,7 @@ export default function AssetRow(props: AssetRowProps) {
// Ignored. The project was already closed.
}
}
await deleteAssetMutate([
asset.id,
{ force: forever, parentId: asset.parentId },
asset.title,
])
await deleteAssetMutate([asset.id, { force: forever }, asset.title])
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
setInsertionVisibility(Visibility.visible)
@ -524,7 +486,7 @@ export default function AssetRow(props: AssetRowProps) {
asset.title,
])
if (details.url != null) {
download.download(details.url, asset.title)
await backend.download(details.url, asset.title)
} else {
const error: unknown = getText('projectHasNoSourceFilesPhrase')
toastAndLog('downloadProjectError', error, asset.title)
@ -541,7 +503,7 @@ export default function AssetRow(props: AssetRowProps) {
asset.title,
])
if (details.url != null) {
download.download(details.url, asset.title)
await backend.download(details.url, asset.title)
} else {
const error: unknown = getText('fileNotFoundPhrase')
toastAndLog('downloadFileError', error, asset.title)
@ -575,9 +537,11 @@ export default function AssetRow(props: AssetRowProps) {
}
} else {
if (asset.type === backendModule.AssetType.project) {
const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id
const uuid = localBackend.extractTypeAndId(asset.id).id
download.download(
`./api/project-manager/projects/${uuid}/enso-project`,
const queryString = new URLSearchParams({ projectsDirectory }).toString()
await backend.download(
`./api/project-manager/projects/${uuid}/enso-project?${queryString}`,
`${asset.title}.enso-project`
)
}
@ -910,8 +874,6 @@ export default function AssetRow(props: AssetRowProps) {
rowState={rowState}
setRowState={setRowState}
isEditable={state.category !== Category.trash}
doOpenProject={doOpenProject}
doCloseProject={doCloseProject}
/>
</td>
)

View File

@ -51,7 +51,12 @@ export default function FileNameColumn(props: FileNameColumnProps) {
const isCloud = backend.type === backendModule.BackendType.remote
const updateFileMutation = backendHooks.useBackendMutation(backend, 'updateFile')
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile')
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile', {
meta: {
invalidates: [['assetVersions', item.item.id, item.item.title]],
awaitInvalidates: true,
},
})
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {

View File

@ -7,11 +7,11 @@ import ArrowUpIcon from 'enso-assets/arrow_up.svg'
import PlayIcon from 'enso-assets/play.svg'
import StopIcon from 'enso-assets/stop.svg'
import * as projectHooks from '#/hooks/projectHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import * as dashboard from '#/pages/dashboard/Dashboard'
import * as ariaComponents from '#/components/AriaComponents'
import Spinner from '#/components/Spinner'
import StatelessSpinner, * as spinner from '#/components/StatelessSpinner'
@ -61,15 +61,15 @@ export interface ProjectIconProps {
readonly backend: Backend
readonly isOpened: boolean
readonly item: backendModule.ProjectAsset
readonly doOpenProject: (id: backendModule.ProjectId, runInBackground: boolean) => void
readonly doCloseProject: (id: backendModule.ProjectId) => void
readonly openProjectTab: (projectId: backendModule.ProjectId) => void
}
/** An interactive icon indicating the status of a project. */
export default function ProjectIcon(props: ProjectIconProps) {
const { backend, item, isOpened } = props
const { openProjectTab, doOpenProject, doCloseProject } = props
const openProject = projectHooks.useOpenProject()
const closeProject = projectHooks.useCloseProject()
const openProjectTab = projectHooks.useOpenEditor()
const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
@ -80,7 +80,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
isLoading,
isError,
} = reactQuery.useQuery({
...dashboard.createGetProjectDetailsQuery.createPassiveListener(item.id),
...projectHooks.createGetProjectDetailsQuery.createPassiveListener(item.id),
select: data => data.state.type,
enabled: isOpened,
})
@ -125,6 +125,16 @@ export default function ProjectIcon(props: ProjectIconProps) {
}
})()
const doOpenProject = () => {
openProject({ ...item, type: backend.type })
}
const doCloseProject = () => {
closeProject({ ...item, type: backend.type })
}
const doOpenProjectTab = () => {
openProjectTab(item.id)
}
switch (state) {
case null:
case backendModule.ProjectState.created:
@ -139,9 +149,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
aria-label={getText('openInEditor')}
tooltipPlacement="left"
extraClickZone="xsmall"
onPress={() => {
doOpenProject(item.id, false)
}}
onPress={doOpenProject}
/>
)
case backendModule.ProjectState.openInProgress:
@ -160,9 +168,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
tooltipPlacement="left"
className={tailwindMerge.twJoin(isRunningInBackground && 'text-green')}
{...(isOtherUserUsingProject ? { title: getText('otherUserIsUsingProjectError') } : {})}
onPress={() => {
doCloseProject(item.id)
}}
onPress={doCloseProject}
/>
<StatelessSpinner
state={spinnerState}
@ -187,9 +193,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
tooltipPlacement="left"
tooltip={isOtherUserUsingProject ? getText('otherUserIsUsingProjectError') : null}
className={tailwindMerge.twMerge(isRunningInBackground && 'text-green')}
onPress={() => {
doCloseProject(item.id)
}}
onPress={doCloseProject}
/>
<Spinner
state={spinner.SpinnerState.done}
@ -208,9 +212,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
icon={ArrowUpIcon}
aria-label={getText('openInEditor')}
tooltipPlacement="right"
onPress={() => {
openProjectTab(item.id)
}}
onPress={doOpenProjectTab}
/>
)}
</div>

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import NetworkIcon from 'enso-assets/network.svg'
import * as backendHooks from '#/hooks/backendHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -53,18 +54,16 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
setRowState,
state,
isEditable,
doCloseProject,
doOpenProject,
backendType,
isOpened,
} = props
const { backend, selectedKeys } = state
const { nodeMap, doOpenEditor } = state
const { backend, selectedKeys, nodeMap } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const doOpenProject = projectHooks.useOpenProject()
if (item.type !== backendModule.AssetType.project) {
// eslint-disable-next-line no-restricted-syntax
@ -96,7 +95,12 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const updateProjectMutation = backendHooks.useBackendMutation(backend, 'updateProject')
const duplicateProjectMutation = backendHooks.useBackendMutation(backend, 'duplicateProject')
const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails')
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile')
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile', {
meta: {
invalidates: [['assetVersions', item.item.id, item.item.title]],
awaitInvalidates: true,
},
})
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {
@ -115,7 +119,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
try {
await updateProjectMutation.mutateAsync([
asset.id,
{ ami: null, ideVersion: null, projectName: newTitle, parentId: asset.parentId },
{ ami: null, ideVersion: null, projectName: newTitle },
asset.title,
])
} catch (error) {
@ -183,9 +187,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
id: createdProject.projectId,
projectState: object.merge(projectState, {
type: backendModule.ProjectState.placeholder,
...(backend.type === backendModule.BackendType.remote
? {}
: { path: createdProject.state.path }),
}),
})
)
@ -334,13 +335,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
// This is a workaround for a temporary bad state in the backend causing the
// `projectState` key to be absent.
item={object.merge(asset, { projectState })}
doCloseProject={id => {
doCloseProject({ id, parentId: asset.parentId, title: asset.title, type: backendType })
}}
doOpenProject={id => {
doOpenProject({ id, type: backendType, parentId: asset.parentId, title: asset.title })
}}
openProjectTab={doOpenEditor}
/>
)}
<EditableSpan

View File

@ -1,8 +1,6 @@
/** @file Column types and column display modes. */
import type * as React from 'react'
import type * as dashboard from '#/pages/dashboard/Dashboard'
import type * as assetsTable from '#/layouts/AssetsTable'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
@ -35,8 +33,6 @@ export interface AssetColumnProps {
readonly rowState: assetsTable.AssetRowState
readonly setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
readonly isEditable: boolean
readonly doOpenProject: (project: dashboard.Project) => void
readonly doCloseProject: (project: dashboard.Project) => void
}
/** Props for a {@link AssetColumn}. */

View File

@ -0,0 +1,370 @@
/** @file Mutations related to project management. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import * as z from 'zod'
import * as eventCallbacks from '#/hooks/eventCallbackHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as projectsProvider from '#/providers/ProjectsProvider'
import * as backendModule from '#/services/Backend'
import type LocalBackend from '#/services/LocalBackend'
import type RemoteBackend from '#/services/RemoteBackend'
import LocalStorage from '#/utilities/LocalStorage'
// ============================
// === Global configuration ===
// ============================
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly launchedProjects: z.infer<typeof LAUNCHED_PROJECT_SCHEMA>
}
}
// =================
// === Constants ===
// =================
const PROJECT_SCHEMA = z
.object({
id: z.custom<backendModule.ProjectId>(x => typeof x === 'string'),
parentId: z.custom<backendModule.DirectoryId>(x => typeof x === 'string'),
title: z.string(),
type: z.nativeEnum(backendModule.BackendType),
})
.readonly()
const LAUNCHED_PROJECT_SCHEMA = z.array(PROJECT_SCHEMA).readonly()
/**
* Launched project information.
*/
export type Project = z.infer<typeof PROJECT_SCHEMA>
/**
* Launched project ID.
*/
export type ProjectId = backendModule.ProjectId
LocalStorage.registerKey('launchedProjects', {
isUserSpecific: true,
schema: LAUNCHED_PROJECT_SCHEMA,
})
// ====================================
// === createGetProjectDetailsQuery ===
// ====================================
/** Options for {@link createGetProjectDetailsQuery}. */
export interface CreateOpenedProjectQueryOptions {
readonly type: backendModule.BackendType
readonly assetId: backendModule.Asset<backendModule.AssetType.project>['id']
readonly parentId: backendModule.Asset<backendModule.AssetType.project>['parentId']
readonly title: backendModule.Asset<backendModule.AssetType.project>['title']
readonly remoteBackend: RemoteBackend
readonly localBackend: LocalBackend | null
}
/** Project status query. */
export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) {
const { assetId, parentId, title, remoteBackend, localBackend, type } = options
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
const isLocal = type === backendModule.BackendType.local
return reactQuery.queryOptions({
queryKey: createGetProjectDetailsQuery.getQueryKey(assetId),
meta: { persist: false },
gcTime: 0,
refetchInterval: ({ state }) => {
/** Default interval for refetching project status when the project is opened. */
const openedIntervalMS = 30_000
/** Interval when we open a cloud project.
* Since opening a cloud project is a long operation, we want to check the status less often. */
const cloudOpeningIntervalMS = 5_000
/** Interval when we open a local project or when we want to sync the project status as soon as
* possible. */
const activeSyncIntervalMS = 100
const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
if (state.status === 'error') {
// eslint-disable-next-line no-restricted-syntax
return false
}
if (isLocal) {
if (state.data?.state.type === backendModule.ProjectState.opened) {
return openedIntervalMS
} else {
return activeSyncIntervalMS
}
} else {
if (state.data == null) {
return activeSyncIntervalMS
} else if (states.includes(state.data.state.type)) {
return openedIntervalMS
} else {
return cloudOpeningIntervalMS
}
}
},
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
queryFn: async () => {
invariant(backend != null, 'Backend is null')
return await backend.getProjectDetails(assetId, parentId, title)
},
})
}
createGetProjectDetailsQuery.getQueryKey = (id: ProjectId) => ['project', id] as const
createGetProjectDetailsQuery.createPassiveListener = (id: ProjectId) =>
reactQuery.queryOptions<backendModule.Project>({
queryKey: createGetProjectDetailsQuery.getQueryKey(id),
})
// ==============================
// === useOpenProjectMutation ===
// ==============================
/** A mutation to open a project. */
export function useOpenProjectMutation() {
const client = reactQuery.useQueryClient()
const session = authProvider.useFullUserSession()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
return reactQuery.useMutation({
mutationKey: ['openProject'],
networkMode: 'always',
mutationFn: ({ title, id, type, parentId }: Project) => {
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.openProject(
id,
{
executeAsync: false,
cognitoCredentials: {
accessToken: session.accessToken,
refreshToken: session.accessToken,
clientId: session.clientId,
expireAt: session.expireAt,
refreshUrl: session.refreshUrl,
},
parentId,
},
title
)
},
onMutate: ({ id }) => {
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
},
onError: async (_, { id }) => {
await client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) })
},
})
}
// ===============================
// === useCloseProjectMutation ===
// ===============================
/** Mutation to close a project. */
export function useCloseProjectMutation() {
const client = reactQuery.useQueryClient()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
return reactQuery.useMutation({
mutationKey: ['closeProject'],
mutationFn: async ({ type, id, title }: Project) => {
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.closeProject(id, title)
},
onMutate: ({ id }) => {
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } })
void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
},
onSuccess: (_, { id }) =>
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
onError: (_, { id }) =>
client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
})
}
// ================================
// === useRenameProjectMutation ===
// ================================
/** Mutation to rename a project. */
export function useRenameProjectMutation() {
const client = reactQuery.useQueryClient()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
return reactQuery.useMutation({
mutationKey: ['renameProject'],
mutationFn: ({ newName, project }: { newName: string; project: Project }) => {
const { type, id, title } = project
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.updateProject(id, { projectName: newName, ami: null, ideVersion: null }, title)
},
onSuccess: (_, { project }) =>
client.invalidateQueries({
queryKey: createGetProjectDetailsQuery.getQueryKey(project.id),
}),
})
}
// ======================
// === useOpenProject ===
// ======================
/** Options for {@link useOpenProject}. */
export interface OpenProjectOptions {
/** Whether to open the project in the background.
* Set to `false` to navigate to the project tab.
* @default true */
readonly openInBackground?: boolean
}
/** A callback to open a project. */
export function useOpenProject() {
const client = reactQuery.useQueryClient()
const projectsStore = projectsProvider.useProjectsStore()
const addLaunchedProject = projectsProvider.useAddLaunchedProject()
const closeAllProjects = useCloseAllProjects()
const openProjectMutation = useOpenProjectMutation()
const openEditor = useOpenEditor()
return eventCallbacks.useEventCallback((project: Project, options: OpenProjectOptions = {}) => {
const { openInBackground = true } = options
// Since multiple tabs cannot be opened at the sametime, the opened projects need to be closed first.
if (projectsStore.getState().launchedProjects.length > 0) {
closeAllProjects()
}
const isOpeningTheSameProject =
client.getMutationCache().find({
mutationKey: ['openProject'],
predicate: mutation => mutation.options.scope?.id === project.id,
})?.state.status === 'pending'
if (!isOpeningTheSameProject) {
openProjectMutation.mutate(project)
const openingProjectMutation = client.getMutationCache().find({
mutationKey: ['openProject'],
// this is unsafe, but we can't do anything about it
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
predicate: mutation => mutation.state.variables?.id === project.id,
})
openingProjectMutation?.setOptions({
...openingProjectMutation.options,
scope: { id: project.id },
})
addLaunchedProject(project)
if (!openInBackground) {
openEditor(project.id)
}
}
})
}
// =====================
// === useOpenEditor ===
// =====================
/** A function to open the editor. */
export function useOpenEditor() {
const setPage = projectsProvider.useSetPage()
return eventCallbacks.useEventCallback((projectId: ProjectId) => {
React.startTransition(() => {
setPage(projectId)
})
})
}
// =======================
// === useCloseProject ===
// =======================
/** A function to close a project. */
export function useCloseProject() {
const client = reactQuery.useQueryClient()
const closeProjectMutation = useCloseProjectMutation()
const removeLaunchedProject = projectsProvider.useRemoveLaunchedProject()
const setPage = projectsProvider.useSetPage()
return eventCallbacks.useEventCallback((project: Project) => {
client
.getMutationCache()
.findAll({
mutationKey: ['openProject'],
predicate: mutation => mutation.options.scope?.id === project.id,
})
.forEach(mutation => {
mutation.setOptions({ ...mutation.options, retry: false })
mutation.destroy()
})
closeProjectMutation.mutate(project)
client
.getMutationCache()
.findAll({
mutationKey: ['closeProject'],
// this is unsafe, but we can't do anything about it
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
predicate: mutation => mutation.state.variables?.id === project.id,
})
.forEach(mutation => {
mutation.setOptions({ ...mutation.options, scope: { id: project.id } })
})
removeLaunchedProject(project.id)
setPage(projectsProvider.TabType.drive)
})
}
// ===========================
// === useCloseAllProjects ===
// ===========================
/** A function to close all projects. */
export function useCloseAllProjects() {
const projectsStore = projectsProvider.useProjectsStore()
const closeProject = useCloseProject()
return eventCallbacks.useEventCallback(() => {
for (const launchedProject of projectsStore.getState().launchedProjects) {
closeProject(launchedProject)
}
})
}

View File

@ -14,6 +14,10 @@ import * as lazyMemo from '#/hooks/useLazyMemoHooks'
import * as safeJsonParse from '#/utilities/safeJsonParse'
// ===================================
// === SearchParamsStateReturnType ===
// ===================================
/**
* The return type of the `useSearchParamsState` hook.
*/
@ -21,6 +25,10 @@ type SearchParamsStateReturnType<T> = Readonly<
[value: T, setValue: (nextValue: React.SetStateAction<T>) => void, clear: () => void]
>
// ============================
// === useSearchParamsState ===
// ============================
/**
* Hook to synchronize a state in the URL search params. It returns the value, a setter and a clear function.
* @param key - The key to store the value in the URL search params.
@ -89,3 +97,72 @@ export function useSearchParamsState<T = unknown>(
return [value, setValue, clear]
}
/**
* Hook to synchronize a state in the URL search params. It returns the value, a setter and a clear function.
* @param key - The key to store the value in the URL search params.
* @param defaultValue - The default value to use if the key is not present in the URL search params.
* @param predicate - A function to check if the value is of the right type.
*/
export function useSearchParamsStateNonReactive<T = unknown>(
key: string,
defaultValue: T | (() => T),
predicate: (unknown: unknown) => unknown is T = (unknown): unknown is T => true
): SearchParamsStateReturnType<T> {
const [searchParams, setSearchParams] = reactRouterDom.useSearchParams()
const prefixedKey = `${appUtils.SEARCH_PARAMS_PREFIX}${key}`
const lazyDefaultValueInitializer = lazyMemo.useLazyMemoHooks(defaultValue, [])
const predicateEventCallback = eventCallback.useEventCallback(predicate)
const clear = eventCallback.useEventCallback((replace: boolean = false) => {
searchParams.delete(prefixedKey)
setSearchParams(searchParams, { replace })
})
const unprefixedValue = searchParams.get(key)
if (unprefixedValue != null) {
searchParams.set(prefixedKey, unprefixedValue)
searchParams.delete(key)
setSearchParams(searchParams)
}
const rawValue = React.useMemo<T>(() => {
const maybeValue = searchParams.get(prefixedKey)
const defaultValueFrom = lazyDefaultValueInitializer()
return maybeValue != null
? safeJsonParse.safeJsonParse(maybeValue, defaultValueFrom, (unknown): unknown is T => true)
: defaultValueFrom
}, [prefixedKey, lazyDefaultValueInitializer, searchParams])
const isValueValid = predicateEventCallback(rawValue)
const value = isValueValid ? rawValue : lazyDefaultValueInitializer()
if (!isValueValid) {
clear(true)
}
/**
* Set the value in the URL search params. If the next value is the same as the default value, it will remove the key from the URL search params.
* Function reference is always the same.
* @param nextValue - The next value to set.
* @returns void
*/
const setValue = eventCallback.useEventCallback((nextValue: React.SetStateAction<T>) => {
if (nextValue instanceof Function) {
nextValue = nextValue(value)
}
if (nextValue === lazyDefaultValueInitializer()) {
clear()
} else {
searchParams.set(prefixedKey, JSON.stringify(nextValue))
setSearchParams(searchParams)
}
})
return [value, setValue, clear]
}

View File

@ -6,6 +6,7 @@ import * as toast from 'react-toastify'
import * as billingHooks from '#/hooks/billing'
import * as copyHooks from '#/hooks/copyHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -17,8 +18,6 @@ import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
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'
@ -36,7 +35,7 @@ import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import * as backendModule from '#/services/Backend'
import * as localBackend from '#/services/LocalBackend'
import * as localBackendModule from '#/services/LocalBackend'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
@ -72,6 +71,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const remoteBackend = backendProvider.useRemoteBackend()
const localBackend = backendProvider.useLocalBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
@ -87,8 +87,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
: isCloud
? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
: asset.type === backendModule.AssetType.project
? asset.projectState.path ?? null
: localBackend.extractTypeAndId(asset.id).id
? localBackend?.getProjectDirectoryPath(asset.id) ?? null
: localBackendModule.extractTypeAndId(asset.id).id
const copyMutation = copyHooks.useCopy({ copyText: path ?? '' })
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
@ -103,7 +103,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const { data } = reactQuery.useQuery(
item.item.type === backendModule.AssetType.project
? dashboard.createGetProjectDetailsQuery.createPassiveListener(item.item.id)
? projectHooks.createGetProjectDetailsQuery.createPassiveListener(item.item.id)
: { queryKey: ['__IGNORED__'] }
)
@ -254,7 +254,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
} else {
try {
const projectResponse = await fetch(
`./api/project-manager/projects/${localBackend.extractTypeAndId(asset.id).id}/enso-project`
`./api/project-manager/projects/${localBackendModule.extractTypeAndId(asset.id).id}/enso-project`
)
// This DOES NOT update the cloud assets list when it
// completes, as the current backend is not the remote
@ -406,9 +406,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
/>
)}
{isCloud && managesThisAsset && self != null && <Separator hidden={hidden} />}
{asset.type === backendModule.AssetType.project && (
<ContextMenuEntry
hidden={hidden}
isDisabled={!isCloud}
action="duplicate"
doAction={() => {
unsetModal()
@ -420,6 +420,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
})
}}
/>
)}
{isCloud && <ContextMenuEntry hidden={hidden} action="copy" doAction={doCopy} />}
{path != null && (
<ContextMenuEntry

View File

@ -9,6 +9,7 @@ import * as backendHooks from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import type Category from '#/layouts/CategorySwitcher/Category'
@ -22,7 +23,7 @@ import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinn
import * as backendModule from '#/services/Backend'
import type Backend from '#/services/Backend'
import * as localBackend from '#/services/LocalBackend'
import * as localBackendModule from '#/services/LocalBackend'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import * as object from '#/utilities/object'
@ -49,6 +50,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const localBackend = backendProvider.useLocalBackend()
const [item, setItemInner] = React.useState(itemRaw)
const [isEditingDescription, setIsEditingDescription] = React.useState(false)
const [queuedDescription, setQueuedDescripion] = React.useState<string | null>(null)
@ -84,8 +86,8 @@ export default function AssetProperties(props: AssetPropertiesProps) {
const path = isCloud
? null
: item.item.type === backendModule.AssetType.project
? item.item.projectState.path ?? null
: localBackend.extractTypeAndId(item.item.id).id
? localBackend?.getProjectDirectoryPath(item.item.id) ?? null
: localBackendModule.extractTypeAndId(item.item.id).id
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
@ -113,14 +115,9 @@ export default function AssetProperties(props: AssetPropertiesProps) {
const oldDescription = item.item.description
setItem(oldItem => oldItem.with({ item: object.merge(oldItem.item, { description }) }))
try {
const projectPath = item.item.projectState?.path
await updateAssetMutation.mutateAsync([
item.item.id,
{
parentDirectoryId: null,
description,
...(projectPath == null ? {} : { projectPath }),
},
{ parentDirectoryId: null, description },
item.item.title,
])
} catch (error) {

View File

@ -9,6 +9,7 @@ import * as mimeTypes from '#/data/mimeTypes'
import * as backendHooks from '#/hooks/backendHooks'
import * as intersectionHooks from '#/hooks/intersectionHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import useOnScroll from '#/hooks/useOnScroll'
@ -18,6 +19,7 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
import * as projectsProvider from '#/providers/ProjectsProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
@ -25,8 +27,6 @@ import AssetEventType from '#/events/AssetEventType'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'
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'
@ -339,7 +339,6 @@ export interface AssetsTableState {
title?: string | null,
override?: boolean
) => void
readonly doOpenEditor: (id: backendModule.ProjectId) => void
readonly doCopy: () => void
readonly doCut: () => void
readonly doPaste: (
@ -358,7 +357,6 @@ export interface AssetRowState {
/** Props for a {@link AssetsTable}. */
export interface AssetsTableProps {
readonly openedProjects: dashboard.Project[]
readonly hidden: boolean
readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
@ -371,12 +369,6 @@ export interface AssetsTableProps {
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
readonly doOpenEditor: (id: dashboard.ProjectId) => void
readonly doOpenProject: (
project: dashboard.Project,
options?: dashboard.OpenProjectOptions
) => void
readonly doCloseProject: (project: dashboard.Project) => void
readonly assetManagementApiRef: React.Ref<AssetManagementApi>
}
@ -390,19 +382,13 @@ export interface AssetManagementApi {
/** The table of project assets. */
export default function AssetsTable(props: AssetsTableProps) {
const {
hidden,
query,
setQuery,
setCanDownload,
category,
openedProjects,
assetManagementApiRef,
} = props
const { hidden, query, setQuery, setCanDownload, category, assetManagementApiRef } = props
const { setSuggestions, initialProjectName } = props
const { doOpenEditor, doOpenProject, doCloseProject } = props
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
const openedProjects = projectsProvider.useLaunchedProjects()
const doOpenProject = projectHooks.useOpenProject()
const { user } = authProvider.useNonPartialUserSession()
const backend = backendProvider.useBackend(category)
const labels = backendHooks.useBackendListTags(backend)
@ -412,12 +398,12 @@ export default function AssetsTable(props: AssetsTableProps) {
const inputBindings = inputBindingsProvider.useInputBindings()
const navigator2D = navigator2DProvider.useNavigator2D()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const previousCategoryRef = React.useRef(category)
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [initialized, setInitialized] = React.useState(false)
const initializedRef = React.useRef(initialized)
initializedRef.current = initialized
const [isLoading, setIsLoading] = React.useState(true)
const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS)
const [sortInfo, setSortInfo] =
React.useState<sorting.SortInfo<columnUtils.SortableColumn> | null>(null)
@ -432,7 +418,7 @@ export default function AssetsTable(props: AssetsTableProps) {
ReadonlySet<backendModule.AssetId>
> | null>(null)
const [, setQueuedAssetEvents] = React.useState<assetEvent.AssetEvent[]>([])
const [, setNameOfProjectToImmediatelyOpen] = React.useState(initialProjectName)
const nameOfProjectToImmediatelyOpenRef = React.useRef(initialProjectName)
const rootDirectoryId = React.useMemo(
() => backend.rootDirectoryId(user) ?? backendModule.DirectoryId(''),
[backend, user]
@ -649,6 +635,9 @@ export default function AssetsTable(props: AssetsTableProps) {
)
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
React.useEffect(() => {
previousCategoryRef.current = category
})
React.useEffect(() => {
if (selectedKeys.size === 0) {
@ -868,10 +857,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [isCloud, assetTree, query, visibilities, labels, setSuggestions])
React.useEffect(() => {
setIsLoading(true)
}, [backend, category])
React.useEffect(() => {
assetTreeRef.current = assetTree
const newNodeMap = new Map(assetTree.preorderTraversal().map(asset => [asset.key, asset]))
@ -899,34 +884,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [hidden, inputBindings, dispatchAssetEvent])
React.useEffect(() => {
if (isLoading) {
setNameOfProjectToImmediatelyOpen(initialProjectName)
} else {
// The project name here might also be a string with project id, e.g. when opening
// a project file from explorer on Windows.
const isInitialProject = (asset: backendModule.AnyAsset) =>
asset.title === initialProjectName || asset.id === initialProjectName
const projectToLoad = assetTree
.preorderTraversal()
.map(node => node.item)
.filter(backendModule.assetIsProject)
.find(isInitialProject)
if (projectToLoad != null) {
doOpenProject({
type: backendModule.BackendType.local,
id: projectToLoad.id,
title: projectToLoad.title,
parentId: projectToLoad.parentId,
})
} else if (initialProjectName != null) {
toastAndLog('findProjectError', null, initialProjectName)
}
}
// This effect MUST only run when `initialProjectName` is changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialProjectName])
const setSelectedKeys = React.useCallback(
(newSelectedKeys: ReadonlySet<backendModule.AssetId>) => {
selectedKeysRef.current = newSelectedKeys
@ -962,56 +919,50 @@ export default function AssetsTable(props: AssetsTableProps) {
const overwriteNodes = React.useCallback(
(newAssets: backendModule.AnyAsset[]) => {
setInitialized(true)
mostRecentlySelectedIndexRef.current = null
selectionStartIndexRef.current = null
// This is required, otherwise we are using an outdated
// `nameOfProjectToImmediatelyOpen`.
setNameOfProjectToImmediatelyOpen(oldNameOfProjectToImmediatelyOpen => {
setInitialized(true)
const nameOfProjectToImmediatelyOpen = nameOfProjectToImmediatelyOpenRef.current
const rootParentDirectoryId = backendModule.DirectoryId('')
const rootDirectory = backendModule.createRootDirectoryAsset(rootDirectoryId)
const newRootNode = new AssetTreeNode(
rootDirectory,
rootParentDirectoryId,
rootParentDirectoryId,
newAssets.map(asset =>
const rootId = rootDirectory.id
const children = newAssets.map(asset =>
AssetTreeNode.fromAsset(
asset,
rootDirectory.id,
rootDirectory.id,
rootId,
rootId,
0,
`${backend.rootPath}/${asset.title}`,
null
)
),
)
const newRootNode = new AssetTreeNode(
rootDirectory,
rootParentDirectoryId,
rootParentDirectoryId,
children,
-1,
backend.rootPath,
null,
rootDirectory.id,
rootId,
true
)
setAssetTree(newRootNode)
// The project name here might also be a string with project id, e.g.
// when opening a project file from explorer on Windows.
const isInitialProject = (asset: backendModule.AnyAsset) =>
asset.title === oldNameOfProjectToImmediatelyOpen ||
asset.id === oldNameOfProjectToImmediatelyOpen
if (oldNameOfProjectToImmediatelyOpen != null) {
const projectToLoad = newAssets
.filter(backendModule.assetIsProject)
.find(isInitialProject)
asset.title === nameOfProjectToImmediatelyOpen ||
asset.id === nameOfProjectToImmediatelyOpen
if (nameOfProjectToImmediatelyOpen != null) {
const projectToLoad = newAssets.filter(backendModule.assetIsProject).find(isInitialProject)
if (projectToLoad != null) {
doOpenProject(
{
type: backendModule.BackendType.local,
id: projectToLoad.id,
title: projectToLoad.title,
parentId: projectToLoad.parentId,
},
{ openInBackground: false }
)
const backendType = backendModule.BackendType.local
const { id, title, parentId } = projectToLoad
doOpenProject({ type: backendType, id, title, parentId }, { openInBackground: false })
} else {
toastAndLog('findProjectError', null, oldNameOfProjectToImmediatelyOpen)
toastAndLog('findProjectError', null, nameOfProjectToImmediatelyOpen)
}
}
setQueuedAssetEvents(oldQueuedAssetEvents => {
@ -1024,8 +975,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
return []
})
return null
})
nameOfProjectToImmediatelyOpenRef.current = null
},
[doOpenProject, rootDirectoryId, backend.rootPath, dispatchAssetEvent, toastAndLog]
)
@ -1054,10 +1004,39 @@ export default function AssetsTable(props: AssetsTableProps) {
],
{ queryKey: [], staleTime: 0, meta: { persist: false } }
)
const isLoading =
rootDirectoryQuery.isLoading || rootDirectoryQuery.isPending || rootDirectoryQuery.isFetching
React.useEffect(() => {
if (isLoading) {
nameOfProjectToImmediatelyOpenRef.current = initialProjectName
} else {
// The project name here might also be a string with project id, e.g. when opening
// a project file from explorer on Windows.
const isInitialProject = (asset: backendModule.AnyAsset) =>
asset.title === initialProjectName || asset.id === initialProjectName
const projectToLoad = assetTree
.preorderTraversal()
.map(node => node.item)
.filter(backendModule.assetIsProject)
.find(isInitialProject)
if (projectToLoad != null) {
doOpenProject({
type: backendModule.BackendType.local,
id: projectToLoad.id,
title: projectToLoad.title,
parentId: projectToLoad.parentId,
})
} else if (initialProjectName != null) {
toastAndLog('findProjectError', null, initialProjectName)
}
}
// This effect MUST only run when `initialProjectName` is changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialProjectName])
React.useEffect(() => {
if (rootDirectoryQuery.data) {
setIsLoading(false)
overwriteNodes(rootDirectoryQuery.data)
}
}, [rootDirectoryQuery.data, overwriteNodes])
@ -1763,9 +1742,6 @@ export default function AssetsTable(props: AssetsTableProps) {
type: backendModule.ProjectState.placeholder,
volumeId: '',
openedBy: user.email,
...(event.original.projectState.path != null
? { path: event.original.projectState.path }
: {}),
},
labels: [],
description: null,
@ -1954,6 +1930,19 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
const handleFileDrop = (event: React.DragEvent) => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: rootDirectoryId,
parentId: rootDirectoryId,
files: Array.from(event.dataTransfer.files),
})
}
}
const state = React.useMemo<AssetsTableState>(
// The type MUST be here to trigger excess property errors at typecheck time.
() => ({
@ -1974,7 +1963,6 @@ export default function AssetsTable(props: AssetsTableProps) {
nodeMap: nodeMapRef,
hideColumn,
doToggleDirectoryExpansion,
doOpenEditor,
doCopy,
doCut,
doPaste,
@ -1988,7 +1976,6 @@ export default function AssetsTable(props: AssetsTableProps) {
sortInfo,
query,
doToggleDirectoryExpansion,
doOpenEditor,
doCopy,
doCut,
doPaste,
@ -2270,7 +2257,7 @@ export default function AssetsTable(props: AssetsTableProps) {
displayItems.map((item, i) => {
const key = AssetTreeNode.getKey(item)
const isSelected = (visuallySelectedKeysOverride ?? selectedKeys).has(key)
const isSoleSelected = selectedKeys.size === 1 && isSelected
const isSoleSelected = isSelected && selectedKeys.size === 1
return (
<AssetRow
@ -2290,8 +2277,6 @@ export default function AssetsTable(props: AssetsTableProps) {
item={item}
state={state}
hidden={hidden || visibilities.get(item.key) === Visibility.hidden}
doOpenProject={doOpenProject}
doCloseProject={doCloseProject}
selected={isSelected}
setSelected={selected => {
setSelectedKeys(set.withPresence(selectedKeysRef.current, key, selected))
@ -2361,8 +2346,6 @@ export default function AssetsTable(props: AssetsTableProps) {
setItem={() => {}}
setRowState={() => {}}
isEditable={false}
doCloseProject={doCloseProject}
doOpenProject={doOpenProject}
/>
))}
</DragModal>
@ -2548,6 +2531,7 @@ export default function AssetsTable(props: AssetsTableProps) {
ids: new Set(filtered.map(dragItem => dragItem.asset.id)),
})
}
handleFileDrop(event)
}}
onClick={() => {
setSelectedKeys(new Set())
@ -2685,16 +2669,7 @@ export default function AssetsTable(props: AssetsTableProps) {
onDragOver={onDropzoneDragOver}
onDrop={event => {
setIsDraggingFiles(false)
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: rootDirectoryId,
parentId: rootDirectoryId,
files: Array.from(event.dataTransfer.files),
})
}
handleFileDrop(event)
}}
>
<SvgMask src={DropFilesImage} className="size-8" />

View File

@ -13,8 +13,6 @@ import * as textProvider from '#/providers/TextProvider'
import AssetListEventType from '#/events/AssetListEventType'
import type * as dashboard from '#/pages/dashboard/Dashboard'
import type * as assetPanel from '#/layouts/AssetPanel'
import AssetPanel from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
@ -62,30 +60,16 @@ enum DriveStatus {
/** Props for a {@link Drive}. */
export interface DriveProps {
readonly openedProjects: dashboard.Project[]
readonly category: Category
readonly setCategory: (category: Category) => void
readonly hidden: boolean
readonly initialProjectName: string | null
readonly doOpenEditor: (id: dashboard.ProjectId) => void
readonly doOpenProject: (project: dashboard.Project) => void
readonly doCloseProject: (project: dashboard.Project) => void
readonly assetsManagementApiRef: React.Ref<assetsTable.AssetManagementApi>
}
/** Contains directory path and directory contents (projects, folders, secrets and files). */
export default function Drive(props: DriveProps) {
const {
openedProjects,
doOpenEditor,
doCloseProject,
category,
setCategory,
hidden,
initialProjectName,
doOpenProject,
assetsManagementApiRef,
} = props
const { category, setCategory, hidden, initialProjectName, assetsManagementApiRef } = props
const { isOffline } = offlineHooks.useOffline()
const { localStorage } = localStorageProvider.useLocalStorage()
@ -99,8 +83,10 @@ export default function Drive(props: DriveProps) {
const [suggestions, setSuggestions] = React.useState<readonly assetSearchBar.Suggestion[]>([])
const [canDownload, setCanDownload] = React.useState(false)
const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false)
const [assetPanelProps, setAssetPanelProps] =
const [assetPanelPropsRaw, setAssetPanelProps] =
React.useState<assetPanel.AssetPanelRequiredProps | null>(null)
const assetPanelProps =
backend.type === assetPanelPropsRaw?.backend?.type ? assetPanelPropsRaw : null
const [isAssetPanelEnabled, setIsAssetPanelEnabled] = React.useState(
() => localStorage.get('isAssetPanelVisible') ?? false
)
@ -326,7 +312,6 @@ export default function Drive(props: DriveProps) {
) : (
<AssetsTable
assetManagementApiRef={assetsManagementApiRef}
openedProjects={openedProjects}
hidden={hidden}
query={query}
setQuery={setQuery}
@ -337,9 +322,6 @@ export default function Drive(props: DriveProps) {
setAssetPanelProps={setAssetPanelProps}
setIsAssetPanelTemporarilyVisible={setIsAssetPanelTemporarilyVisible}
targetDirectoryNodeRef={targetDirectoryNodeRef}
doOpenEditor={doOpenEditor}
doOpenProject={doOpenProject}
doCloseProject={doCloseProject}
/>
)}
</div>

View File

@ -8,12 +8,11 @@ import type * as types from 'enso-common/src/types'
import * as appUtils from '#/appUtils'
import * as gtagHooks from '#/hooks/gtagHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as dashboard from '#/pages/dashboard/Dashboard'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as suspense from '#/components/Suspense'
@ -36,8 +35,8 @@ export interface EditorProps {
readonly isOpening: boolean
readonly isOpeningFailed: boolean
readonly openingError: Error | null
readonly startProject: (project: dashboard.Project) => void
readonly project: dashboard.Project
readonly startProject: (project: projectHooks.Project) => void
readonly project: projectHooks.Project
readonly hidden: boolean
readonly ydocUrl: string | null
readonly appRunner: types.EditorRunner | null
@ -52,7 +51,7 @@ export default function Editor(props: EditorProps) {
const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
const projectStatusQuery = dashboard.createGetProjectDetailsQuery({
const projectStatusQuery = projectHooks.createGetProjectDetailsQuery({
type: project.type,
assetId: project.id,
parentId: project.parentId,
@ -86,11 +85,7 @@ export default function Editor(props: EditorProps) {
}
return (
<div
className={twMerge.twJoin('contents', hidden && 'hidden')}
data-testid="gui-editor-root"
data-testvalue={project.id}
>
<div className={twMerge.twJoin('contents', hidden && 'hidden')} data-testvalue={project.id}>
{(() => {
if (projectQuery.isError) {
return (

View File

@ -6,9 +6,9 @@ import invariant from 'tiny-invariant'
import type * as text from 'enso-common/src/text'
import * as textProvider from '#/providers/TextProvider'
import * as projectHooks from '#/hooks/projectHooks'
import * as dashboard from '#/pages/dashboard/Dashboard'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
@ -167,7 +167,8 @@ const Tabs = React.forwardRef(TabsInternal)
/** Props for a {@link Tab}. */
interface InternalTabProps extends Readonly<React.PropsWithChildren> {
readonly project?: dashboard.Project
readonly 'data-testid'?: string
readonly project?: projectHooks.Project
readonly isActive: boolean
readonly icon: string
readonly labelId: text.TextId
@ -200,7 +201,7 @@ export function Tab(props: InternalTabProps) {
const { isLoading, data } = reactQuery.useQuery<backend.Project>(
project?.id
? dashboard.createGetProjectDetailsQuery.createPassiveListener(project.id)
? projectHooks.createGetProjectDetailsQuery.createPassiveListener(project.id)
: { queryKey: ['__IGNORE__'], queryFn: reactQuery.skipToken }
)
@ -223,6 +224,7 @@ export function Tab(props: InternalTabProps) {
)}
>
<ariaComponents.Button
data-testid={props['data-testid']}
size="custom"
variant="custom"
loaderPosition="icon"

View File

@ -67,7 +67,7 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
}}
>
<aria.Text className="relative">{getText('confirmPrompt', actionText)}</aria.Text>
<ariaComponents.ButtonGroup>
<ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button
size="medium"
variant="delete"

View File

@ -58,7 +58,7 @@ export default function ConfirmDeleteUserModal(props: ConfirmDeleteUserModalProp
<aria.Text className="relative mb-2 text-balance text-center">
{getText('confirmDeleteUserAccountWarning')}
</aria.Text>
<ariaComponents.ButtonGroup className="self-center">
<ariaComponents.ButtonGroup className="relative self-center">
<ariaComponents.Button
size="custom"
variant="custom"

View File

@ -272,7 +272,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
: getText('andOtherProjects', otherProjectsCount)}
</aria.Text>
)}
<ariaComponents.ButtonGroup>
<ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button
variant="submit"
onPress={() => {

View File

@ -86,7 +86,7 @@ export default function EditAssetDescriptionModal(props: EditAssetDescriptionMod
{error && <div className="relative text-sm text-red-500">{error.message}</div>}
<div className="relative flex gap-2">
<ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button variant="submit" type="submit" loading={isPending}>
{actionButtonLabel}
</ariaComponents.Button>
@ -98,7 +98,7 @@ export default function EditAssetDescriptionModal(props: EditAssetDescriptionMod
>
{getText('editAssetDescriptionModalCancel')}
</ariaComponents.Button>
</div>
</ariaComponents.ButtonGroup>
</form>
</Modal>
)

View File

@ -163,7 +163,7 @@ export default function ManageLabelsModal<
{
<FocusArea direction="horizontal">
{innerProps => (
<div className="flex gap-input-with-button" {...innerProps}>
<ariaComponents.ButtonGroup className="relative" {...innerProps}>
<FocusRing within>
<div
className={tailwindMerge.twMerge(
@ -199,7 +199,7 @@ export default function ManageLabelsModal<
>
{getText('create')}
</ariaComponents.Button>
</div>
</ariaComponents.ButtonGroup>
)}
</FocusArea>
}

View File

@ -130,7 +130,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
</ColorPicker>
)}
</FocusArea>
<ariaComponents.ButtonGroup>
<ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button variant="submit" isDisabled={!canSubmit} onPress={doSubmit}>
{getText('create')}
</ariaComponents.Button>

View File

@ -107,7 +107,7 @@ export default function NewUserGroupModal(props: NewUserGroupModalProps) {
</div>
<aria.FieldError className="text-red-700/90">{nameError}</aria.FieldError>
</aria.TextField>
<ariaComponents.ButtonGroup>
<ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button
variant="submit"
isDisabled={!canSubmit}

View File

@ -92,7 +92,7 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
<div className="relative">
<DatalinkInput dropdownTitle="Type" value={value} setValue={setValue} />
</div>
<ariaComponents.ButtonGroup>
<ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button variant="submit" isDisabled={!isSubmittable} onPress={doSubmit}>
{getText('create')}
</ariaComponents.Button>

View File

@ -122,7 +122,7 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
)}
</FocusArea>
</div>
<ariaComponents.ButtonGroup>
<ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button variant="submit" isDisabled={!canSubmit} onPress={doSubmit}>
{isCreatingSecret ? getText('create') : getText('update')}
</ariaComponents.Button>

View File

@ -2,8 +2,6 @@
* interactive components. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import * as validator from 'validator'
import * as z from 'zod'
@ -14,6 +12,7 @@ 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 projectHooks from '#/hooks/projectHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -21,6 +20,7 @@ import * as backendProvider from '#/providers/BackendProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import ProjectsProvider, * as projectsProvider from '#/providers/ProjectsProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
@ -43,10 +43,8 @@ import Page from '#/components/Page'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import * as backendModule from '#/services/Backend'
import type LocalBackend from '#/services/LocalBackend'
import * as localBackendModule from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager'
import type RemoteBackend from '#/services/RemoteBackend'
import * as array from '#/utilities/array'
import LocalStorage from '#/utilities/LocalStorage'
@ -56,51 +54,15 @@ import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
// === Global configuration ===
// ============================
/** Main content of the screen. Only one should be visible at a time. */
enum TabType {
drive = 'drive',
settings = 'settings',
}
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly isAssetPanelVisible: boolean
readonly page: z.infer<typeof PAGES_SCHEMA>
readonly launchedProjects: z.infer<typeof LAUNCHED_PROJECT_SCHEMA>
}
}
LocalStorage.registerKey('isAssetPanelVisible', { schema: z.boolean() })
const PROJECT_SCHEMA = z.object({
id: z.custom<backendModule.ProjectId>(),
parentId: z.custom<backendModule.DirectoryId>(),
title: z.string(),
type: z.nativeEnum(backendModule.BackendType),
})
const LAUNCHED_PROJECT_SCHEMA = z.array(PROJECT_SCHEMA)
/**
* Launched project information.
*/
export type Project = z.infer<typeof PROJECT_SCHEMA>
/**
* Launched project ID.
*/
export type ProjectId = Project['id']
LocalStorage.registerKey('launchedProjects', {
isUserSpecific: true,
schema: LAUNCHED_PROJECT_SCHEMA,
})
const PAGES_SCHEMA = z.nativeEnum(TabType).or(z.custom<backendModule.ProjectId>())
LocalStorage.registerKey('page', {
schema: PAGES_SCHEMA,
})
// =================
// === Dashboard ===
// =================
@ -114,101 +76,13 @@ export interface DashboardProps {
readonly ydocUrl: string | null
}
/**
*
*/
export interface OpenProjectOptions {
/**
* Whether to open the project in the background.
* Set to `false` to navigate to the project tab.
* @default true
*/
readonly openInBackground?: boolean
}
/**
*
*/
export interface CreateOpenedProjectQueryOptions {
readonly type: backendModule.BackendType
readonly assetId: backendModule.Asset<backendModule.AssetType.project>['id']
readonly parentId: backendModule.Asset<backendModule.AssetType.project>['parentId']
readonly title: backendModule.Asset<backendModule.AssetType.project>['title']
readonly remoteBackend: RemoteBackend
readonly localBackend: LocalBackend | null
}
/**
* Project status query.
*/
export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) {
const { assetId, parentId, title, remoteBackend, localBackend, type } = options
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
const isLocal = type === backendModule.BackendType.local
return reactQuery.queryOptions({
queryKey: createGetProjectDetailsQuery.getQueryKey(assetId),
meta: { persist: false },
gcTime: 0,
refetchInterval: ({ state }) => {
/**
* Default interval for refetching project status when the project is opened.
*/
const openedIntervalMS = 30_000
/**
* Interval when we open a cloud project.
* Since opening a cloud project is a long operation, we want to check the status less often.
*/
const cloudOpeningIntervalMS = 5_000
/**
* Interval when we open a local project or when we want to sync the project status as soon as possible.
*/
const activeSyncIntervalMS = 100
const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
if (state.status === 'error') {
// eslint-disable-next-line no-restricted-syntax
return false
}
if (isLocal) {
if (state.data?.state.type === backendModule.ProjectState.opened) {
return openedIntervalMS
} else {
return activeSyncIntervalMS
}
} else {
if (state.data == null) {
return activeSyncIntervalMS
} else if (states.includes(state.data.state.type)) {
return openedIntervalMS
} else {
return cloudOpeningIntervalMS
}
}
},
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
queryFn: () => {
invariant(backend != null, 'Backend is null')
return backend.getProjectDetails(assetId, parentId, title)
},
})
}
createGetProjectDetailsQuery.getQueryKey = (id: Project['id']) => ['project', id] as const
createGetProjectDetailsQuery.createPassiveListener = (id: Project['id']) =>
reactQuery.queryOptions<backendModule.Project>({
queryKey: createGetProjectDetailsQuery.getQueryKey(id),
})
/** The component that contains the entire UI. */
export default function Dashboard(props: DashboardProps) {
return (
<EventListProvider>
<ProjectsProvider>
<DashboardInner {...props} />
</ProjectsProvider>
</EventListProvider>
)
}
@ -216,8 +90,7 @@ export default function Dashboard(props: DashboardProps) {
/** 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 { user } = authProvider.useFullUserSession()
const localBackend = backendProvider.useLocalBackend()
const { getText } = textProvider.useText()
const { modalRef } = modalProvider.useModalRef()
@ -251,52 +124,10 @@ function DashboardInner(props: DashboardProps) {
)
const isCloud = categoryModule.isCloud(category)
const [launchedProjects, privateSetLaunchedProjects] = React.useState<Project[]>(
() => localStorage.get('launchedProjects') ?? []
)
// These pages MUST be ROUTER PAGES.
const [page, privateSetPage] = searchParamsState.useSearchParamsState(
'page',
() => localStorage.get('page') ?? TabType.drive,
(value: unknown): value is Project['id'] | TabType => {
return (
array.includes(Object.values(TabType), value) || launchedProjects.some(p => p.id === value)
)
}
)
const page = projectsProvider.usePage()
const launchedProjects = projectsProvider.useLaunchedProjects()
const selectedProject = launchedProjects.find(p => p.id === page) ?? null
const setLaunchedProjects = eventCallbacks.useEventCallback(
(fn: (currentState: Project[]) => Project[]) => {
React.startTransition(() => {
privateSetLaunchedProjects(currentState => {
const nextState = fn(currentState)
localStorage.set('launchedProjects', nextState)
return nextState
})
})
}
)
const addLaunchedProject = eventCallbacks.useEventCallback((project: Project) => {
setLaunchedProjects(currentState => [...currentState, project])
})
const removeLaunchedProject = eventCallbacks.useEventCallback((projectId: Project['id']) => {
setLaunchedProjects(currentState => currentState.filter(({ id }) => id !== projectId))
})
const clearLaunchedProjects = eventCallbacks.useEventCallback(() => {
setLaunchedProjects(() => [])
})
const setPage = eventCallbacks.useEventCallback((nextPage: Project['id'] | TabType) => {
privateSetPage(nextPage)
localStorage.set('page', nextPage)
})
if (isCloud && !isUserEnabled && localBackend != null) {
setTimeout(() => {
// This sets `BrowserRouter`, so it must not be set synchronously.
@ -304,92 +135,20 @@ function DashboardInner(props: DashboardProps) {
})
}
const openProjectMutation = reactQuery.useMutation({
mutationKey: ['openProject'],
networkMode: 'always',
mutationFn: ({ title, id, type, parentId }: Project) => {
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.openProject(
id,
{
executeAsync: false,
cognitoCredentials: {
accessToken: session.accessToken,
refreshToken: session.accessToken,
clientId: session.clientId,
expireAt: session.expireAt,
refreshUrl: session.refreshUrl,
},
parentId,
},
title
)
},
onMutate: ({ id }) => {
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
},
onError: async (_, { id }) => {
await client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) })
},
})
const closeProjectMutation = reactQuery.useMutation({
mutationKey: ['closeProject'],
mutationFn: async ({ type, id, title }: Project) => {
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.closeProject(id, title)
},
onMutate: ({ id }) => {
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } })
void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
},
onSuccess: (_, { id }) =>
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
onError: (_, { id }) =>
client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
})
const client = reactQuery.useQueryClient()
const renameProjectMutation = reactQuery.useMutation({
mutationFn: ({ newName, project }: { newName: string; project: Project }) => {
const { parentId, type, id, title } = project
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.updateProject(
id,
{ projectName: newName, ami: null, ideVersion: null, parentId },
title
)
},
onSuccess: (_, { project }) =>
client.invalidateQueries({
queryKey: createGetProjectDetailsQuery.getQueryKey(project.id),
}),
})
const setPage = projectsProvider.useSetPage()
const openEditor = projectHooks.useOpenEditor()
const openProject = projectHooks.useOpenProject()
const closeProject = projectHooks.useCloseProject()
const closeAllProjects = projectHooks.useCloseAllProjects()
const clearLaunchedProjects = projectsProvider.useClearLaunchedProjects()
const openProjectMutation = projectHooks.useOpenProjectMutation()
const renameProjectMutation = projectHooks.useRenameProjectMutation()
eventListProvider.useAssetEventListener(event => {
switch (event.type) {
case AssetEventType.openProject: {
const { title, parentId, backendType, id, runInBackground } = event
doOpenProject(
openProject(
{ title, parentId, type: backendType, id },
{ openInBackground: runInBackground }
)
@ -397,7 +156,7 @@ function DashboardInner(props: DashboardProps) {
}
case AssetEventType.closeProject: {
const { title, parentId, backendType, id } = event
doCloseProject({ title, parentId, type: backendType, id })
closeProject({ title, parentId, type: backendType, id })
break
}
default: {
@ -416,7 +175,7 @@ function DashboardInner(props: DashboardProps) {
updateModal(oldModal => {
if (oldModal == null) {
queueMicrotask(() => {
setPage(localStorage.get('page') ?? TabType.drive)
setPage(localStorage.get('page') ?? projectsProvider.TabType.drive)
})
return oldModal
} else {
@ -447,96 +206,14 @@ function DashboardInner(props: DashboardProps) {
}
}, [inputBindings])
const doOpenProject = eventCallbacks.useEventCallback(
(project: Project, options: OpenProjectOptions = {}) => {
const { openInBackground = true } = options
// since we don't support multitabs, we need to close opened project first
if (launchedProjects.length > 0) {
doCloseAllProjects()
}
const isOpeningTheSameProject =
client.getMutationCache().find({
mutationKey: ['openProject'],
predicate: mutation => mutation.options.scope?.id === project.id,
})?.state.status === 'pending'
if (!isOpeningTheSameProject) {
openProjectMutation.mutate(project)
const openingProjectMutation = client.getMutationCache().find({
mutationKey: ['openProject'],
// this is unsafe, but we can't do anything about it
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
predicate: mutation => mutation.state.variables?.id === project.id,
})
openingProjectMutation?.setOptions({
...openingProjectMutation.options,
scope: { id: project.id },
})
addLaunchedProject(project)
if (!openInBackground) {
doOpenEditor(project.id)
}
}
}
)
const doOpenEditor = eventCallbacks.useEventCallback((projectId: Project['id']) => {
React.startTransition(() => {
setPage(projectId)
})
})
const doCloseProject = eventCallbacks.useEventCallback((project: Project) => {
client
.getMutationCache()
.findAll({
mutationKey: ['openProject'],
predicate: mutation => mutation.options.scope?.id === project.id,
})
.forEach(mutation => {
mutation.setOptions({ ...mutation.options, retry: false })
mutation.destroy()
})
closeProjectMutation.mutate(project)
client
.getMutationCache()
.findAll({
mutationKey: ['closeProject'],
// this is unsafe, but we can't do anything about it
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
predicate: mutation => mutation.state.variables?.id === project.id,
})
.forEach(mutation => {
mutation.setOptions({ ...mutation.options, scope: { id: project.id } })
})
removeLaunchedProject(project.id)
setPage(TabType.drive)
})
const doCloseAllProjects = eventCallbacks.useEventCallback(() => {
for (const launchedProject of launchedProjects) {
doCloseProject(launchedProject)
}
})
const doRemoveSelf = eventCallbacks.useEventCallback((project: Project) => {
const doRemoveSelf = eventCallbacks.useEventCallback((project: projectHooks.Project) => {
dispatchAssetListEvent({ type: AssetListEventType.removeSelf, id: project.id })
doCloseProject(project)
closeProject(project)
})
const onSignOut = eventCallbacks.useEventCallback(() => {
setPage(TabType.drive)
doCloseAllProjects()
setPage(projectsProvider.TabType.drive)
closeAllProjects()
clearLaunchedProjects()
})
@ -580,11 +257,11 @@ function DashboardInner(props: DashboardProps) {
<div className="flex">
<TabBar>
<tabBar.Tab
isActive={page === TabType.drive}
isActive={page === projectsProvider.TabType.drive}
icon={DriveIcon}
labelId="drivePageName"
onPress={() => {
setPage(TabType.drive)
setPage(projectsProvider.TabType.drive)
}}
>
{getText('drivePageName')}
@ -592,6 +269,7 @@ function DashboardInner(props: DashboardProps) {
{launchedProjects.map(project => (
<tabBar.Tab
data-testid="editor-tab-button"
project={project}
key={project.id}
isActive={page === project.id}
@ -601,26 +279,26 @@ function DashboardInner(props: DashboardProps) {
setPage(project.id)
}}
onClose={() => {
doCloseProject(project)
closeProject(project)
}}
onLoadEnd={() => {
doOpenEditor(project.id)
openEditor(project.id)
}}
>
{project.title}
</tabBar.Tab>
))}
{page === TabType.settings && (
{page === projectsProvider.TabType.settings && (
<tabBar.Tab
isActive
icon={SettingsIcon}
labelId="settingsPageName"
onPress={() => {
setPage(TabType.settings)
setPage(projectsProvider.TabType.settings)
}}
onClose={() => {
setPage(TabType.drive)
setPage(projectsProvider.TabType.drive)
}}
>
{getText('settingsPageName')}
@ -632,7 +310,7 @@ function DashboardInner(props: DashboardProps) {
onShareClick={selectedProject ? doOpenShareModal : undefined}
setIsHelpChatOpen={setIsHelpChatOpen}
goToSettingsPage={() => {
setPage(TabType.settings)
setPage(projectsProvider.TabType.settings)
}}
onSignOut={onSignOut}
/>
@ -640,14 +318,10 @@ function DashboardInner(props: DashboardProps) {
<Drive
assetsManagementApiRef={assetManagementApiRef}
openedProjects={launchedProjects}
category={category}
setCategory={setCategory}
hidden={page !== TabType.drive}
hidden={page !== projectsProvider.TabType.drive}
initialProjectName={initialProjectName}
doOpenProject={doOpenProject}
doOpenEditor={doOpenEditor}
doCloseProject={doCloseProject}
/>
{launchedProjects.map(project => (
@ -668,7 +342,7 @@ function DashboardInner(props: DashboardProps) {
/>
))}
{page === TabType.settings && <Settings />}
{page === projectsProvider.TabType.settings && <Settings />}
{process.env.ENSO_CLOUD_CHAT_URL != null ? (
<Chat
isOpen={isHelpChatOpen}

View File

@ -0,0 +1,238 @@
/** @file The React provider (and associated hooks) for providing reactive events. */
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as z from 'zod'
import * as zustand from 'zustand'
import * as eventCallbacks from '#/hooks/eventCallbackHooks'
import type * as projectHooks from '#/hooks/projectHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as array from '#/utilities/array'
import LocalStorage from '#/utilities/LocalStorage'
// ===============
// === TabType ===
// ===============
/** Main content of the screen. Only one should be visible at a time. */
export enum TabType {
drive = 'drive',
settings = 'settings',
}
// ============================
// === Global configuration ===
// ============================
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly isAssetPanelVisible: boolean
readonly page: z.infer<typeof PAGES_SCHEMA>
}
}
const PAGES_SCHEMA = z
.nativeEnum(TabType)
.or(z.custom<projectHooks.ProjectId>(value => typeof value === 'string'))
LocalStorage.registerKey('page', { schema: PAGES_SCHEMA })
// =====================
// === ProjectsStore ===
// =====================
/** The state of this zustand store. */
interface ProjectsStore {
readonly page: projectHooks.ProjectId | TabType
readonly setPage: (page: projectHooks.ProjectId | TabType) => void
readonly launchedProjects: readonly projectHooks.Project[]
readonly addLaunchedProject: (project: projectHooks.Project) => void
readonly removeLaunchedProject: (projectId: projectHooks.ProjectId) => void
readonly clearLaunchedProjects: () => void
}
// =======================
// === ProjectsContext ===
// =======================
/** State contained in a `ProjectsContext`. */
export interface ProjectsContextType extends zustand.StoreApi<ProjectsStore> {}
const ProjectsContext = React.createContext<ProjectsContextType | null>(null)
/** Props for a {@link ProjectsProvider}. */
export interface ProjectsProviderProps extends Readonly<React.PropsWithChildren> {}
// ========================
// === ProjectsProvider ===
// ========================
/** A React provider (and associated hooks) for determining whether the current area
* containing the current element is focused. */
export default function ProjectsProvider(props: ProjectsProviderProps) {
const { children } = props
const { localStorage } = localStorageProvider.useLocalStorage()
const [store] = React.useState(() => {
return zustand.createStore<ProjectsStore>(set => ({
page: TabType.drive,
setPage: page => {
set({ page })
},
launchedProjects: localStorage.get('launchedProjects') ?? [],
addLaunchedProject: project => {
set(({ launchedProjects }) => ({ launchedProjects: [...launchedProjects, project] }))
},
removeLaunchedProject: projectId => {
set(({ launchedProjects }) => ({
launchedProjects: launchedProjects.filter(({ id }) => id !== projectId),
}))
},
clearLaunchedProjects: () => {
set({ launchedProjects: [] })
},
}))
})
return (
<ProjectsContext.Provider value={store}>
<PageSynchronizer />
{children}
</ProjectsContext.Provider>
)
}
// ========================
// === PageSynchronizer ===
// ========================
/** A component to synchronize React state with search parmas state. */
function PageSynchronizer() {
const { localStorage } = localStorageProvider.useLocalStorage()
const store = useProjectsStore()
const providerSetPage = useSetPage()
const [page, privateSetPage] = searchParamsState.useSearchParamsState(
'page',
() => store.getState().page,
(value: unknown): value is projectHooks.ProjectId | TabType => {
return (
array.includes(Object.values(TabType), value) ||
store.getState().launchedProjects.some(p => p.id === value)
)
}
)
React.useEffect(() => {
providerSetPage(page)
}, [page, providerSetPage])
React.useEffect(() =>
store.subscribe(state => {
privateSetPage(state.page)
})
)
React.useEffect(() =>
store.subscribe(state => {
localStorage.set('launchedProjects', state.launchedProjects)
})
)
return null
}
// ========================
// === useProjectsStore ===
// ========================
/** The projects store. */
export function useProjectsStore() {
const store = React.useContext(ProjectsContext)
invariant(store, 'Projects store store can only be used inside an `ProjectsProvider`.')
return store
}
// =============================
// === useAddLaunchedProject ===
// =============================
/** A function to retrieve all launched projects. */
export function useLaunchedProjects() {
const store = useProjectsStore()
return zustand.useStore(store, state => state.launchedProjects)
}
// =============================
// === useAddLaunchedProject ===
// =============================
/** A function to add a new launched projoect. */
export function useAddLaunchedProject() {
const store = useProjectsStore()
const addLaunchedProject = zustand.useStore(store, state => state.addLaunchedProject)
return eventCallbacks.useEventCallback((project: projectHooks.Project) => {
React.startTransition(() => {
addLaunchedProject(project)
})
})
}
// ================================
// === useRemoveLaunchedProject ===
// ================================
/** A function to remove a launched project. */
export function useRemoveLaunchedProject() {
const store = useProjectsStore()
const removeLaunchedProject = zustand.useStore(store, state => state.removeLaunchedProject)
return eventCallbacks.useEventCallback((projectId: projectHooks.ProjectId) => {
React.startTransition(() => {
removeLaunchedProject(projectId)
})
})
}
// ================================
// === useClearLaunchedProjects ===
// ================================
/** A function to remove all launched projects. */
export function useClearLaunchedProjects() {
const store = useProjectsStore()
const clearLaunchedProjects = zustand.useStore(store, state => state.clearLaunchedProjects)
return eventCallbacks.useEventCallback(() => {
React.startTransition(() => {
clearLaunchedProjects()
})
})
}
// ===============
// === usePage ===
// ===============
/** A function to get the current page. */
export function usePage() {
const store = useProjectsStore()
return zustand.useStore(store, state => state.page)
}
// ==================
// === useSetPage ===
// ==================
/** A function to set the current page. */
export function useSetPage() {
const store = useProjectsStore()
const setPage = zustand.useStore(store, state => state.setPage)
return eventCallbacks.useEventCallback((page: projectHooks.ProjectId | TabType) => {
React.startTransition(() => {
setPage(page)
})
})
}

View File

@ -9,6 +9,7 @@ import type ProjectManager from '#/services/ProjectManager'
import * as appBaseUrl from '#/utilities/appBaseUrl'
import * as dateTime from '#/utilities/dateTime'
import * as download from '#/utilities/download'
import * as errorModule from '#/utilities/error'
import * as fileInfo from '#/utilities/fileInfo'
@ -162,7 +163,6 @@ export default class LocalBackend extends Backend {
this.projectManager.projects.get(entry.metadata.id)?.state ??
backend.ProjectState.closed,
volumeId: '',
path: entry.path,
},
labels: [],
description: null,
@ -193,7 +193,7 @@ export default class LocalBackend extends Backend {
const result = await this.projectManager.listProjects({})
return result.projects.map(project => ({
name: project.name,
organizationId: '',
organizationId: backend.OrganizationId(''),
projectId: newProjectId(project.id),
packageName: project.name,
state: {
@ -218,20 +218,12 @@ export default class LocalBackend extends Backend {
missingComponentAction: projectManager.MissingComponentAction.install,
...(projectsDirectory == null ? {} : { projectsDirectory }),
})
const path = projectManager.joinPath(
projectsDirectory ?? this.projectManager.rootDirectory,
project.projectNormalizedName
)
return {
name: project.projectName,
organizationId: '',
organizationId: backend.OrganizationId(''),
projectId: newProjectId(project.projectId),
packageName: project.projectName,
state: {
type: backend.ProjectState.closed,
volumeId: '',
path,
},
state: { type: backend.ProjectState.closed, volumeId: '' },
}
}
@ -293,7 +285,7 @@ export default class LocalBackend extends Backend {
ideVersion: version,
jsonAddress: null,
binaryAddress: null,
organizationId: '',
organizationId: backend.OrganizationId(''),
packageName: project.name,
projectId,
state: { type: backend.ProjectState.closed, volumeId: '' },
@ -313,7 +305,7 @@ export default class LocalBackend extends Backend {
},
jsonAddress: ipWithSocketToAddress(cachedProject.languageServerJsonAddress),
binaryAddress: ipWithSocketToAddress(cachedProject.languageServerBinaryAddress),
organizationId: '',
organizationId: backend.OrganizationId(''),
packageName: cachedProject.projectNormalizedName,
projectId,
state: {
@ -364,10 +356,9 @@ export default class LocalBackend extends Backend {
await this.projectManager.renameProject({
projectId: id,
name: projectManager.ProjectName(body.projectName),
projectsDirectory: extractTypeAndId(body.parentId).id,
})
}
const parentId = extractTypeAndId(body.parentId).id
const parentId = this.projectManager.getProjectDirectoryPath(id)
const result = await this.projectManager.listDirectory(parentId)
const project = result.flatMap(listedProject =>
listedProject.type === projectManager.FileSystemEntryType.ProjectEntry &&
@ -390,18 +381,31 @@ export default class LocalBackend extends Backend {
engineVersion: version,
ideVersion: version,
name: project.name,
organizationId: '',
organizationId: backend.OrganizationId(''),
projectId,
}
}
}
}
/** Duplicate a specific version of a project. */
override async duplicateProject(projectId: backend.ProjectId): Promise<backend.CreatedProject> {
const id = extractTypeAndId(projectId).id
const project = await this.projectManager.duplicateProject({ projectId: id })
return {
projectId: newProjectId(project.projectId),
name: project.projectName,
packageName: project.projectNormalizedName,
organizationId: backend.OrganizationId(''),
state: { type: backend.ProjectState.closed, volumeId: '' },
}
}
/** Delete an arbitrary asset.
* @throws An error if the JSON-RPC call fails. */
override async deleteAsset(
assetId: backend.AssetId,
body: backend.DeleteAssetRequestBody,
_body: backend.DeleteAssetRequestBody,
title: string | null
): Promise<void> {
const typeAndId = extractTypeAndId(assetId)
@ -413,10 +417,7 @@ export default class LocalBackend extends Backend {
}
case backend.AssetType.project: {
try {
await this.projectManager.deleteProject({
projectId: typeAndId.id,
projectsDirectory: extractTypeAndId(body.parentId).id,
})
await this.projectManager.deleteProject({ projectId: typeAndId.id })
return
} catch (error) {
throw new Error(
@ -429,10 +430,30 @@ export default class LocalBackend extends Backend {
}
}
/** Copy an arbitrary asset to another directory. Not yet implemented in the backend.
* @throws {Error} Always. */
override copyAsset(): Promise<backend.CopyAssetResponse> {
throw new Error('Cannot copy assets in local backend yet.')
/** Copy an arbitrary asset to another directory. */
override async copyAsset(
assetId: backend.AssetId,
parentDirectoryId: backend.DirectoryId
): Promise<backend.CopyAssetResponse> {
const typeAndId = extractTypeAndId(assetId)
if (typeAndId.type !== backend.AssetType.project) {
throw new Error('Only projects can be copied on the Local Backend.')
} else {
const project = await this.projectManager.duplicateProject({ projectId: typeAndId.id })
const projectPath = this.projectManager.projectPaths.get(typeAndId.id)
const parentPath =
projectPath == null ? null : projectManager.getDirectoryAndName(projectPath).directoryPath
if (parentPath !== extractTypeAndId(parentDirectoryId).id) {
throw new Error('Cannot duplicate project to a different directory on the Local Backend.')
} else {
const asset = {
id: newProjectId(project.projectId),
parentId: parentDirectoryId,
title: project.projectName,
}
return { asset }
}
}
}
/** Return a list of engine versions. */
@ -559,15 +580,13 @@ export default class LocalBackend extends Backend {
): Promise<void> {
if (body.parentDirectoryId != null) {
const typeAndId = extractTypeAndId(assetId)
const from = typeAndId.type === backend.AssetType.project ? body.projectPath : typeAndId.id
if (from == null) {
throw new Error('Could not move project: project has no `projectPath`.')
} else {
const from =
typeAndId.type !== backend.AssetType.project
? typeAndId.id
: this.projectManager.getProjectDirectoryPath(typeAndId.id)
const fileName = fileInfo.fileName(from)
const to = projectManager.joinPath(extractTypeAndId(body.parentDirectoryId).id, fileName)
await this.projectManager.moveFile(from, to)
return
}
}
}
@ -604,13 +623,10 @@ export default class LocalBackend extends Backend {
const to = projectManager.joinPath(projectManager.Path(folderPath), body.title)
await this.projectManager.moveFile(from, to)
}
/** Return a {@link Promise} that resolves only when a project is ready to open. */
override async waitUntilProjectIsReady(
projectId: backend.ProjectId,
directory: backend.DirectoryId | null,
title: string
) {
return await this.getProjectDetails(projectId, directory, title)
/** Construct a new path using the given parent directory and a file name. */
getProjectDirectoryPath(id: backend.ProjectId) {
return this.projectManager.getProjectDirectoryPath(extractTypeAndId(id).id)
}
/** Construct a new path using the given parent directory and a file name. */
@ -634,9 +650,10 @@ export default class LocalBackend extends Backend {
}
}
/** Invalid operation. */
override duplicateProject() {
return this.invalidOperation()
/** Download from an arbitrary URL that is assumed to originate from this backend. */
override async download(url: string, name?: string) {
download.download(url, name)
return Promise.resolve()
}
/** Invalid operation. */

View File

@ -6,7 +6,7 @@ import * as detect from 'enso-common/src/detect'
import * as backend from '#/services/Backend'
import * as appBaseUrl from '#/utilities/appBaseUrl'
import type * as dateTime from '#/utilities/dateTime'
import * as dateTime from '#/utilities/dateTime'
import * as newtype from '#/utilities/newtype'
// =================
@ -178,7 +178,14 @@ export interface EngineVersion {
/** The return value of the "list available engine versions" endpoint. */
export interface VersionList {
readonly versions: EngineVersion[]
readonly versions: readonly EngineVersion[]
}
/** The return value of the "duplicate project" endpoint. */
export interface DuplicatedProject {
readonly projectId: UUID
readonly projectName: string
readonly projectNormalizedName: string
}
// ====================
@ -231,13 +238,19 @@ export interface CreateProjectParams {
readonly projectsDirectory?: Path
}
/** Parameters for the "list samples" endpoint. */
/** Parameters for the "rename project" endpoint. */
export interface RenameProjectParams {
readonly projectId: UUID
readonly name: ProjectName
readonly projectsDirectory?: Path
}
/** Parameters for the "duplicate project" endpoint. */
export interface DuplicateProjectParams {
readonly projectId: UUID
readonly projectsDirectory?: Path
}
/** Parameters for the "delete project" endpoint. */
export interface DeleteProjectParams {
readonly projectId: UUID
@ -266,6 +279,16 @@ function normalizeSlashes(path: string): Path {
}
}
// ===========================
// === getDirectoryAndName ===
// ===========================
/** Split a {@link Path} inito the path of its parent directory, and its file name. */
export function getDirectoryAndName(path: Path) {
const [, directoryPath = '', fileName = ''] = path.match(/^(.+)[/]([^/]+)$/) ?? []
return { directoryPath: Path(directoryPath), fileName }
}
// =======================
// === Project Manager ===
// =======================
@ -280,10 +303,19 @@ export enum ProjectManagerEvents {
* It should always be in sync with the Rust interface at
* `app/gui/controller/engine-protocol/src/project_manager.rs`. */
export default class ProjectManager {
// This is required so that projects get recursively updated (deleted, renamed or moved).
private readonly internalDirectories = new Map<Path, readonly FileSystemEntry[]>()
private readonly internalProjects = new Map<UUID, ProjectState>()
private readonly internalProjectPaths = new Map<UUID, Path>()
// This MUST be declared after `internalDirectories` because it depends on `internalDirectories`.
// eslint-disable-next-line @typescript-eslint/member-ordering
readonly directories: ReadonlyMap<UUID, ProjectState> = this.internalProjects
// This MUST be declared after `internalProjects` because it depends on `internalProjects`.
// eslint-disable-next-line @typescript-eslint/member-ordering
readonly projects: ReadonlyMap<UUID, ProjectState> = this.internalProjects
// This MUST be declared after `internalProjectPaths` because it depends on `internalProjectPaths`.
// eslint-disable-next-line @typescript-eslint/member-ordering
readonly projectPaths: ReadonlyMap<UUID, Path> = this.internalProjectPaths
private id = 0
private resolvers = new Map<number, (value: never) => void>()
private rejecters = new Map<number, (reason?: JSONRPCError) => void>()
@ -355,6 +387,12 @@ export default class ProjectManager {
socket.close()
}
/** Get the directory path of a project. */
getProjectDirectoryPath(projectId: UUID) {
const projectPath = this.internalProjectPaths.get(projectId)
return projectPath == null ? this.rootDirectory : getDirectoryAndName(projectPath).directoryPath
}
/** Open an existing project. */
async openProject(params: OpenProjectParams): Promise<OpenProject> {
const cached = this.internalProjects.get(params.projectId)
@ -388,21 +426,67 @@ export default class ProjectManager {
/** Create a new project. */
async createProject(params: CreateProjectParams): Promise<CreateProject> {
return this.sendRequest<CreateProject>('project/create', {
const result = await this.sendRequest<CreateProject>('project/create', {
missingComponentAction: MissingComponentAction.install,
...params,
})
const directoryPath = params.projectsDirectory ?? this.rootDirectory
// Update `internalDirectories` by listing the project's parent directory, because the
// directory name of the project is unknown. Deleting the directory is not an option because
// that will prevent ALL descendants of the parent directory from being updated.
await this.listDirectory(directoryPath)
return result
}
/** Rename a project. */
async renameProject(params: RenameProjectParams): Promise<void> {
return this.sendRequest('project/rename', params)
async renameProject(params: Omit<RenameProjectParams, 'projectsDirectory'>): Promise<void> {
const path = this.internalProjectPaths.get(params.projectId)
const directoryPath =
path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath
const fullParams: RenameProjectParams = { ...params, projectsDirectory: directoryPath }
await this.sendRequest('project/rename', fullParams)
// Update `internalDirectories` by listing the project's parent directory, because the new
// directory name of the project is unknown. Deleting the directory is not an option because
// that will prevent ALL descendants of the parent directory from being updated.
await this.listDirectory(directoryPath)
}
/** Duplicate a project. */
async duplicateProject(
params: Omit<DuplicateProjectParams, 'projectsDirectory'>
): Promise<DuplicatedProject> {
const path = this.internalProjectPaths.get(params.projectId)
const directoryPath =
path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath
const fullParams: DuplicateProjectParams = { ...params, projectsDirectory: directoryPath }
const result = this.sendRequest<DuplicatedProject>('project/duplicate', fullParams)
// Update `internalDirectories` by listing the project's parent directory, because the
// directory name of the project is unknown. Deleting the directory is not an option because
// that will prevent ALL descendants of the parent directory from being updated.
await this.listDirectory(directoryPath)
return result
}
/** Delete a project. */
async deleteProject(params: DeleteProjectParams): Promise<void> {
async deleteProject(params: Omit<DeleteProjectParams, 'projectsDirectory'>): Promise<void> {
const path = this.internalProjectPaths.get(params.projectId)
const directoryPath =
path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath
const fullParams: DeleteProjectParams = { ...params, projectsDirectory: directoryPath }
await this.sendRequest('project/delete', fullParams)
this.internalProjectPaths.delete(params.projectId)
this.internalProjects.delete(params.projectId)
return this.sendRequest('project/delete', params)
const siblings = this.internalDirectories.get(directoryPath)
if (siblings != null) {
this.internalDirectories.set(
directoryPath,
siblings.filter(
entry =>
entry.type !== FileSystemEntryType.ProjectEntry ||
entry.metadata.id !== params.projectId
)
)
}
}
/** List installed engine versions. */
@ -430,37 +514,161 @@ export default class ProjectManager {
}
/** List directories, projects and files in the given folder. */
async listDirectory(parentId: Path | null) {
async listDirectory(parentId: Path | null): Promise<readonly FileSystemEntry[]> {
/** The type of the response body of this endpoint. */
interface ResponseBody {
readonly entries: FileSystemEntry[]
}
parentId ??= this.rootDirectory
const response = await this.runStandaloneCommand<ResponseBody>(
null,
'filesystem-list',
parentId ?? this.rootDirectory
parentId
)
return response.entries.map(entry => ({ ...entry, path: normalizeSlashes(entry.path) }))
const result = response.entries.map(entry => ({ ...entry, path: normalizeSlashes(entry.path) }))
this.internalDirectories.set(parentId, result)
for (const entry of result) {
if (entry.type === FileSystemEntryType.ProjectEntry) {
this.internalProjectPaths.set(entry.metadata.id, entry.path)
}
}
return result
}
/** Create a directory. */
async createDirectory(path: Path) {
return this.runStandaloneCommand(null, 'filesystem-create-directory', path)
await this.runStandaloneCommand(null, 'filesystem-create-directory', path)
this.internalDirectories.set(path, [])
const directoryPath = getDirectoryAndName(path).directoryPath
const siblings = this.internalDirectories.get(directoryPath)
if (siblings) {
const now = dateTime.toRfc3339(new Date())
this.internalDirectories.set(directoryPath, [
...siblings.filter(sibling => sibling.type === FileSystemEntryType.DirectoryEntry),
{
type: FileSystemEntryType.DirectoryEntry,
attributes: {
byteSize: 0,
creationTime: now,
lastAccessTime: now,
lastModifiedTime: now,
},
path,
},
...siblings.filter(sibling => sibling.type !== FileSystemEntryType.DirectoryEntry),
])
}
}
/** Create a file. */
async createFile(path: Path, file: Blob) {
await this.runStandaloneCommand(file, 'filesystem-write-path', path)
const directoryPath = getDirectoryAndName(path).directoryPath
const siblings = this.internalDirectories.get(directoryPath)
if (siblings) {
const now = dateTime.toRfc3339(new Date())
this.internalDirectories.set(directoryPath, [
...siblings.filter(sibling => sibling.type !== FileSystemEntryType.FileEntry),
{
type: FileSystemEntryType.FileEntry,
attributes: {
byteSize: file.size,
creationTime: now,
lastAccessTime: now,
lastModifiedTime: now,
},
path,
},
...siblings.filter(sibling => sibling.type === FileSystemEntryType.FileEntry),
])
}
}
/** Move a file or directory. */
async moveFile(from: Path, to: Path) {
await this.runStandaloneCommand(null, 'filesystem-move-from', from, '--filesystem-move-to', to)
const children = this.internalDirectories.get(from)
// Assume a directory needs to be loaded for its children to be loaded.
if (children) {
const moveChildren = (directoryChildren: readonly FileSystemEntry[]) => {
for (const child of directoryChildren) {
switch (child.type) {
case FileSystemEntryType.DirectoryEntry: {
const childChildren = this.internalDirectories.get(child.path)
if (childChildren) {
moveChildren(childChildren)
}
break
}
case FileSystemEntryType.ProjectEntry: {
const path = this.internalProjectPaths.get(child.metadata.id)
if (path != null) {
this.internalProjectPaths.set(child.metadata.id, Path(path.replace(from, to)))
}
break
}
case FileSystemEntryType.FileEntry: {
// No special extra metadata is stored for files.
break
}
}
}
this.internalDirectories.set(
from,
children.map(child => ({ ...child, path: Path(child.path.replace(from, to)) }))
)
}
moveChildren(children)
}
const directoryPath = getDirectoryAndName(from).directoryPath
const siblings = this.internalDirectories.get(directoryPath)
if (siblings) {
this.internalDirectories.set(
directoryPath,
siblings.filter(entry => entry.path !== from)
)
}
}
/** Delete a file or directory. */
async deleteFile(path: Path) {
await this.runStandaloneCommand(null, 'filesystem-delete', path)
const children = this.internalDirectories.get(path)
// Assume a directory needs to be loaded for its children to be loaded.
if (children) {
const removeChildren = (directoryChildren: readonly FileSystemEntry[]) => {
for (const child of directoryChildren) {
switch (child.type) {
case FileSystemEntryType.DirectoryEntry: {
const childChildren = this.internalDirectories.get(child.path)
if (childChildren) {
removeChildren(childChildren)
}
break
}
case FileSystemEntryType.ProjectEntry: {
this.internalProjects.delete(child.metadata.id)
this.internalProjectPaths.delete(child.metadata.id)
break
}
case FileSystemEntryType.FileEntry: {
// No special extra metadata is stored for files.
break
}
}
}
}
removeChildren(children)
this.internalDirectories.delete(path)
}
const directoryPath = getDirectoryAndName(path).directoryPath
const siblings = this.internalDirectories.get(directoryPath)
if (siblings) {
this.internalDirectories.set(
directoryPath,
siblings.filter(entry => entry.path !== path)
)
}
}
/** Remove all handlers for a specified request ID. */

View File

@ -12,6 +12,7 @@ import type * as textProvider from '#/providers/TextProvider'
import Backend, * as backend from '#/services/Backend'
import * as remoteBackendPaths from '#/services/remoteBackendPaths'
import * as download from '#/utilities/download'
import type HttpClient from '#/utilities/HttpClient'
import * as object from '#/utilities/object'
@ -35,9 +36,6 @@ const STATUS_NOT_AUTHORIZED = 401
/** The number of milliseconds in one day. */
const ONE_DAY_MS = 86_400_000
/** The interval between requests checking whether a project is ready to be opened in the IDE. */
const CHECK_STATUS_INTERVAL_MS = 5000
// =============
// === Types ===
// =============
@ -537,10 +535,9 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */
override async deleteAsset(
assetId: backend.AssetId,
bodyRaw: backend.DeleteAssetRequestBody,
body: backend.DeleteAssetRequestBody,
title: string
) {
const body = object.omit(bodyRaw, 'parentId')
const paramsString = new URLSearchParams([['force', String(body.force)]]).toString()
const path = remoteBackendPaths.deleteAssetPath(assetId) + '?' + paramsString
const response = await this.delete(path)
@ -749,10 +746,9 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */
override async updateProject(
projectId: backend.ProjectId,
bodyRaw: backend.UpdateProjectRequestBody,
body: backend.UpdateProjectRequestBody,
title: string
): Promise<backend.UpdatedProject> {
const body = object.omit(bodyRaw, 'parentId')
const path = remoteBackendPaths.projectUpdatePath(projectId)
const response = await this.put<backend.UpdatedProject>(path, body)
if (!responseIsSuccessful(response)) {
@ -1110,21 +1106,9 @@ export default class RemoteBackend extends Backend {
}
}
/** Return a {@link Promise} that resolves only when a project is ready to open. */
override async waitUntilProjectIsReady(
projectId: backend.ProjectId,
directory: backend.DirectoryId | null,
title: string,
abortSignal?: AbortSignal
) {
let project = await this.getProjectDetails(projectId, directory, title)
while (project.state.type !== backend.ProjectState.opened && abortSignal?.aborted !== true) {
await new Promise<void>(resolve => setTimeout(resolve, CHECK_STATUS_INTERVAL_MS))
project = await this.getProjectDetails(projectId, directory, title)
}
return project
/** Download from an arbitrary URL that is assumed to originate from this backend. */
override async download(url: string, name?: string) {
await download.downloadWithHeaders(url, this.client.defaultHeaders, name)
}
/** Get the default version given the type of version (IDE or backend). */

View File

@ -52,7 +52,7 @@ export default class HttpClient {
*
* This is useful for setting headers that are required for every request, like
* authentication tokens. */
public defaultHeaders: HeadersInit = {}
public defaultHeaders: Record<string, string> = {}
) {}
/** Send an HTTP GET request to the specified URL. */

View File

@ -1,7 +1,13 @@
/** @file A function to initiate a download. */
/** Initiates a download for the specified url. */
// ================
// === download ===
// ================
/** Initiate a download for the specified url. */
export function download(url: string, name?: string | null) {
url = new URL(url, location.toString()).toString()
// Avoid using `window.systemApi` because the name is lost.
const link = document.createElement('a')
link.href = url
link.download = name ?? url.match(/[^/]+$/)?.[0] ?? ''
@ -9,3 +15,24 @@ export function download(url: string, name?: string | null) {
link.click()
document.body.removeChild(link)
}
// ===========================
// === downloadWithHeaders ===
// ===========================
/** Initiate a download with the specified headers, for the specified url. */
export async function downloadWithHeaders(
url: string,
headers: Record<string, string>,
name?: string
) {
url = new URL(url, location.toString()).toString()
if ('systemApi' in window) {
window.systemApi.downloadURL(url, headers)
} else {
const response = await fetch(url, { headers })
const body = await response.blob()
const objectUrl = URL.createObjectURL(body)
download(objectUrl, name ?? url.match(/[^/]+$/)?.[0] ?? '')
}
}

View File

@ -322,7 +322,10 @@ type NormalizeKeybindSegment = {
/** A segment suggestible by autocomplete. */
type SuggestedKeybindSegment = Key | Pointer | `${Modifier}+`
/** A helper type used to autocomplete and validate a single keyboard shortcut in the editor. */
type AutocompleteKeybind<T extends string, FoundKeyName extends string = never> = T extends '+'
export type AutocompleteKeybind<
T extends string,
FoundKeyName extends string = never,
> = T extends '+'
? T
: T extends `${infer First}+${infer Rest}`
? Lowercase<First> extends LowercaseModifier

View File

@ -1,5 +1,4 @@
/** @file Utilities for working with permissions. */
import * as permissions from 'enso-common/src/utilities/permissions'
export * from 'enso-common/src/utilities/permissions'

View File

@ -89,6 +89,7 @@ interface MenuApi {
/** `window.systemApi` exposes functionality related to the operating system. */
interface SystemApi {
readonly downloadURL: (url: string, headers?: Record<string, string>) => void
readonly showItemInFolder: (fullPath: string) => void
}

View File

@ -18,11 +18,22 @@
dir = ./.;
sha256 = "sha256-o/MRwGYjLPyD1zZQe3LX0dOynwRJpVygfF9+vSnqTOc=";
};
isOnLinux = pkgs.lib.hasInfix "linux" system;
rust-jni = if isOnLinux then with fenix.packages.${system}; combine [
minimal.cargo
minimal.rustc
targets.x86_64-unknown-linux-musl.latest.rust-std
] else fenix.packages.${system}.minimal;
in
pkgs.mkShell {
pkgs.mkShell rec {
buildInputs = with pkgs; [
# === Graal dependencies ===
libxcrypt-legacy
];
packages = with pkgs; [
# === TypeScript dependencies ===
nodejs_20 # should match the Node.JS version of the lambdas
nodejs_20
corepack
# === Electron ===
electron
@ -32,14 +43,31 @@
# === WASM parser dependencies ===
rust
wasm-pack
# Java and SBT omitted for now
];
shellHook = ''
SHIMS_PATH=$HOME/.local/share/enso/nix-shims
# `sccache` can be used to speed up compile times for Rust crates.
# `~/.cargo/bin/sccache` is provided by `cargo install sccache`.
# `~/.cargo/bin` must be in the `PATH` for the binary to be accessible.
export PATH=$HOME/.cargo/bin:$PATH
export PATH=$SHIMS_PATH:${rust.out}:$HOME/.cargo/bin:$PATH
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH"
# `rustup` shim
mkdir -p $SHIMS_PATH
cat <<END > $SHIMS_PATH/rustup
if [ "\$3" = "x86_64-unknown-linux-musl" ]; then
echo 'Installing Nix Rust shims'
ln -s ${rust-jni.out}/bin/rustc $SHIMS_PATH
ln -s ${rust-jni.out}/bin/cargo $SHIMS_PATH
else
echo 'Uninstalling Nix Rust shims (if installed)'
rm -f $SHIMS_PATH/{rustc,cargo}
fi
END
chmod +x $SHIMS_PATH/rustup
# Uninstall shims if already installed
$SHIMS_PATH/rustup
'';
});
};

1
nix/bin/rustup Executable file
View File

@ -0,0 +1 @@
#!/bin/sh

View File

@ -487,8 +487,8 @@ importers:
specifier: ^20.11.21
version: 20.11.21
electron:
specifier: 25.7.0
version: 25.7.0
specifier: 31.2.0
version: 31.2.0
electron-builder:
specifier: ^24.13.3
version: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
@ -2837,9 +2837,6 @@ packages:
'@types/node-fetch@2.6.4':
resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==}
'@types/node@18.19.39':
resolution: {integrity: sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==}
'@types/node@20.11.21':
resolution: {integrity: sha512-/ySDLGscFPNasfqStUuWWPfL78jompfIoVzLJPVVAHBh6rpG68+pI2Gk+fNLeI8/f1yPYL4s46EleVIc20F1Ow==}
@ -4089,8 +4086,8 @@ packages:
electron-to-chromium@1.4.815:
resolution: {integrity: sha512-OvpTT2ItpOXJL7IGcYakRjHCt8L5GrrN/wHCQsRB4PQa1X9fe+X9oen245mIId7s14xvArCGSTIq644yPUKKLg==}
electron@25.7.0:
resolution: {integrity: sha512-P82EzYZ8k9J21x5syhXV7EkezDmEXwycReXnagfzS0kwepnrlWzq1aDIUWdNvzTdHobky4m/nYcL98qd73mEVA==}
electron@31.2.0:
resolution: {integrity: sha512-5w+kjOsGiTXytPSErBPNp/3znnuEMKc42RD41MqRoQkiYaR8x/Le2+qWk1cL60UwE/67oeKnOHnnol8xEuldGg==}
engines: {node: '>= 12.20.55'}
hasBin: true
@ -10137,10 +10134,6 @@ snapshots:
'@types/node': 20.11.21
form-data: 3.0.1
'@types/node@18.19.39':
dependencies:
undici-types: 5.26.5
'@types/node@20.11.21':
dependencies:
undici-types: 5.26.5
@ -11717,10 +11710,10 @@ snapshots:
electron-to-chromium@1.4.815: {}
electron@25.7.0:
electron@31.2.0:
dependencies:
'@electron/get': 2.0.3
'@types/node': 18.19.39
'@types/node': 20.11.21
extract-zip: 2.0.1
transitivePeerDependencies:
- supports-color
@ -13820,6 +13813,12 @@ snapshots:
dependencies:
fast-diff: 1.3.0
prettier-plugin-organize-imports@4.0.0(prettier@3.3.2)(typescript@5.5.3):
dependencies:
prettier: 3.3.2
typescript: 5.5.3
optional: true
prettier-plugin-organize-imports@4.0.0(prettier@3.3.2)(typescript@5.5.3)(vue-tsc@2.0.24(typescript@5.5.3)):
dependencies:
prettier: 3.3.2
@ -13832,7 +13831,7 @@ snapshots:
prettier: 3.3.2
optionalDependencies:
'@ianvs/prettier-plugin-sort-imports': 4.3.0(@vue/compiler-sfc@3.4.31)(prettier@3.3.2)
prettier-plugin-organize-imports: 4.0.0(prettier@3.3.2)(typescript@5.5.3)(vue-tsc@2.0.24(typescript@5.5.3))
prettier-plugin-organize-imports: 4.0.0(prettier@3.3.2)(typescript@5.5.3)
prettier@3.3.2: {}