diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index 9c820d608023..f3453bc427a3 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -198,10 +198,6 @@ const RESTRICTED_SYNTAXES = [ 'TSAsExpression:has(TSUnknownKeyword, TSNeverKeyword, TSAnyKeyword) > TSAsExpression', message: 'Use type assertions to specific types instead of `unknown`, `any` or `never`', }, - { - selector: 'IfStatement > ExpressionStatement', - message: 'Wrap `if` branches in `{}`', - }, ] /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/app/ide-desktop/lib/content/esbuild-config.ts b/app/ide-desktop/lib/content/esbuild-config.ts index 3db5b078676b..f7a620ebfd68 100644 --- a/app/ide-desktop/lib/content/esbuild-config.ts +++ b/app/ide-desktop/lib/content/esbuild-config.ts @@ -96,6 +96,7 @@ export function bundlerOptions(args: Arguments) { entryPoints: [ pathModule.resolve(THIS_PATH, 'src', 'index.ts'), pathModule.resolve(THIS_PATH, 'src', 'index.html'), + pathModule.resolve(THIS_PATH, 'src', 'run.js'), pathModule.resolve(THIS_PATH, 'src', 'style.css'), pathModule.resolve(THIS_PATH, 'src', 'docsStyle.css'), ...wasmArtifacts.split(pathModule.delimiter), @@ -107,9 +108,13 @@ export function bundlerOptions(args: Arguments) { outbase: 'src', plugins: [ { + // This is a workaround that is needed + // because esbuild panics when using `loader: { '.js': 'copy' }`. + // See https://github.com/evanw/esbuild/issues/3041. + // Setting `loader: 'copy'` prevents this file from being converted to ESM + // because of the `"type": "module"` in the `package.json`. // This file MUST be in CommonJS format because it is loaded using `Function()` - // in `ensogl/pack/js/src/runner/index.ts`. - // All other files are ESM because of `"type": "module"` in `package.json`. + // in `ensogl/pack/js/src/runner/index.ts` name: 'pkg-js-is-cjs', setup: build => { build.onLoad({ filter: /[/\\]pkg.js$/ }, async ({ path }) => ({ diff --git a/app/ide-desktop/lib/content/src/index.html b/app/ide-desktop/lib/content/src/index.html index d4e33dfb5555..65625b65e350 100644 --- a/app/ide-desktop/lib/content/src/index.html +++ b/app/ide-desktop/lib/content/src/index.html @@ -37,6 +37,7 @@ +
diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index 3b5da038dcf0..e1d0145b2787 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -8,6 +8,8 @@ import * as authentication from 'enso-authentication' import * as contentConfig from 'enso-content-config' import * as app from '../../../../../target/ensogl-pack/linked-dist/index' +import * as projectManager from './project_manager' +import GLOBAL_CONFIG from '../../../../gui/config.yaml' assert { type: 'yaml' } const logger = app.log.logger @@ -23,8 +25,6 @@ const ESBUILD_EVENT_NAME = 'change' const SECOND = 1000 /** Time in seconds after which a `fetchTimeout` ends. */ const FETCH_TIMEOUT = 300 -/** The `id` attribute of the element that the IDE will be rendered into. */ -const IDE_ELEMENT_ID = 'root' // =================== // === Live reload === @@ -119,132 +119,104 @@ function displayDeprecatedVersionDialog() { } // ======================== -// === Main entry point === +// === Main Entry Point === // ======================== interface StringConfig { [key: string]: StringConfig | string } -// Hack to mutate `configOptions.OPTIONS` -let currentAppInstance: app.App | null = new app.App({ - config: { - loader: { - wasmUrl: 'pkg-opt.wasm', - jsUrl: 'pkg.js', - assetsUrl: 'dynamic-assets', - }, - }, - configOptions: contentConfig.OPTIONS, - packageInfo: { - version: BUILD_INFO.version, - engineVersion: BUILD_INFO.engineVersion, - }, -}) - -function tryStopProject() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - currentAppInstance?.wasm?.drop?.() -} - -async function runProject(inputConfig?: StringConfig) { - tryStopProject() - const rootElement = document.getElementById(IDE_ELEMENT_ID) - if (!rootElement) { - logger.error(`The root element (the element with ID '${IDE_ELEMENT_ID}') was not found.`) - } else { - while (rootElement.firstChild) { - rootElement.removeChild(rootElement.firstChild) - } - } - - const config = Object.assign( - { - loader: { - wasmUrl: 'pkg-opt.wasm', - jsUrl: 'pkg.js', - assetsUrl: 'dynamic-assets', +class Main { + async main(inputConfig: StringConfig) { + const config = Object.assign( + { + loader: { + wasmUrl: 'pkg-opt.wasm', + jsUrl: 'pkg.js', + assetsUrl: 'dynamic-assets', + }, }, - }, - inputConfig - ) - - currentAppInstance = new app.App({ - config, - configOptions: contentConfig.OPTIONS, - packageInfo: { - version: BUILD_INFO.version, - engineVersion: BUILD_INFO.engineVersion, - }, - }) - console.log('bruh', currentAppInstance) + inputConfig + ) + + const appInstance = new app.App({ + config, + configOptions: contentConfig.OPTIONS, + packageInfo: { + version: BUILD_INFO.version, + engineVersion: BUILD_INFO.engineVersion, + }, + }) - if (!currentAppInstance.initialized) { - console.error('Failed to initialize the application.') - } else { - if (contentConfig.OPTIONS.options.dataCollection.value) { - // TODO: Add remote-logging here. - } - if (!(await checkMinSupportedVersion(contentConfig.OPTIONS))) { - displayDeprecatedVersionDialog() - } else { - const email = contentConfig.OPTIONS.groups.authentication.options.email.value - // The default value is `""`, so a truthiness check is most appropriate here. - if (email) { - logger.log(`User identified as '${email}'.`) + if (appInstance.initialized) { + if (contentConfig.OPTIONS.options.dataCollection.value) { + // TODO: Add remote-logging here. } - void currentAppInstance.run() - } - } -} - -if ( - (contentConfig.OPTIONS.options.authentication.value || - contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value) && - contentConfig.OPTIONS.groups.startup.options.entry.value === - contentConfig.OPTIONS.groups.startup.options.entry.default -) { - window.tryStopProject = tryStopProject - window.runProject = runProject - const hideAuth = () => { - const auth = document.getElementById('dashboard') - const ide = document.getElementById('root') - if (auth) { - auth.style.display = 'none' - } - if (ide) { - ide.hidden = false - } - } - /** This package is an Electron desktop app (i.e., not in the Cloud), so - * we're running on the desktop. */ - /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345 - * `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE - * should only have one entry point. Right now, we have two. One for the cloud - * and one for the desktop. Once these are merged, we can't hardcode the - * platform here, and need to detect it from the environment. */ - const platform = authentication.Platform.desktop - /** FIXME [PB]: https://github.com/enso-org/cloud-v2/issues/366 - * React hooks rerender themselves multiple times. It is resulting in multiple - * Enso main scene being initialized. As a temporary workaround we check whether - * appInstance was already ran. Target solution should move running appInstance - * where it will be called only once. */ - let appInstanceRan = false - const onAuthenticated = () => { - if (!contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value) { - hideAuth() - if (!appInstanceRan) { - appInstanceRan = true - void runProject() + if (!(await checkMinSupportedVersion(contentConfig.OPTIONS))) { + displayDeprecatedVersionDialog() + } else { + if ( + (contentConfig.OPTIONS.options.authentication.value || + contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value) && + contentConfig.OPTIONS.groups.startup.options.entry.value === + contentConfig.OPTIONS.groups.startup.options.entry.default + ) { + const hideAuth = () => { + const auth = document.getElementById('dashboard') + const ide = document.getElementById('root') + if (auth) auth.style.display = 'none' + if (ide) ide.style.display = '' + } + /** This package is an Electron desktop app (i.e., not in the Cloud), so + * we're running on the desktop. */ + /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345 + * `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE + * should only have one entry point. Right now, we have two. One for the cloud + * and one for the desktop. Once these are merged, we can't hardcode the + * platform here, and need to detect it from the environment. */ + const platform = authentication.Platform.desktop + /** FIXME [PB]: https://github.com/enso-org/cloud-v2/issues/366 + * React hooks rerender themselves multiple times. It is resulting in multiple + * Enso main scene being initialized. As a temporary workaround we check whether + * appInstance was already ran. Target solution should move running appInstance + * where it will be called only once. */ + let appInstanceRan = false + const onAuthenticated = () => { + if ( + !contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value + ) { + hideAuth() + if (!appInstanceRan) { + appInstanceRan = true + void appInstance.run() + } + } + } + authentication.run({ + logger, + platform, + projectManager: projectManager.ProjectManager.default(), + showDashboard: + contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, + onAuthenticated, + }) + } else { + void appInstance.run() + } + const email = contentConfig.OPTIONS.groups.authentication.options.email.value + // The default value is `""`, so a truthiness check is most appropriate here. + if (email) { + logger.log(`User identified as '${email}'.`) + } } + } else { + console.error('Failed to initialize the application.') } } - authentication.run({ - logger, - platform, - showDashboard: contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, - onAuthenticated, - }) -} else { - void runProject() } + +const API = new Main() + +// @ts-expect-error `globalConfig.windowAppScopeName` is not known at typecheck time. +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +window[GLOBAL_CONFIG.windowAppScopeName] = API diff --git a/app/ide-desktop/lib/content/src/newtype.ts b/app/ide-desktop/lib/content/src/newtype.ts new file mode 100644 index 000000000000..b3a85ec61518 --- /dev/null +++ b/app/ide-desktop/lib/content/src/newtype.ts @@ -0,0 +1,39 @@ +/** @file TypeScript's closest equivalent of `newtype`s. */ + +interface NewtypeVariant { + // eslint-disable-next-line @typescript-eslint/naming-convention + _$type: TypeName +} + +/** Used to create a "branded type", + * which contains a property that only exists at compile time. + * + * `Newtype` and `Newtype` are not compatible with each other, + * however both are regular `string`s at runtime. + * + * This is useful in parameters that require values from a certain source, + * for example IDs for a specific object type. + * + * It is similar to a `newtype` in other languages. + * Note however because TypeScript is structurally typed, + * a branded type is assignable to its base type: + * `a: string = asNewtype>(b)` successfully typechecks. */ +export type Newtype = NewtypeVariant & T + +interface NotNewtype { + // eslint-disable-next-line @typescript-eslint/naming-convention + _$type?: never +} + +export function asNewtype>( + s: NotNewtype & Omit +): T { + // This cast is unsafe. + // `T` has an extra property `_$type` which is used purely for typechecking + // and does not exist at runtime. + // + // The property name is specifically chosen to trigger eslint's `naming-convention` lint, + // so it should not be possible to accidentally create a value with such a type. + // eslint-disable-next-line no-restricted-syntax + return s as unknown as T +} diff --git a/app/ide-desktop/lib/content/src/project_manager.ts b/app/ide-desktop/lib/content/src/project_manager.ts new file mode 100644 index 000000000000..58777d0551db --- /dev/null +++ b/app/ide-desktop/lib/content/src/project_manager.ts @@ -0,0 +1,166 @@ +/** @file This module defines the Project Manager endpoint. */ +import * as newtype from './newtype' + +const PROJECT_MANAGER_ENDPOINT = 'ws://127.0.0.1:30535' + +// ============= +// === Types === +// ============= + +export enum MissingComponentAction { + fail = 'Fail', + install = 'Install', + forceInstallBroken = 'ForceInstallBroken', +} + +interface Result { + result: T +} + +// This intentionally has the same brand as in the cloud backend API. +export type ProjectId = newtype.Newtype +export type ProjectName = newtype.Newtype +export type UTCDateTime = newtype.Newtype + +interface ProjectMetadata { + name: ProjectName + namespace: string + id: ProjectId + engineVersion: string | null + lastOpened: UTCDateTime | null +} + +interface IpWithSocket { + host: string + port: number +} + +interface ProjectList { + projects: ProjectMetadata[] +} + +interface CreateProject { + projectId: ProjectId +} + +interface OpenProject { + engineVersion: string + languageServerJsonAddress: IpWithSocket + languageServerBinaryAddress: IpWithSocket + projectName: ProjectName + projectNamespace: string +} + +// ================================ +// === Parameters for endpoints === +// ================================ + +export interface OpenProjectParams { + projectId: ProjectId + missingComponentAction: MissingComponentAction +} + +export interface CloseProjectParams { + projectId: ProjectId +} + +export interface ListProjectsParams { + numberOfProjects?: number +} + +export interface CreateProjectParams { + name: ProjectName + projectTemplate?: string + version?: string + missingComponentAction?: MissingComponentAction +} + +export interface RenameProjectParams { + projectId: ProjectId + name: ProjectName +} + +export interface DeleteProjectParams { + projectId: ProjectId +} + +export interface ListSamplesParams { + projectId: ProjectId +} + +// ======================= +// === Project Manager === +// ======================= + +/** A WebSocket endpoint to the project manager. */ +export class ProjectManager { + constructor(protected readonly connectionUrl: string) {} + + static default() { + return new ProjectManager(PROJECT_MANAGER_ENDPOINT) + } + + public async sendRequest(method: string, params: unknown): Promise> { + const req = { + jsonrpc: '2.0', + id: 0, + method, + params, + } + + const ws = new WebSocket(this.connectionUrl) + return new Promise>((resolve, reject) => { + ws.onopen = () => { + ws.send(JSON.stringify(req)) + } + ws.onmessage = event => { + // There is no way to avoid this; `JSON.parse` returns `any`. + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + resolve(JSON.parse(event.data)) + } + ws.onerror = error => { + reject(error) + } + }).finally(() => { + ws.close() + }) + } + + /** * Open an existing project. */ + public async openProject(params: OpenProjectParams): Promise> { + return this.sendRequest('project/open', params) + } + + /** * Close an open project. */ + public async closeProject(params: CloseProjectParams): Promise> { + return this.sendRequest('project/close', params) + } + + /** * Get the projects list, sorted by open time. */ + public async listProjects(params: ListProjectsParams): Promise> { + return this.sendRequest('project/list', params) + } + + /** * Create a new project. */ + public async createProject(params: CreateProjectParams): Promise> { + return this.sendRequest('project/create', { + missingComponentAction: MissingComponentAction.install, + ...params, + }) + } + + /** * Rename a project. */ + public async renameProject(params: RenameProjectParams): Promise> { + return this.sendRequest('project/rename', params) + } + + /** * Delete a project. */ + public async deleteProject(params: DeleteProjectParams): Promise> { + return this.sendRequest('project/delete', params) + } + + /** * Get the list of sample projects that are available to the user. */ + public async listSamples(params: ListSamplesParams): Promise> { + return this.sendRequest('project/listSample', params) + } +} diff --git a/app/ide-desktop/lib/content/src/run.js b/app/ide-desktop/lib/content/src/run.js new file mode 100644 index 000000000000..91a503a25ef6 --- /dev/null +++ b/app/ide-desktop/lib/content/src/run.js @@ -0,0 +1,4 @@ +/** @file This file is used to simply run the IDE. It can be not invoked if the IDE needs to be used + * as a library. */ + +void window.enso.main() diff --git a/app/ide-desktop/lib/content/src/style.css b/app/ide-desktop/lib/content/src/style.css index e8afd0d5b252..6e0fcf478620 100644 --- a/app/ide-desktop/lib/content/src/style.css +++ b/app/ide-desktop/lib/content/src/style.css @@ -73,6 +73,11 @@ /* End of fonts */ +html, +body { + height: 100vh; +} + body { margin: 0; overscroll-behavior: none; diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx index 87e63b82d123..a6f5bac986bd 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx @@ -9,8 +9,7 @@ import toast from 'react-hot-toast' import * as app from '../../components/app' import * as authServiceModule from '../service' -import * as backendProvider from '../../providers/backend' -import * as cloudService from '../../dashboard/cloudService' +import * as backendService from '../../dashboard/service' import * as errorModule from '../../error' import * as loggerProvider from '../../providers/logger' import * as newtype from '../../newtype' @@ -50,7 +49,7 @@ export interface FullUserSession { /** User's email address. */ email: string /** User's organization information. */ - organization: cloudService.UserOrOrganization + organization: backendService.UserOrOrganization } /** Object containing the currently signed-in user's session data, if the user has not yet set their @@ -139,7 +138,6 @@ export function AuthProvider(props: AuthProviderProps) { const { authService, children } = props const { cognito } = authService const { session } = sessionProvider.useSession() - const { setBackend } = backendProvider.useSetBackend() const logger = loggerProvider.useLogger() const navigate = router.useNavigate() const onAuthenticated = react.useCallback(props.onAuthenticated, []) @@ -159,8 +157,7 @@ export function AuthProvider(props: AuthProviderProps) { } else { const { accessToken, email } = session.val - const backend = cloudService.createBackend(accessToken, logger) - setBackend(backend) + const backend = backendService.createBackend(accessToken, logger) const organization = await backend.usersMe() let newUserSession: UserSession if (!organization) { @@ -256,11 +253,11 @@ export function AuthProvider(props: AuthProviderProps) { /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343 * The API client is reinitialised on every request. That is an inefficient way of usage. * Fix it by using React context and implementing it as a singleton. */ - const backend = cloudService.createBackend(accessToken, logger) + const backend = backendService.createBackend(accessToken, logger) await backend.createUser({ userName: username, - userEmail: newtype.asNewtype(email), + userEmail: newtype.asNewtype(email), }) navigate(app.DASHBOARD_PATH) toast.success(MESSAGES.setUsernameSuccess) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx index bfc148108d13..279cd22ee9ff 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx @@ -38,13 +38,12 @@ import * as react from 'react' import * as router from 'react-router-dom' import * as toast from 'react-hot-toast' -import * as app from '../../../../../../../../target/ensogl-pack/linked-dist/index' +import * as projectManagerModule from 'enso-content/src/project_manager' import * as authService from '../authentication/service' import * as platformModule from '../platform' import * as authProvider from '../authentication/providers/auth' -import * as backendProvider from '../providers/backend' import * as loggerProvider from '../providers/logger' import * as modalProvider from '../providers/modal' import * as sessionProvider from '../authentication/providers/session' @@ -80,16 +79,26 @@ export const SET_USERNAME_PATH = '/set-username' // === App === // =========== -/** Global configuration for the `App` component. */ -export interface AppProps { +interface BaseAppProps { logger: loggerProvider.Logger platform: platformModule.Platform /** Whether the dashboard should be rendered. */ showDashboard: boolean - ide?: app.App onAuthenticated: () => void } +interface DesktopAppProps extends BaseAppProps { + platform: platformModule.Platform.desktop + projectManager: projectManagerModule.ProjectManager +} + +interface OtherAppProps extends BaseAppProps { + platform: Exclude +} + +/** Global configuration for the `App` component. */ +export type AppProps = DesktopAppProps | OtherAppProps + /** Component called by the parent module, returning the root React component for this * package. * @@ -162,15 +171,12 @@ function AppRouter(props: AppProps) { userSession={userSession} registerAuthEventListener={registerAuthEventListener} > - {/* @ts-expect-error Auth will always set this before dashboard is rendered. */} - - - {routes} - - + + {routes} + ) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx index a00b416e20e9..c1ecda9868db 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx @@ -235,71 +235,6 @@ export const CLOSE_ICON = ( ) -export const CLOUD_ICON = ( - - - -) - -export const COMPUTER_ICON = ( - - - -) - -export interface StopIconProps { - className?: string -} - -/** Icon displayed when a project is ready to stop. */ -export function StopIcon(props: StopIconProps) { - const { className } = props - return ( - - - - - - ) -} - // =========== // === Svg === // =========== diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx index a87f55139f44..9619f1b9b31a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx @@ -2,20 +2,19 @@ * interactive components. */ import * as react from 'react' -import * as cloudService from '../cloudService' +import * as projectManagerModule from 'enso-content/src/project_manager' + +import * as auth from '../../authentication/providers/auth' +import * as backend from '../service' import * as fileInfo from '../../fileInfo' import * as hooks from '../../hooks' -import * as localService from '../localService' +import * as loggerProvider from '../../providers/logger' +import * as modalProvider from '../../providers/modal' import * as newtype from '../../newtype' import * as platformModule from '../../platform' import * as svg from '../../components/svg' import * as uploadMultipleFiles from '../../uploadMultipleFiles' -import * as auth from '../../authentication/providers/auth' -import * as backendProvider from '../../providers/backend' -import * as loggerProvider from '../../providers/logger' -import * as modalProvider from '../../providers/modal' - import PermissionDisplay, * as permissionDisplay from './permissionDisplay' import ContextMenu from './contextMenu' import ContextMenuEntry from './contextMenuEntry' @@ -74,8 +73,8 @@ enum Column { export interface CreateFormProps { left: number top: number - backend: cloudService.Backend - directoryId: cloudService.DirectoryId + backend: backend.Backend + directoryId: backend.DirectoryId onSuccess: () => void } @@ -90,28 +89,23 @@ export interface CreateFormProps { // eslint-disable-next-line @typescript-eslint/no-inferrable-types const EXPERIMENTAL: boolean = true -/** The `id` attribute of the element into which the IDE will be rendered. */ -const IDE_ELEMENT_ID = 'root' /** The `localStorage` key under which the ID of the current directory is stored. */ const DIRECTORY_STACK_KEY = 'enso-dashboard-directory-stack' /** English names for the name column. */ -const ASSET_TYPE_NAME: Record = { - [cloudService.AssetType.project]: 'Projects', - [cloudService.AssetType.file]: 'Files', - [cloudService.AssetType.secret]: 'Secrets', - [cloudService.AssetType.directory]: 'Folders', +const ASSET_TYPE_NAME: Record = { + [backend.AssetType.project]: 'Projects', + [backend.AssetType.file]: 'Files', + [backend.AssetType.secret]: 'Secrets', + [backend.AssetType.directory]: 'Folders', } as const /** Forms to create each asset type. */ -const ASSET_TYPE_CREATE_FORM: Record< - cloudService.AssetType, - (props: CreateFormProps) => JSX.Element -> = { - [cloudService.AssetType.project]: ProjectCreateForm, - [cloudService.AssetType.file]: FileCreateForm, - [cloudService.AssetType.secret]: SecretCreateForm, - [cloudService.AssetType.directory]: DirectoryCreateForm, +const ASSET_TYPE_CREATE_FORM: Record JSX.Element> = { + [backend.AssetType.project]: ProjectCreateForm, + [backend.AssetType.file]: FileCreateForm, + [backend.AssetType.secret]: SecretCreateForm, + [backend.AssetType.directory]: DirectoryCreateForm, } /** English names for every column except for the name column. */ @@ -127,23 +121,23 @@ const COLUMN_NAME: Record, string> = { } as const /** The corresponding `Permissions` for each backend `PermissionAction`. */ -const PERMISSION: Record = { - [cloudService.PermissionAction.own]: { type: permissionDisplay.Permission.owner }, - [cloudService.PermissionAction.execute]: { +const PERMISSION: Record = { + [backend.PermissionAction.own]: { type: permissionDisplay.Permission.owner }, + [backend.PermissionAction.execute]: { type: permissionDisplay.Permission.regular, read: false, write: false, docsWrite: false, exec: true, }, - [cloudService.PermissionAction.edit]: { + [backend.PermissionAction.edit]: { type: permissionDisplay.Permission.regular, read: false, write: true, docsWrite: false, exec: false, }, - [cloudService.PermissionAction.read]: { + [backend.PermissionAction.read]: { type: permissionDisplay.Permission.regular, read: true, write: false, @@ -187,128 +181,100 @@ const COLUMNS_FOR: Record = { // ======================== /** Returns the id of the root directory for a user or organization. */ -function rootDirectoryId(userOrOrganizationId: cloudService.UserOrOrganizationId) { - return newtype.asNewtype( - userOrOrganizationId.replace(/^organization-/, `${cloudService.AssetType.directory}-`) +function rootDirectoryId(userOrOrganizationId: backend.UserOrOrganizationId) { + return newtype.asNewtype( + userOrOrganizationId.replace(/^organization-/, `${backend.AssetType.directory}-`) ) } -// FIXME[sb]: While this works, throwing a runtime error can be avoided -// if types are properly narrowed, e.g. using a type guard instead. -function asCloudBackend( - backend: cloudService.Backend | localService.Backend -): cloudService.Backend { - if (!('checkResources' in backend)) { - throw new Error('This functionality only works with the cloud backend.') - } else { - return backend - } -} - // ================= // === Dashboard === // ================= -export interface DashboardProps { +interface BaseDashboardProps { + logger: loggerProvider.Logger platform: platformModule.Platform } +interface DesktopDashboardProps extends BaseDashboardProps { + platform: platformModule.Platform.desktop + projectManager: projectManagerModule.ProjectManager +} + +interface OtherDashboardProps extends BaseDashboardProps { + platform: Exclude +} + +export type DashboardProps = DesktopDashboardProps | OtherDashboardProps + // TODO[sb]: Implement rename when clicking name of a selected row. // There is currently no way to tell whether a row is selected from a column. function Dashboard(props: DashboardProps) { - const { platform } = props + const { logger, platform } = props - const logger = loggerProvider.useLogger() const { accessToken, organization } = auth.useFullUserSession() - const { backend } = backendProvider.useBackend() - const { setBackend } = backendProvider.useSetBackend() + const backendService = backend.createBackend(accessToken, logger) const { modal } = modalProvider.useModal() const { setModal, unsetModal } = modalProvider.useSetModal() - const [backendPlatform, setBackendPlatform] = react.useState(platformModule.Platform.cloud) const [refresh, doRefresh] = hooks.useRefresh() const [query, setQuery] = react.useState('') const [directoryId, setDirectoryId] = react.useState(rootDirectoryId(organization.id)) const [directoryStack, setDirectoryStack] = react.useState< - cloudService.Asset[] + backend.Asset[] >([]) // Defined by the spec as `compact` by default, however it is not ready yet. const [columnDisplayMode, setColumnDisplayMode] = react.useState(ColumnDisplayMode.release) - const [tab, setTab] = react.useState(Tab.dashboard) - const [project, setProject] = react.useState(null) - const [selectedAssets, setSelectedAssets] = react.useState([]) - const [isFileBeingDragged, setIsFileBeingDragged] = react.useState(false) const [projectAssets, setProjectAssetsRaw] = react.useState< - cloudService.Asset[] + backend.Asset[] >([]) const [directoryAssets, setDirectoryAssetsRaw] = react.useState< - cloudService.Asset[] + backend.Asset[] >([]) const [secretAssets, setSecretAssetsRaw] = react.useState< - cloudService.Asset[] - >([]) - const [fileAssets, setFileAssetsRaw] = react.useState< - cloudService.Asset[] + backend.Asset[] >([]) + const [fileAssets, setFileAssetsRaw] = react.useState[]>( + [] + ) const [visibleProjectAssets, setVisibleProjectAssets] = react.useState< - cloudService.Asset[] + backend.Asset[] >([]) const [visibleDirectoryAssets, setVisibleDirectoryAssets] = react.useState< - cloudService.Asset[] + backend.Asset[] >([]) const [visibleSecretAssets, setVisibleSecretAssets] = react.useState< - cloudService.Asset[] + backend.Asset[] >([]) const [visibleFileAssets, setVisibleFileAssets] = react.useState< - cloudService.Asset[] + backend.Asset[] >([]) + const [tab, setTab] = react.useState(Tab.dashboard) + const [project, setProject] = react.useState(null) + + const [selectedAssets, setSelectedAssets] = react.useState([]) + const [isFileBeingDragged, setIsFileBeingDragged] = react.useState(false) + const directory = directoryStack[directoryStack.length - 1] const parentDirectory = directoryStack[directoryStack.length - 2] - react.useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - if ( - // On macOS, we need to check for combination of `alt` + `d` which is `∂` (`del`). - (event.key === 'd' || event.key === '∂') && - event.ctrlKey && - event.altKey && - !event.shiftKey && - !event.metaKey - ) { - setTab(Tab.dashboard) - const ideElement = document.getElementById(IDE_ELEMENT_ID) - if (ideElement) { - ideElement.hidden = true - } - } - } - document.addEventListener('keydown', onKeyDown) - return () => { - document.removeEventListener('keydown', onKeyDown) - } - }, []) - - function setProjectAssets( - newProjectAssets: cloudService.Asset[] - ) { + function setProjectAssets(newProjectAssets: backend.Asset[]) { setProjectAssetsRaw(newProjectAssets) setVisibleProjectAssets(newProjectAssets.filter(asset => asset.title.includes(query))) } - function setDirectoryAssets( - newDirectoryAssets: cloudService.Asset[] - ) { + function setDirectoryAssets(newDirectoryAssets: backend.Asset[]) { setDirectoryAssetsRaw(newDirectoryAssets) setVisibleDirectoryAssets(newDirectoryAssets.filter(asset => asset.title.includes(query))) } - function setSecretAssets(newSecretAssets: cloudService.Asset[]) { + function setSecretAssets(newSecretAssets: backend.Asset[]) { setSecretAssetsRaw(newSecretAssets) setVisibleSecretAssets(newSecretAssets.filter(asset => asset.title.includes(query))) } - function setFileAssets(newFileAssets: cloudService.Asset[]) { + function setFileAssets(newFileAssets: backend.Asset[]) { setFileAssetsRaw(newFileAssets) setVisibleFileAssets(newFileAssets.filter(asset => asset.title.includes(query))) } @@ -321,7 +287,7 @@ function Dashboard(props: DashboardProps) { ) } - function enterDirectory(directoryAsset: cloudService.Asset) { + function enterDirectory(directoryAsset: backend.Asset) { setDirectoryId(directoryAsset.id) setDirectoryStack([...directoryStack, directoryAsset]) } @@ -331,7 +297,7 @@ function Dashboard(props: DashboardProps) { if (cachedDirectoryStackJson) { // The JSON was inserted by the code below, so it will always have the right type. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const cachedDirectoryStack: cloudService.Asset[] = + const cachedDirectoryStack: backend.Asset[] = JSON.parse(cachedDirectoryStackJson) setDirectoryStack(cachedDirectoryStack) const cachedDirectoryId = cachedDirectoryStack[cachedDirectoryStack.length - 1]?.id @@ -351,9 +317,9 @@ function Dashboard(props: DashboardProps) { /** React components for the name column. */ const nameRenderers: { - [Type in cloudService.AssetType]: (asset: cloudService.Asset) => JSX.Element + [Type in backend.AssetType]: (asset: backend.Asset) => JSX.Element } = { - [cloudService.AssetType.project]: projectAsset => ( + [backend.AssetType.project]: projectAsset => (
{ @@ -374,17 +340,13 @@ function Dashboard(props: DashboardProps) { project={projectAsset} openIde={async () => { setTab(Tab.ide) - setProject(await backend.getProjectDetails(projectAsset.id)) - const ideElement = document.getElementById(IDE_ELEMENT_ID) - if (ideElement) { - ideElement.hidden = false - } + setProject(await backendService.getProjectDetails(projectAsset.id)) }} /> {projectAsset.title}
), - [cloudService.AssetType.directory]: directoryAsset => ( + [backend.AssetType.directory]: directoryAsset => (
{ @@ -407,7 +369,7 @@ function Dashboard(props: DashboardProps) { {svg.DIRECTORY_ICON} {directoryAsset.title}
), - [cloudService.AssetType.secret]: secret => ( + [backend.AssetType.secret]: secret => (
{ @@ -427,7 +389,7 @@ function Dashboard(props: DashboardProps) { {svg.SECRET_ICON} {secret.title}
), - [cloudService.AssetType.file]: file => ( + [backend.AssetType.file]: file => (
{ @@ -453,7 +415,7 @@ function Dashboard(props: DashboardProps) { /** React components for every column except for the name column. */ const columnRenderer: Record< Exclude, - (asset: cloudService.Asset) => JSX.Element + (asset: backend.Asset) => JSX.Element > = { [Column.lastModified]: () => <>, [Column.sharedWith]: asset => ( @@ -499,16 +461,17 @@ function Dashboard(props: DashboardProps) { [Column.ide]: () => <>, } - function renderer(column: Column, assetType: Type) { + function renderer(column: Column, assetType: Type) { return column === Column.name ? // This is type-safe only if we pass enum literals as `assetType`. + // eslint-disable-next-line no-restricted-syntax - (nameRenderers[assetType] as (asset: cloudService.Asset) => JSX.Element) + (nameRenderers[assetType] as (asset: backend.Asset) => JSX.Element) : columnRenderer[column] } /** Heading element for every column. */ - function ColumnHeading(column: Column, assetType: cloudService.AssetType) { + function ColumnHeading(column: Column, assetType: backend.AssetType) { return column === Column.name ? (
{ASSET_TYPE_NAME[assetType]} @@ -528,7 +491,7 @@ function Dashboard(props: DashboardProps) { left={buttonPosition.left} top={buttonPosition.top} // FIXME[sb]: Don't pass outdated `doRefresh` - maybe `backendService` too. - backend={asCloudBackend(backend)} + backend={backendService} directoryId={directoryId} onSuccess={doRefresh} /> @@ -551,17 +514,11 @@ function Dashboard(props: DashboardProps) { setVisibleFileAssets(fileAssets.filter(asset => asset.title.includes(query))) }, [query]) - function setAssets(assets: cloudService.Asset[]) { - const newProjectAssets = assets.filter( - cloudService.assetIsType(cloudService.AssetType.project) - ) - const newDirectoryAssets = assets.filter( - cloudService.assetIsType(cloudService.AssetType.directory) - ) - const newSecretAssets = assets.filter( - cloudService.assetIsType(cloudService.AssetType.secret) - ) - const newFileAssets = assets.filter(cloudService.assetIsType(cloudService.AssetType.file)) + function setAssets(assets: backend.Asset[]) { + const newProjectAssets = assets.filter(backend.assetIsType(backend.AssetType.project)) + const newDirectoryAssets = assets.filter(backend.assetIsType(backend.AssetType.directory)) + const newSecretAssets = assets.filter(backend.assetIsType(backend.AssetType.secret)) + const newFileAssets = assets.filter(backend.assetIsType(backend.AssetType.file)) setProjectAssets(newProjectAssets) setDirectoryAssets(newDirectoryAssets) setSecretAssets(newSecretAssets) @@ -571,12 +528,36 @@ function Dashboard(props: DashboardProps) { hooks.useAsyncEffect( null, async signal => { - const assets = await backend.listDirectory({ parentId: directoryId }) + let assets: backend.Asset[] + + switch (platform) { + case platformModule.Platform.cloud: { + assets = await backendService.listDirectory({ + parentId: directoryId, + }) + break + } + case platformModule.Platform.desktop: { + const result = await props.projectManager.listProjects({}) + const localProjects = result.result.projects + assets = [] + for (const localProject of localProjects) { + assets.push({ + type: backend.AssetType.project, + title: localProject.name, + id: localProject.id, + parentId: '', + permissions: null, + }) + } + break + } + } if (!signal.aborted) { setAssets(assets) } }, - [accessToken, directoryId, refresh, backend] + [accessToken, directoryId, refresh] ) react.useEffect(() => { @@ -625,24 +606,51 @@ function Dashboard(props: DashboardProps) { return `${prefix}${highestProjectIndex + 1}` } - async function handleCreateProject(templateName?: string | null) { + async function handleCreateProject(templateName: string | null) { const projectName = getNewProjectName(templateName) - const body: cloudService.CreateProjectRequestBody = { - projectName, - projectTemplateName: templateName?.replace(/_/g, '').toLocaleLowerCase() ?? null, - parentDirectoryId: directoryId, + switch (platform) { + case platformModule.Platform.cloud: { + const body: backend.CreateProjectRequestBody = { + projectName, + projectTemplateName: + templateName?.replace(/_/g, '').toLocaleLowerCase() ?? null, + parentDirectoryId: directoryId, + } + if (templateName) { + body.projectTemplateName = templateName.replace(/_/g, '').toLocaleLowerCase() + } + const projectAsset = await backendService.createProject(body) + setProjectAssets([ + ...projectAssets, + { + type: backend.AssetType.project, + title: projectAsset.name, + id: projectAsset.projectId, + parentId: '', + permissions: [], + }, + ]) + break + } + case platformModule.Platform.desktop: { + const result = await props.projectManager.createProject({ + name: newtype.asNewtype(projectName), + ...(templateName ? { projectTemplate: templateName } : {}), + }) + const newProject = result.result + setProjectAssets([ + ...projectAssets, + { + type: backend.AssetType.project, + title: projectName, + id: newProject.projectId, + parentId: '', + permissions: [], + }, + ]) + break + } } - const projectAsset = await backend.createProject(body) - setProjectAssets([ - ...projectAssets, - { - type: cloudService.AssetType.project, - title: projectAsset.name, - id: projectAsset.projectId, - parentId: '', - permissions: [], - }, - ]) } return ( @@ -656,47 +664,19 @@ function Dashboard(props: DashboardProps) { >
{ if (project && tab === Tab.dashboard) { setTab(Tab.ide) - const ideElement = document.getElementById(IDE_ELEMENT_ID) - if (ideElement) { - ideElement.hidden = false - } } else { setTab(Tab.dashboard) - const ideElement = document.getElementById(IDE_ELEMENT_ID) - if (ideElement) { - ideElement.hidden = true - } - } - }} - backendPlatform={backendPlatform} - setBackendPlatform={newBackendPlatform => { - setBackendPlatform(newBackendPlatform) - setProjectAssets([]) - setDirectoryAssets([]) - setSecretAssets([]) - setFileAssets([]) - switch (newBackendPlatform) { - case platformModule.Platform.desktop: - setBackend(localService.createBackend()) - break - case platformModule.Platform.cloud: - setBackend(cloudService.createBackend(accessToken, logger)) - break } }} query={query} setQuery={setQuery} /> - +

Drive

@@ -723,16 +703,12 @@ function Dashboard(props: DashboardProps) {
-
- - -
-
- - - - -
- +
- > + > items={visibleProjectAssets} getKey={proj => proj.id} placeholder={ @@ -894,8 +801,8 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, cloudService.AssetType.project), - render: renderer(column, cloudService.AssetType.project), + heading: ColumnHeading(column, backend.AssetType.project), + render: renderer(column, backend.AssetType.project), }))} onClick={projectAsset => { setSelectedAssets([projectAsset]) @@ -936,7 +843,7 @@ function Dashboard(props: DashboardProps) { name={projectAsset.title} assetType={projectAsset.type} doDelete={() => - asCloudBackend(backend).deleteProject(projectAsset.id) + backendService.deleteProject(projectAsset.id) } onSuccess={doRefresh} /> @@ -960,10 +867,10 @@ function Dashboard(props: DashboardProps) { )) }} /> - {backendPlatform === platformModule.Platform.cloud && ( + {platform === platformModule.Platform.cloud && ( <> - > + > items={visibleDirectoryAssets} getKey={dir => dir.id} placeholder={ @@ -974,11 +881,8 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading( - column, - cloudService.AssetType.directory - ), - render: renderer(column, cloudService.AssetType.directory), + heading: ColumnHeading(column, backend.AssetType.directory), + render: renderer(column, backend.AssetType.directory), }))} onClick={directoryAsset => { setSelectedAssets([directoryAsset]) @@ -990,7 +894,7 @@ function Dashboard(props: DashboardProps) { }} /> - > + > items={visibleSecretAssets} getKey={secret => secret.id} placeholder={ @@ -1001,8 +905,8 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, cloudService.AssetType.secret), - render: renderer(column, cloudService.AssetType.secret), + heading: ColumnHeading(column, backend.AssetType.secret), + render: renderer(column, backend.AssetType.secret), }))} onClick={secret => { setSelectedAssets([secret]) @@ -1018,7 +922,7 @@ function Dashboard(props: DashboardProps) { name={secret.title} assetType={secret.type} doDelete={() => - asCloudBackend(backend).deleteSecret(secret.id) + backendService.deleteSecret(secret.id) } onSuccess={doRefresh} /> @@ -1034,7 +938,7 @@ function Dashboard(props: DashboardProps) { }} /> - > + > items={visibleFileAssets} getKey={file => file.id} placeholder={ @@ -1045,8 +949,8 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, cloudService.AssetType.file), - render: renderer(column, cloudService.AssetType.file), + heading: ColumnHeading(column, backend.AssetType.file), + render: renderer(column, backend.AssetType.file), }))} onClick={file => { setSelectedAssets([file]) @@ -1067,9 +971,7 @@ function Dashboard(props: DashboardProps) { - asCloudBackend(backend).deleteFile(file.id) - } + doDelete={() => backendService.deleteFile(file.id)} onSuccess={doRefresh} /> )) @@ -1099,7 +1001,7 @@ function Dashboard(props: DashboardProps) { )}
- {isFileBeingDragged && backendPlatform === platformModule.Platform.cloud ? ( + {isFileBeingDragged ? (
{ @@ -1112,7 +1014,7 @@ function Dashboard(props: DashboardProps) { event.preventDefault() setIsFileBeingDragged(false) await uploadMultipleFiles.uploadMultipleFiles( - asCloudBackend(backend), + backendService, directoryId, Array.from(event.dataTransfer.files) ) @@ -1123,7 +1025,7 @@ function Dashboard(props: DashboardProps) {
) : null} {/* This should be just `{modal}`, however TypeScript incorrectly throws an error. */} - {project && } + {project && } {modal && <>{modal}}
) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx index e3b8f617227b..8f13d6e4da19 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx @@ -2,14 +2,14 @@ import * as react from 'react' import toast from 'react-hot-toast' -import * as cloudService from '../cloudService' +import * as backendModule from '../service' import * as error from '../../error' import * as modalProvider from '../../providers/modal' import CreateForm, * as createForm from './createForm' export interface DirectoryCreateFormProps extends createForm.CreateFormPassthroughProps { - backend: cloudService.Backend - directoryId: cloudService.DirectoryId + backend: backendModule.Backend + directoryId: backendModule.DirectoryId onSuccess: () => void } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx index b41008190b2e..20ae40b26c9b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx @@ -2,14 +2,14 @@ import * as react from 'react' import toast from 'react-hot-toast' -import * as cloudService from '../cloudService' +import * as backendModule from '../service' import * as error from '../../error' import * as modalProvider from '../../providers/modal' import CreateForm, * as createForm from './createForm' export interface FileCreateFormProps extends createForm.CreateFormPassthroughProps { - backend: cloudService.Backend - directoryId: cloudService.DirectoryId + backend: backendModule.Backend + directoryId: backendModule.DirectoryId onSuccess: () => void } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx index 0fe9ef42edb9..d453f5b67fb7 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx @@ -1,35 +1,36 @@ /** @file Container that launches the IDE. */ import * as react from 'react' -import * as backendProvider from '../../providers/backend' -import * as cloudService from '../cloudService' -import * as platformModule from '../../platform' +import * as service from '../service' // ================= // === Constants === // ================= -/** The `id` attribute of the element into which the IDE will be rendered. */ +/** The `id` attribute of the element that the IDE will be rendered into. */ const IDE_ELEMENT_ID = 'root' const IDE_CDN_URL = 'https://ensocdn.s3.us-west-1.amazonaws.com/ide' -const JS_EXTENSION: Record = { - [platformModule.Platform.cloud]: '.js.gz', - [platformModule.Platform.desktop]: '.js', -} as const // ================= // === Component === // ================= interface Props { - project: cloudService.Project - backendPlatform: platformModule.Platform + project: service.Project + backendService: service.Backend } /** Container that launches the IDE. */ function Ide(props: Props) { - const { project, backendPlatform } = props - const { backend } = backendProvider.useBackend() + const { project, backendService } = props + const [ideElement] = react.useState(() => document.querySelector(IDE_ELEMENT_ID)) + const [[loaded, resolveLoaded]] = react.useState((): [Promise, () => void] => { + let resolve!: () => void + const promise = new Promise(innerResolve => { + resolve = innerResolve + }) + return [promise, resolve] + }) react.useEffect(() => { document.getElementById(IDE_ELEMENT_ID)?.classList.remove('hidden') @@ -40,77 +41,62 @@ function Ide(props: Props) { react.useEffect(() => { void (async () => { - const ideVersion = - project.ideVersion?.value ?? - ('listVersions' in backend - ? await backend.listVersions({ - versionType: cloudService.VersionType.ide, - default: true, - }) - : null)?.[0].number.value - const engineVersion = - project.engineVersion?.value ?? - ('listVersions' in backend - ? await backend.listVersions({ - versionType: cloudService.VersionType.backend, - default: true, - }) - : null)?.[0].number.value - const jsonAddress = project.jsonAddress - const binaryAddress = project.binaryAddress - if (ideVersion == null) { - throw new Error('Could not get the IDE version of the project.') - } else if (engineVersion == null) { - throw new Error('Could not get the engine version of the project.') - } else if (jsonAddress == null) { - throw new Error("Could not get the address of the project's JSON endpoint.") - } else if (binaryAddress == null) { - throw new Error("Could not get the address of the project's binary endpoint.") - } else { - const assetsRoot = (() => { - switch (backendPlatform) { - case platformModule.Platform.cloud: - return `${IDE_CDN_URL}/${ideVersion}/` - case platformModule.Platform.desktop: - return '' - } - })() - const runNewProject = async () => { - const originalUrl = window.location.href - // The URL query contains commandline options when running in the desktop, - // which will break the entrypoint for opening a fresh IDE instance. - history.replaceState(null, '', new URL('.', originalUrl)) - await window.runProject({ - loader: { - assetsUrl: `${assetsRoot}dynamic-assets`, - wasmUrl: `${assetsRoot}pkg-opt.wasm`, - jsUrl: `${assetsRoot}pkg${JS_EXTENSION[backendPlatform]}`, - }, - engine: { - rpcUrl: jsonAddress, - dataUrl: binaryAddress, - preferredVersion: engineVersion, - }, - startup: { - project: project.packageName, - }, - }) - // Restore original URL so that initialization works correctly on refresh. - history.replaceState(null, '', originalUrl) - } - if (backendPlatform === platformModule.Platform.desktop) { - await runNewProject() - } else { - const script = document.createElement('script') - script.src = `${IDE_CDN_URL}/${engineVersion}/index.js.gz` - script.onload = async () => { - document.body.removeChild(script) - await runNewProject() - } - document.body.appendChild(script) - } - return + const ideVersion = ( + await backendService.listVersions({ + versionType: service.VersionType.ide, + default: true, + }) + )[0] + const projectIdeVersion = project.ideVersion?.value ?? ideVersion.number.value + const stylesheetLink = document.createElement('link') + stylesheetLink.rel = 'stylesheet' + stylesheetLink.href = `${IDE_CDN_URL}/${projectIdeVersion}/style.css` + const indexScript = document.createElement('script') + indexScript.src = `${IDE_CDN_URL}/${projectIdeVersion}/index.js.gz` + indexScript.addEventListener('load', () => { + console.log('loaded') + resolveLoaded() + }) + document.head.append(stylesheetLink) + document.body.append(indexScript) + })() + }, []) + + react.useEffect(() => { + void (async () => { + while (ideElement?.firstChild) { + ideElement.removeChild(ideElement.firstChild) } + const ideVersion = ( + await backendService.listVersions({ + versionType: service.VersionType.ide, + default: true, + }) + )[0] + const backendVersion = ( + await backendService.listVersions({ + versionType: service.VersionType.backend, + default: true, + }) + )[0] + const projectIdeVersion = project.ideVersion?.value ?? ideVersion.number.value + const projectEngineVersion = project.engineVersion?.value ?? backendVersion.number.value + await loaded + await window.enso.main({ + loader: { + assetsUrl: `${IDE_CDN_URL}/${projectIdeVersion}/dynamic-assets`, + wasmUrl: `${IDE_CDN_URL}/${projectIdeVersion}/pkg-opt.wasm`, + jsUrl: `${IDE_CDN_URL}/${projectIdeVersion}/pkg.js.gz`, + }, + engine: { + rpcUrl: `${project.address!}json`, + dataUrl: `${project.address!}binary`, + preferredVersion: projectEngineVersion, + }, + startup: { + project: project.packageName, + }, + }) })() }, [project]) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx index 575d458ef20c..95764520bd71 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx @@ -1,8 +1,10 @@ /** @file An interactive button displaying the status of a project. */ import * as react from 'react' +import * as reactDom from 'react-dom' -import * as backendProvider from '../../providers/backend' -import * as cloudService from '../cloudService' +import * as auth from '../../authentication/providers/auth' +import * as backend from '../service' +import * as loggerProvider from '../../providers/logger' import * as svg from '../../components/svg' // ============= @@ -21,7 +23,7 @@ enum SpinnerState { // ================= /** The interval between requests checking whether the IDE is ready. */ -const CHECK_STATUS_INTERVAL = 10000 +const STATUS_CHECK_INTERVAL = 10000 const SPINNER_CSS_CLASSES: Record = { [SpinnerState.initial]: 'dasharray-5 ease-linear', @@ -29,68 +31,86 @@ const SPINNER_CSS_CLASSES: Record = { [SpinnerState.done]: 'dasharray-100 duration-1000 ease-in', } as const +/** Displayed when a project is ready to stop. */ +function StopIcon(spinnerState: SpinnerState) { + return ( + + + + + + ) +} + // ================= // === Component === // ================= export interface ProjectActionButtonProps { - project: cloudService.Asset + project: backend.Asset openIde: () => void } /** An interactive button displaying the status of a project. */ function ProjectActionButton(props: ProjectActionButtonProps) { const { project, openIde } = props - const { backend } = backendProvider.useBackend() + const { accessToken } = auth.useFullUserSession() + const logger = loggerProvider.useLogger() + const backendService = backend.createBackend(accessToken, logger) - const [state, setState] = react.useState(cloudService.ProjectState.created) - const [isCheckingStatus, setIsCheckingStatus] = react.useState(false) + const [state, setState] = react.useState(backend.ProjectState.created) + const [checkStatusInterval, setCheckStatusInterval] = react.useState(null) const [spinnerState, setSpinnerState] = react.useState(SpinnerState.done) - react.useEffect(() => { - async function checkProjectStatus() { - const response = await backend.getProjectDetails(project.id) - - setState(response.state.type) - - if (response.state.type === cloudService.ProjectState.opened) { - setSpinnerState(SpinnerState.done) - setIsCheckingStatus(false) - } - } - if (!isCheckingStatus) { - return - } else { - const handle = window.setInterval( - () => void checkProjectStatus(), - CHECK_STATUS_INTERVAL - ) - return () => { - clearInterval(handle) - } - } - }, [isCheckingStatus]) - react.useEffect(() => { void (async () => { - const projectDetails = await backend.getProjectDetails(project.id) + const projectDetails = await backendService.getProjectDetails(project.id) setState(projectDetails.state.type) - if (projectDetails.state.type === cloudService.ProjectState.openInProgress) { - setSpinnerState(SpinnerState.initial) - setIsCheckingStatus(true) - } })() }, []) function closeProject() { - setState(cloudService.ProjectState.closed) - window.tryStopProject() - void backend.closeProject(project.id) - setIsCheckingStatus(false) + setState(backend.ProjectState.closed) + void backendService.closeProject(project.id) + + reactDom.unstable_batchedUpdates(() => { + setCheckStatusInterval(null) + if (checkStatusInterval != null) { + clearInterval(checkStatusInterval) + } + }) } function openProject() { - setState(cloudService.ProjectState.openInProgress) + setState(backend.ProjectState.openInProgress) setSpinnerState(SpinnerState.initial) // The `setTimeout` is required so that the completion percentage goes from // the `initial` fraction to the `loading` fraction, @@ -98,27 +118,41 @@ function ProjectActionButton(props: ProjectActionButtonProps) { setTimeout(() => { setSpinnerState(SpinnerState.loading) }, 0) - void backend.openProject(project.id) - setIsCheckingStatus(true) + + void backendService.openProject(project.id) + + const checkProjectStatus = async () => { + const response = await backendService.getProjectDetails(project.id) + + setState(response.state.type) + + if (response.state.type === backend.ProjectState.opened) { + setCheckStatusInterval(null) + if (checkStatusInterval != null) { + clearInterval(checkStatusInterval) + } + setSpinnerState(SpinnerState.done) + } + } + + reactDom.unstable_batchedUpdates(() => { + setCheckStatusInterval( + window.setInterval(() => void checkProjectStatus(), STATUS_CHECK_INTERVAL) + ) + }) } switch (state) { - case cloudService.ProjectState.created: - case cloudService.ProjectState.new: - case cloudService.ProjectState.closed: + case backend.ProjectState.created: + case backend.ProjectState.new: + case backend.ProjectState.closed: return - case cloudService.ProjectState.openInProgress: - return ( - - ) - case cloudService.ProjectState.opened: + case backend.ProjectState.openInProgress: + return + case backend.ProjectState.opened: return ( <> - + ) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx index 68dd4bf13f93..12b6a0ee486b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx @@ -2,14 +2,14 @@ import * as react from 'react' import toast from 'react-hot-toast' -import * as cloudService from '../cloudService' +import * as backendModule from '../service' import * as error from '../../error' import * as modalProvider from '../../providers/modal' import CreateForm, * as createForm from './createForm' export interface ProjectCreateFormProps extends createForm.CreateFormPassthroughProps { - backend: cloudService.Backend - directoryId: cloudService.DirectoryId + backend: backendModule.Backend + directoryId: backendModule.DirectoryId onSuccess: () => void } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx index c77ee7d32b21..d6994a085a01 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx @@ -2,14 +2,14 @@ import * as react from 'react' import toast from 'react-hot-toast' -import * as cloudService from '../cloudService' +import * as backendModule from '../service' import * as error from '../../error' import * as modalProvider from '../../providers/modal' import CreateForm, * as createForm from './createForm' export interface SecretCreateFormProps extends createForm.CreateFormPassthroughProps { - backend: cloudService.Backend - directoryId: cloudService.DirectoryId + backend: backendModule.Backend + directoryId: backendModule.DirectoryId onSuccess: () => void } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx index 0bdfca9c6dfe..d34a67373f3c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx @@ -1,7 +1,18 @@ /** @file Renders the list of templates from which a project can be created. */ -import * as platformModule from '../../platform' import * as svg from '../../components/svg' +// ================= +// === Constants === +// ================= + +/** + * Dash border spacing is not supported by native CSS. + * Therefore, use a background image to create the border. + * It is essentially an SVG image that was generated by the website. + * @see {@link https://kovart.github.io/dashed-border-generator} + */ +const BORDER = `url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%233e515f' stroke-width='4' stroke-dasharray='15%2c 15' stroke-dashoffset='0' stroke-linecap='butt'/%3e%3c/svg%3e")` + // ================= // === Templates === // ================= @@ -11,70 +22,37 @@ interface Template { title: string description: string id: string - background: string } -/** The full list of templates available to cloud projects. */ -const CLOUD_TEMPLATES: Template[] = [ +/** All templates for creating projects that have contents. */ +const TEMPLATES: Template[] = [ { title: 'Colorado COVID', id: 'Colorado_COVID', description: 'Learn to glue multiple spreadsheets to analyses all your data at once.', - background: '#6b7280', }, { title: 'KMeans', id: 'Kmeans', description: 'Learn where to open a coffee shop to maximize your income.', - background: '#6b7280', }, { title: 'NASDAQ Returns', id: 'NASDAQ_Returns', description: 'Learn how to clean your data to prepare it for advanced analysis.', - background: '#6b7280', }, { title: 'Restaurants', id: 'Orders', description: 'Learn how to clean your data to prepare it for advanced analysis.', - background: '#6b7280', }, { title: 'Github Stars', id: 'Stargazers', description: 'Learn how to clean your data to prepare it for advanced analysis.', - background: '#6b7280', - }, -] - -/** The full list of templates available to local projects. */ -const DESKTOP_TEMPLATES: Template[] = [ - { - title: 'Combine spreadsheets', - id: 'Orders', - description: 'Glue multiple spreadsheets together to analyse all your data at once.', - background: 'url("/spreadsheets.png") 50% 20% / 80% no-repeat, #479366', - }, - { - title: 'Geospatial analysis', - id: 'Restaurants', - description: 'Learn where to open a coffee shop to maximize your income.', - background: 'url("/geo.png") center / cover', - }, - { - title: 'Analyze GitHub stars', - id: 'Stargazers', - description: "Find out which of Enso's repositories are most popular over time.", - background: 'url("/visualize.png") center / cover', }, ] -const TEMPLATES: Record = { - [platformModule.Platform.cloud]: CLOUD_TEMPLATES, - [platformModule.Platform.desktop]: DESKTOP_TEMPLATES, -} - // ======================= // === TemplatesRender === // ======================= @@ -117,12 +95,7 @@ function TemplatesRender(props: TemplatesRenderProps) { onTemplateClick(template.id) }} > -
+

{template.title}

@@ -142,20 +115,16 @@ function TemplatesRender(props: TemplatesRenderProps) { /** The `TemplatesRender`'s container. */ interface TemplatesProps { - backendPlatform: platformModule.Platform - onTemplateClick: (name?: string | null) => void + onTemplateClick: (name: string | null) => void } function Templates(props: TemplatesProps) { - const { backendPlatform, onTemplateClick } = props + const { onTemplateClick } = props return (
- +
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx index 133d8d151417..b4adfdebf138 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx @@ -1,9 +1,7 @@ /** @file The top-bar of dashboard. */ import * as dashboard from './dashboard' -import * as platformModule from '../../platform' -import * as svg from '../../components/svg' - import * as modalProvider from '../../providers/modal' +import * as svg from '../../components/svg' import UserMenu from './userMenu' @@ -12,12 +10,9 @@ import UserMenu from './userMenu' // ============== interface TopBarProps { - platform: platformModule.Platform projectName: string | null tab: dashboard.Tab toggleTab: () => void - backendPlatform: platformModule.Platform - setBackendPlatform: (backendPlatform: platformModule.Platform) => void query: string setQuery: (value: string) => void } @@ -27,49 +22,12 @@ interface TopBarProps { * because `searchVal` may change parent component's project list. */ function TopBar(props: TopBarProps) { - const { - platform, - projectName, - tab, - toggleTab, - backendPlatform, - setBackendPlatform, - query, - setQuery, - } = props + const { projectName, tab, toggleTab, query, setQuery } = props const { setModal } = modalProvider.useSetModal() return (
- {platform === platformModule.Platform.desktop && ( -
- - -
- )}
void } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx index 61b205bc9401..3cc1dfbf42e5 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx @@ -35,7 +35,6 @@ function UserMenuItem(props: react.PropsWithChildren) { function UserMenu() { const { signOut } = auth.useAuth() const { accessToken, organization } = auth.useFullUserSession() - const { setModal } = modalProvider.useSetModal() const goToProfile = () => { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localService.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localService.ts deleted file mode 100644 index 8a399353be51..000000000000 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localService.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** @file Module containing the API client for the local backend API. - * - * Each exported function in the {@link Backend} in this module corresponds to an API endpoint. The - * functions are asynchronous and return a `Promise` that resolves to the response from the API. */ -import * as cloudService from './cloudService' -import * as newtype from '../newtype' -import * as projectManager from './projectManager' - -// ======================== -// === Helper functions === -// ======================== - -function ipWithSocketToAddress(ipWithSocket: projectManager.IpWithSocket) { - return newtype.asNewtype(`ws://${ipWithSocket.host}:${ipWithSocket.port}`) -} - -// =============== -// === Backend === -// =============== - -interface CurrentlyOpenProjectInfo { - id: projectManager.ProjectId - project: projectManager.OpenProject -} - -export class Backend implements Partial { - private readonly projectManager = projectManager.ProjectManager.default - private currentlyOpenProject: CurrentlyOpenProjectInfo | null = null - - async listDirectory(): Promise { - const result = await this.projectManager.listProjects({}) - return result.projects.map(project => ({ - type: cloudService.AssetType.project, - title: project.name, - id: project.id, - parentId: '', - permissions: [], - })) - } - - async listProjects(): Promise { - const result = await this.projectManager.listProjects({}) - return result.projects.map(project => ({ - name: project.name, - organizationId: '', - projectId: project.id, - packageName: project.name, - state: { - type: cloudService.ProjectState.created, - }, - jsonAddress: null, - binaryAddress: null, - })) - } - - async createProject( - body: cloudService.CreateProjectRequestBody - ): Promise { - const project = await this.projectManager.createProject({ - name: newtype.asNewtype(body.projectName), - projectTemplate: body.projectTemplateName ?? '', - missingComponentAction: projectManager.MissingComponentAction.install, - }) - return { - name: body.projectName, - organizationId: '', - projectId: project.projectId, - packageName: body.projectName, - state: { - type: cloudService.ProjectState.created, - }, - } - } - - async closeProject(projectId: cloudService.ProjectId): Promise { - await this.projectManager.closeProject({ projectId }) - this.currentlyOpenProject = null - } - - async getProjectDetails(projectId: cloudService.ProjectId): Promise { - if (projectId !== this.currentlyOpenProject?.id) { - const result = await this.projectManager.listProjects({}) - const project = result.projects.find(listedProject => listedProject.id === projectId) - const engineVersion = project?.engineVersion - if (project == null) { - throw new Error(`The project ID '${projectId}' is invalid.`) - } else if (engineVersion == null) { - throw new Error(`The project '${projectId}' does not have an engine version.`) - } else { - return Promise.resolve({ - name: project.name, - engineVersion: { - lifecycle: cloudService.VersionLifecycle.stable, - value: engineVersion, - }, - ideVersion: { - lifecycle: cloudService.VersionLifecycle.stable, - value: engineVersion, - }, - jsonAddress: null, - binaryAddress: null, - organizationId: '', - packageName: project.name, - projectId, - state: { - type: cloudService.ProjectState.closed, - }, - }) - } - } else { - const project = this.currentlyOpenProject.project - return Promise.resolve({ - name: project.projectName, - engineVersion: { - lifecycle: cloudService.VersionLifecycle.stable, - value: project.engineVersion, - }, - ideVersion: { - lifecycle: cloudService.VersionLifecycle.stable, - value: project.engineVersion, - }, - jsonAddress: ipWithSocketToAddress(project.languageServerJsonAddress), - binaryAddress: ipWithSocketToAddress(project.languageServerBinaryAddress), - organizationId: '', - packageName: project.projectName, - projectId, - state: { - type: cloudService.ProjectState.opened, - }, - }) - } - } - - async openProject(projectId: cloudService.ProjectId): Promise { - const project = await this.projectManager.openProject({ - projectId, - missingComponentAction: projectManager.MissingComponentAction.install, - }) - this.currentlyOpenProject = { id: projectId, project } - } -} - -// ===================== -// === createBackend === -// ===================== - -/** Shorthand method for creating a new instance of the backend API. */ -export function createBackend(): Backend { - return new Backend() -} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts deleted file mode 100644 index c507c6a39916..000000000000 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** @file This module defines the Project Manager endpoint. */ -import * as newtype from '../newtype' - -// ================= -// === Constants === -// ================= - -const PROJECT_MANAGER_ENDPOINT = 'ws://127.0.0.1:30535' -/** Duration before the {@link ProjectManager} tries to create a WebSocket again. */ -const RETRY_INTERVAL = 1000 -/** Duration after which the {@link ProjectManager} stops re-trying to create a WebSocket. */ -const STOP_TRYING_AFTER = 10000 - -// ============= -// === Types === -// ============= - -export enum MissingComponentAction { - fail = 'Fail', - install = 'Install', - forceInstallBroken = 'ForceInstallBroken', -} - -interface JSONRPCError { - code: number - message: string - data?: unknown -} - -interface JSONRPCBaseResponse { - jsonrpc: '2.0' - id: number -} - -interface JSONRPCSuccessResponse extends JSONRPCBaseResponse { - result: T -} - -interface JSONRPCErrorResponse extends JSONRPCBaseResponse { - error: JSONRPCError -} - -type JSONRPCResponse = JSONRPCErrorResponse | JSONRPCSuccessResponse - -// This intentionally has the same brand as in the cloud backend API. -export type ProjectId = newtype.Newtype -export type ProjectName = newtype.Newtype -export type UTCDateTime = newtype.Newtype - -export interface ProjectMetadata { - name: ProjectName - namespace: string - id: ProjectId - engineVersion: string | null - lastOpened: UTCDateTime | null -} - -export interface IpWithSocket { - host: string - port: number -} - -export interface ProjectList { - projects: ProjectMetadata[] -} - -export interface CreateProject { - projectId: ProjectId -} - -export interface OpenProject { - engineVersion: string - languageServerJsonAddress: IpWithSocket - languageServerBinaryAddress: IpWithSocket - projectName: ProjectName - projectNamespace: string -} - -// ================================ -// === Parameters for endpoints === -// ================================ - -export interface OpenProjectParams { - projectId: ProjectId - missingComponentAction: MissingComponentAction -} - -export interface CloseProjectParams { - projectId: ProjectId -} - -export interface ListProjectsParams { - numberOfProjects?: number -} - -export interface CreateProjectParams { - name: ProjectName - projectTemplate?: string - version?: string - missingComponentAction?: MissingComponentAction -} - -export interface RenameProjectParams { - projectId: ProjectId - name: ProjectName -} - -export interface DeleteProjectParams { - projectId: ProjectId -} - -export interface ListSamplesParams { - projectId: ProjectId -} - -// ======================= -// === Project Manager === -// ======================= - -/** A WebSocket endpoint to the project manager. */ -export class ProjectManager { - static default = new ProjectManager(PROJECT_MANAGER_ENDPOINT) - protected id = 0 - protected resolvers = new Map void>() - protected rejecters = new Map void>() - protected socketPromise: Promise - - constructor(protected readonly connectionUrl: string) { - const createSocket = () => { - this.resolvers = new Map() - const oldRejecters = this.rejecters - this.rejecters = new Map() - for (const reject of oldRejecters.values()) { - reject() - } - this.socketPromise = new Promise((resolve, reject) => { - const handle = setInterval(() => { - try { - const socket = new WebSocket(this.connectionUrl) - clearInterval(handle) - socket.onmessage = event => { - // There is no way to avoid this as `JSON.parse` returns `any`. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument - const message: JSONRPCResponse = JSON.parse(event.data) - if ('result' in message) { - this.resolvers.get(message.id)?.(message.result) - } else { - this.rejecters.get(message.id)?.(message.error) - } - } - socket.onerror = createSocket - socket.onclose = createSocket - resolve(socket) - } catch { - // Ignored; the `setInterval` will retry again eventually. - } - }, RETRY_INTERVAL) - setTimeout(() => { - clearInterval(handle) - reject() - }, STOP_TRYING_AFTER) - }) - return this.socketPromise - } - this.socketPromise = createSocket() - } - - /** Open an existing project. */ - public async openProject(params: OpenProjectParams): Promise { - return this.sendRequest('project/open', params) - } - - /** Close an open project. */ - public async closeProject(params: CloseProjectParams): Promise { - return this.sendRequest('project/close', params) - } - - /** Get the projects list, sorted by open time. */ - public async listProjects(params: ListProjectsParams): Promise { - return this.sendRequest('project/list', params) - } - - /** Create a new project. */ - public async createProject(params: CreateProjectParams): Promise { - return this.sendRequest('project/create', { - missingComponentAction: MissingComponentAction.install, - ...params, - }) - } - - /** Rename a project. */ - public async renameProject(params: RenameProjectParams): Promise { - return this.sendRequest('project/rename', params) - } - - /** Delete a project. */ - public async deleteProject(params: DeleteProjectParams): Promise { - return this.sendRequest('project/delete', params) - } - - /** Get the list of sample projects that are available to the user. */ - public async listSamples(params: ListSamplesParams): Promise { - return this.sendRequest('project/listSample', params) - } - - private cleanup(id: number) { - this.resolvers.delete(id) - this.rejecters.delete(id) - } - - /** Send a JSON-RPC request to the project manager. */ - private async sendRequest(method: string, params: unknown): Promise { - const socket = await this.socketPromise - const id = this.id++ - socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })) - return new Promise((resolve, reject) => { - this.resolvers.set(id, value => { - this.cleanup(id) - resolve(value) - }) - this.rejecters.set(id, value => { - this.cleanup(id) - reject(value) - }) - }) - } -} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/cloudService.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts similarity index 95% rename from app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/cloudService.ts rename to app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts index e7fa1a07c28d..e0727a5c75f6 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/cloudService.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts @@ -158,15 +158,9 @@ export interface CreatedProject extends BaseProject { packageName: string } -/** A `Project` returned by the `listProjects` endpoint. */ -export interface ListedProjectRaw extends CreatedProject { - address: Address | null -} - /** A `Project` returned by `listProjects`. */ export interface ListedProject extends CreatedProject { - binaryAddress: Address | null - jsonAddress: Address | null + address: Address | null } /** A `Project` returned by `updateProject`. */ @@ -176,12 +170,6 @@ export interface UpdatedProject extends BaseProject { engineVersion: VersionNumber | null } -/** A user/organization's project containing and/or currently executing code. */ -export interface ProjectRaw extends ListedProjectRaw { - ideVersion: VersionNumber | null - engineVersion: VersionNumber | null -} - /** A user/organization's project containing and/or currently executing code. */ export interface Project extends ListedProject { ideVersion: VersionNumber | null @@ -429,7 +417,7 @@ interface ListDirectoryResponseBody { /** HTTP response body for the "list projects" endpoint. */ interface ListProjectsResponseBody { - projects: ListedProjectRaw[] + projects: ListedProject[] } /** HTTP response body for the "list files" endpoint. */ @@ -554,17 +542,7 @@ export class Backend { if (response.status !== STATUS_OK) { return this.throw('Unable to list projects.') } else { - return (await response.json()).projects.map(project => ({ - ...project, - jsonAddress: - project.address != null - ? newtype.asNewtype
(`${project.address}json`) - : null, - binaryAddress: - project.address != null - ? newtype.asNewtype
(`${project.address}binary`) - : null, - })) + return (await response.json()).projects } } @@ -596,22 +574,11 @@ export class Backend { * * @throws An error if a 401 or 404 status code was received. */ async getProjectDetails(projectId: ProjectId): Promise { - const response = await this.get(getProjectDetailsPath(projectId)) + const response = await this.get(getProjectDetailsPath(projectId)) if (response.status !== STATUS_OK) { return this.throw(`Unable to get details of project with ID '${projectId}'.`) } else { - const project = await response.json() - return { - ...project, - jsonAddress: - project.address != null - ? newtype.asNewtype
(`${project.address}json`) - : null, - binaryAddress: - project.address != null - ? newtype.asNewtype
(`${project.address}binary`) - : null, - } + return await response.json() } } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx index 3ceecf34906b..ae802a6a5023 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx @@ -40,13 +40,17 @@ export function run(props: app.AppProps) { logger.log('Starting authentication/dashboard UI.') /** The root element that the authentication/dashboard app will be rendered into. */ const root = document.getElementById(ROOT_ELEMENT_ID) - const ideElement = document.getElementById(IDE_ELEMENT_ID) if (root == null) { logger.error(`Could not find root element with ID '${ROOT_ELEMENT_ID}'.`) - } else if (ideElement == null) { - logger.error(`Could not find IDE element with ID '${IDE_ELEMENT_ID}'.`) } else { - ideElement.hidden = true + // FIXME: https://github.com/enso-org/cloud-v2/issues/386 + // Temporary workaround on hiding the Enso root element preventing it from + // rendering next to authentication templates. We are uncovering this once the + // authentication library sets the user session. + const ide = document.getElementById(IDE_ELEMENT_ID) + if (ide != null) { + ide.style.display = 'none' + } reactDOM.createRoot(root).render() } } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx deleted file mode 100644 index e497503f7aa3..000000000000 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** @file */ -import * as react from 'react' - -import * as cloudService from '../dashboard/cloudService' -import * as localService from '../dashboard/localService' - -export interface BackendContextType { - backend: cloudService.Backend | localService.Backend - setBackend: (backend: cloudService.Backend | localService.Backend) => void -} - -// @ts-expect-error The default value will never be exposed -// as `backend` will always be accessed using `useBackend`. -const BackendContext = react.createContext(null) - -// React components should always have a sibling `Props` interface -// if they accept props. -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface BackendProviderProps extends React.PropsWithChildren { - initialBackend: cloudService.Backend | localService.Backend -} - -export function BackendProvider(props: BackendProviderProps) { - const { initialBackend, children } = props - const [backend, setBackend] = react.useState( - initialBackend - ) - return ( - - {children} - - ) -} - -export function useBackend() { - const { backend } = react.useContext(BackendContext) - return { backend } -} - -export function useSetBackend() { - const { setBackend } = react.useContext(BackendContext) - return { setBackend } -} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts index 1f5e48d11a21..31bca4b83914 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts @@ -3,11 +3,11 @@ import toast from 'react-hot-toast' -import * as cloudService from './dashboard/cloudService' +import * as backend from './dashboard/service' export async function uploadMultipleFiles( - backendService: cloudService.Backend, - directoryId: cloudService.DirectoryId, + backendService: backend.Backend, + directoryId: backend.DirectoryId, files: File[] ) { const fileCount = files.length diff --git a/app/ide-desktop/lib/dashboard/src/serviceWorker.ts b/app/ide-desktop/lib/dashboard/src/serviceWorker.ts index a242b1648cc9..b1e5f72a8c3d 100644 --- a/app/ide-desktop/lib/dashboard/src/serviceWorker.ts +++ b/app/ide-desktop/lib/dashboard/src/serviceWorker.ts @@ -1,17 +1,6 @@ /** @file A service worker that redirects paths without extensions to `/index.html`. */ /// -// ================= -// === Constants === -// ================= - -const IDE_CDN_URL = 'https://ensocdn.s3.us-west-1.amazonaws.com/ide' -const FALLBACK_VERSION = '2023.1.1-nightly.2023.4.13' - -// ===================== -// === Fetch handler === -// ===================== - // We `declare` a variable here because Service Workers have a different global scope. // eslint-disable-next-line no-restricted-syntax declare const self: ServiceWorkerGlobalScope @@ -25,9 +14,6 @@ self.addEventListener('fetch', event => { ) { event.respondWith(fetch('/index.html')) return - } else if (url.hostname === 'localhost' && url.pathname === '/style.css') { - event.respondWith(fetch(`${IDE_CDN_URL}/${FALLBACK_VERSION}/style.css`)) - return } else { return false } diff --git a/app/ide-desktop/lib/types/globals.d.ts b/app/ide-desktop/lib/types/globals.d.ts index 962502fa7c62..27ca6b15a947 100644 --- a/app/ide-desktop/lib/types/globals.d.ts +++ b/app/ide-desktop/lib/types/globals.d.ts @@ -8,6 +8,10 @@ interface StringConfig { [key: string]: StringConfig | string } +interface Enso { + main: (inputConfig?: StringConfig) => Promise +} + interface BuildInfo { commit: string version: string @@ -40,8 +44,7 @@ interface AuthenticationApi { declare global { interface Window { - tryStopProject: () => void - runProject: (inputConfig?: StringConfig) => Promise + enso: Enso authenticationApi: AuthenticationApi }