diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index f3453bc427a3..9c820d608023 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -198,6 +198,10 @@ 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 f7a620ebfd68..3db5b078676b 100644 --- a/app/ide-desktop/lib/content/esbuild-config.ts +++ b/app/ide-desktop/lib/content/esbuild-config.ts @@ -96,7 +96,6 @@ 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), @@ -108,13 +107,9 @@ 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` + // in `ensogl/pack/js/src/runner/index.ts`. + // All other files are ESM because of `"type": "module"` in `package.json`. 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 65625b65e350..d4e33dfb5555 100644 --- a/app/ide-desktop/lib/content/src/index.html +++ b/app/ide-desktop/lib/content/src/index.html @@ -37,7 +37,6 @@ -
diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index e1d0145b2787..3b5da038dcf0 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -8,8 +8,6 @@ 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 @@ -25,6 +23,8 @@ 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,104 +119,132 @@ function displayDeprecatedVersionDialog() { } // ======================== -// === Main Entry Point === +// === Main entry point === // ======================== interface StringConfig { [key: string]: StringConfig | string } -class Main { - async main(inputConfig: StringConfig) { - const config = Object.assign( - { - loader: { - wasmUrl: 'pkg-opt.wasm', - jsUrl: 'pkg.js', - assetsUrl: 'dynamic-assets', - }, - }, - inputConfig - ) - - const appInstance = new app.App({ - config, - configOptions: contentConfig.OPTIONS, - packageInfo: { - version: BUILD_INFO.version, - engineVersion: BUILD_INFO.engineVersion, +// 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', }, - }) + }, + inputConfig + ) - if (appInstance.initialized) { - if (contentConfig.OPTIONS.options.dataCollection.value) { - // TODO: Add remote-logging here. - } - 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}'.`) - } - } + currentAppInstance = new app.App({ + config, + configOptions: contentConfig.OPTIONS, + packageInfo: { + version: BUILD_INFO.version, + engineVersion: BUILD_INFO.engineVersion, + }, + }) + console.log('bruh', currentAppInstance) + + 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 { - console.error('Failed to initialize the application.') + 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}'.`) + } + void currentAppInstance.run() } } } -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 +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() + } + } + } + authentication.run({ + logger, + platform, + showDashboard: contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, + onAuthenticated, + }) +} else { + void runProject() +} diff --git a/app/ide-desktop/lib/content/src/newtype.ts b/app/ide-desktop/lib/content/src/newtype.ts deleted file mode 100644 index b3a85ec61518..000000000000 --- a/app/ide-desktop/lib/content/src/newtype.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** @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 deleted file mode 100644 index 58777d0551db..000000000000 --- a/app/ide-desktop/lib/content/src/project_manager.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** @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 deleted file mode 100644 index 91a503a25ef6..000000000000 --- a/app/ide-desktop/lib/content/src/run.js +++ /dev/null @@ -1,4 +0,0 @@ -/** @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 6e0fcf478620..e8afd0d5b252 100644 --- a/app/ide-desktop/lib/content/src/style.css +++ b/app/ide-desktop/lib/content/src/style.css @@ -73,11 +73,6 @@ /* 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 a6f5bac986bd..87e63b82d123 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,7 +9,8 @@ import toast from 'react-hot-toast' import * as app from '../../components/app' import * as authServiceModule from '../service' -import * as backendService from '../../dashboard/service' +import * as backendProvider from '../../providers/backend' +import * as cloudService from '../../dashboard/cloudService' import * as errorModule from '../../error' import * as loggerProvider from '../../providers/logger' import * as newtype from '../../newtype' @@ -49,7 +50,7 @@ export interface FullUserSession { /** User's email address. */ email: string /** User's organization information. */ - organization: backendService.UserOrOrganization + organization: cloudService.UserOrOrganization } /** Object containing the currently signed-in user's session data, if the user has not yet set their @@ -138,6 +139,7 @@ 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, []) @@ -157,7 +159,8 @@ export function AuthProvider(props: AuthProviderProps) { } else { const { accessToken, email } = session.val - const backend = backendService.createBackend(accessToken, logger) + const backend = cloudService.createBackend(accessToken, logger) + setBackend(backend) const organization = await backend.usersMe() let newUserSession: UserSession if (!organization) { @@ -253,11 +256,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 = backendService.createBackend(accessToken, logger) + const backend = cloudService.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 279cd22ee9ff..bfc148108d13 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,12 +38,13 @@ import * as react from 'react' import * as router from 'react-router-dom' import * as toast from 'react-hot-toast' -import * as projectManagerModule from 'enso-content/src/project_manager' +import * as app from '../../../../../../../../target/ensogl-pack/linked-dist/index' 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' @@ -79,26 +80,16 @@ export const SET_USERNAME_PATH = '/set-username' // === App === // =========== -interface BaseAppProps { +/** Global configuration for the `App` component. */ +export interface AppProps { 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. * @@ -171,12 +162,15 @@ function AppRouter(props: AppProps) { userSession={userSession} registerAuthEventListener={registerAuthEventListener} > - - {routes} - + {/* @ts-expect-error Auth will always set this before dashboard is rendered. */} + + + {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 c1ecda9868db..a00b416e20e9 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,6 +235,71 @@ 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/service.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/cloudService.ts similarity index 95% rename from app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts rename to app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/cloudService.ts index e0727a5c75f6..e7fa1a07c28d 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/cloudService.ts @@ -158,9 +158,15 @@ 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 { - address: Address | null + binaryAddress: Address | null + jsonAddress: Address | null } /** A `Project` returned by `updateProject`. */ @@ -170,6 +176,12 @@ 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 @@ -417,7 +429,7 @@ interface ListDirectoryResponseBody { /** HTTP response body for the "list projects" endpoint. */ interface ListProjectsResponseBody { - projects: ListedProject[] + projects: ListedProjectRaw[] } /** HTTP response body for the "list files" endpoint. */ @@ -542,7 +554,17 @@ export class Backend { if (response.status !== STATUS_OK) { return this.throw('Unable to list projects.') } else { - return (await response.json()).projects + 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, + })) } } @@ -574,11 +596,22 @@ 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 { - return await response.json() + 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, + } } } 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 9619f1b9b31a..a87f55139f44 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,19 +2,20 @@ * interactive components. */ import * as react from 'react' -import * as projectManagerModule from 'enso-content/src/project_manager' - -import * as auth from '../../authentication/providers/auth' -import * as backend from '../service' +import * as cloudService from '../cloudService' import * as fileInfo from '../../fileInfo' import * as hooks from '../../hooks' -import * as loggerProvider from '../../providers/logger' -import * as modalProvider from '../../providers/modal' +import * as localService from '../localService' 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' @@ -73,8 +74,8 @@ enum Column { export interface CreateFormProps { left: number top: number - backend: backend.Backend - directoryId: backend.DirectoryId + backend: cloudService.Backend + directoryId: cloudService.DirectoryId onSuccess: () => void } @@ -89,23 +90,28 @@ 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 = { - [backend.AssetType.project]: 'Projects', - [backend.AssetType.file]: 'Files', - [backend.AssetType.secret]: 'Secrets', - [backend.AssetType.directory]: 'Folders', +const ASSET_TYPE_NAME: Record = { + [cloudService.AssetType.project]: 'Projects', + [cloudService.AssetType.file]: 'Files', + [cloudService.AssetType.secret]: 'Secrets', + [cloudService.AssetType.directory]: 'Folders', } as const /** Forms to create each asset type. */ -const ASSET_TYPE_CREATE_FORM: Record JSX.Element> = { - [backend.AssetType.project]: ProjectCreateForm, - [backend.AssetType.file]: FileCreateForm, - [backend.AssetType.secret]: SecretCreateForm, - [backend.AssetType.directory]: DirectoryCreateForm, +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, } /** English names for every column except for the name column. */ @@ -121,23 +127,23 @@ const COLUMN_NAME: Record, string> = { } as const /** The corresponding `Permissions` for each backend `PermissionAction`. */ -const PERMISSION: Record = { - [backend.PermissionAction.own]: { type: permissionDisplay.Permission.owner }, - [backend.PermissionAction.execute]: { +const PERMISSION: Record = { + [cloudService.PermissionAction.own]: { type: permissionDisplay.Permission.owner }, + [cloudService.PermissionAction.execute]: { type: permissionDisplay.Permission.regular, read: false, write: false, docsWrite: false, exec: true, }, - [backend.PermissionAction.edit]: { + [cloudService.PermissionAction.edit]: { type: permissionDisplay.Permission.regular, read: false, write: true, docsWrite: false, exec: false, }, - [backend.PermissionAction.read]: { + [cloudService.PermissionAction.read]: { type: permissionDisplay.Permission.regular, read: true, write: false, @@ -181,100 +187,128 @@ const COLUMNS_FOR: Record = { // ======================== /** Returns the id of the root directory for a user or organization. */ -function rootDirectoryId(userOrOrganizationId: backend.UserOrOrganizationId) { - return newtype.asNewtype( - userOrOrganizationId.replace(/^organization-/, `${backend.AssetType.directory}-`) +function rootDirectoryId(userOrOrganizationId: cloudService.UserOrOrganizationId) { + return newtype.asNewtype( + userOrOrganizationId.replace(/^organization-/, `${cloudService.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 === // ================= -interface BaseDashboardProps { - logger: loggerProvider.Logger +export interface DashboardProps { 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 { logger, platform } = props + const { platform } = props + const logger = loggerProvider.useLogger() const { accessToken, organization } = auth.useFullUserSession() - const backendService = backend.createBackend(accessToken, logger) + const { backend } = backendProvider.useBackend() + const { setBackend } = backendProvider.useSetBackend() 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< - backend.Asset[] + cloudService.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< - backend.Asset[] + cloudService.Asset[] >([]) const [directoryAssets, setDirectoryAssetsRaw] = react.useState< - backend.Asset[] + cloudService.Asset[] >([]) const [secretAssets, setSecretAssetsRaw] = react.useState< - backend.Asset[] + cloudService.Asset[] + >([]) + const [fileAssets, setFileAssetsRaw] = react.useState< + cloudService.Asset[] >([]) - const [fileAssets, setFileAssetsRaw] = react.useState[]>( - [] - ) const [visibleProjectAssets, setVisibleProjectAssets] = react.useState< - backend.Asset[] + cloudService.Asset[] >([]) const [visibleDirectoryAssets, setVisibleDirectoryAssets] = react.useState< - backend.Asset[] + cloudService.Asset[] >([]) const [visibleSecretAssets, setVisibleSecretAssets] = react.useState< - backend.Asset[] + cloudService.Asset[] >([]) const [visibleFileAssets, setVisibleFileAssets] = react.useState< - backend.Asset[] + cloudService.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] - function setProjectAssets(newProjectAssets: backend.Asset[]) { + 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[] + ) { setProjectAssetsRaw(newProjectAssets) setVisibleProjectAssets(newProjectAssets.filter(asset => asset.title.includes(query))) } - function setDirectoryAssets(newDirectoryAssets: backend.Asset[]) { + function setDirectoryAssets( + newDirectoryAssets: cloudService.Asset[] + ) { setDirectoryAssetsRaw(newDirectoryAssets) setVisibleDirectoryAssets(newDirectoryAssets.filter(asset => asset.title.includes(query))) } - function setSecretAssets(newSecretAssets: backend.Asset[]) { + function setSecretAssets(newSecretAssets: cloudService.Asset[]) { setSecretAssetsRaw(newSecretAssets) setVisibleSecretAssets(newSecretAssets.filter(asset => asset.title.includes(query))) } - function setFileAssets(newFileAssets: backend.Asset[]) { + function setFileAssets(newFileAssets: cloudService.Asset[]) { setFileAssetsRaw(newFileAssets) setVisibleFileAssets(newFileAssets.filter(asset => asset.title.includes(query))) } @@ -287,7 +321,7 @@ function Dashboard(props: DashboardProps) { ) } - function enterDirectory(directoryAsset: backend.Asset) { + function enterDirectory(directoryAsset: cloudService.Asset) { setDirectoryId(directoryAsset.id) setDirectoryStack([...directoryStack, directoryAsset]) } @@ -297,7 +331,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: backend.Asset[] = + const cachedDirectoryStack: cloudService.Asset[] = JSON.parse(cachedDirectoryStackJson) setDirectoryStack(cachedDirectoryStack) const cachedDirectoryId = cachedDirectoryStack[cachedDirectoryStack.length - 1]?.id @@ -317,9 +351,9 @@ function Dashboard(props: DashboardProps) { /** React components for the name column. */ const nameRenderers: { - [Type in backend.AssetType]: (asset: backend.Asset) => JSX.Element + [Type in cloudService.AssetType]: (asset: cloudService.Asset) => JSX.Element } = { - [backend.AssetType.project]: projectAsset => ( + [cloudService.AssetType.project]: projectAsset => (
{ @@ -340,13 +374,17 @@ function Dashboard(props: DashboardProps) { project={projectAsset} openIde={async () => { setTab(Tab.ide) - setProject(await backendService.getProjectDetails(projectAsset.id)) + setProject(await backend.getProjectDetails(projectAsset.id)) + const ideElement = document.getElementById(IDE_ELEMENT_ID) + if (ideElement) { + ideElement.hidden = false + } }} /> {projectAsset.title}
), - [backend.AssetType.directory]: directoryAsset => ( + [cloudService.AssetType.directory]: directoryAsset => (
{ @@ -369,7 +407,7 @@ function Dashboard(props: DashboardProps) { {svg.DIRECTORY_ICON} {directoryAsset.title}
), - [backend.AssetType.secret]: secret => ( + [cloudService.AssetType.secret]: secret => (
{ @@ -389,7 +427,7 @@ function Dashboard(props: DashboardProps) { {svg.SECRET_ICON} {secret.title}
), - [backend.AssetType.file]: file => ( + [cloudService.AssetType.file]: file => (
{ @@ -415,7 +453,7 @@ function Dashboard(props: DashboardProps) { /** React components for every column except for the name column. */ const columnRenderer: Record< Exclude, - (asset: backend.Asset) => JSX.Element + (asset: cloudService.Asset) => JSX.Element > = { [Column.lastModified]: () => <>, [Column.sharedWith]: asset => ( @@ -461,17 +499,16 @@ 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: backend.Asset) => JSX.Element) + (nameRenderers[assetType] as (asset: cloudService.Asset) => JSX.Element) : columnRenderer[column] } /** Heading element for every column. */ - function ColumnHeading(column: Column, assetType: backend.AssetType) { + function ColumnHeading(column: Column, assetType: cloudService.AssetType) { return column === Column.name ? (
{ASSET_TYPE_NAME[assetType]} @@ -491,7 +528,7 @@ function Dashboard(props: DashboardProps) { left={buttonPosition.left} top={buttonPosition.top} // FIXME[sb]: Don't pass outdated `doRefresh` - maybe `backendService` too. - backend={backendService} + backend={asCloudBackend(backend)} directoryId={directoryId} onSuccess={doRefresh} /> @@ -514,11 +551,17 @@ function Dashboard(props: DashboardProps) { setVisibleFileAssets(fileAssets.filter(asset => asset.title.includes(query))) }, [query]) - 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)) + 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)) setProjectAssets(newProjectAssets) setDirectoryAssets(newDirectoryAssets) setSecretAssets(newSecretAssets) @@ -528,36 +571,12 @@ function Dashboard(props: DashboardProps) { hooks.useAsyncEffect( null, async signal => { - 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 - } - } + const assets = await backend.listDirectory({ parentId: directoryId }) if (!signal.aborted) { setAssets(assets) } }, - [accessToken, directoryId, refresh] + [accessToken, directoryId, refresh, backend] ) react.useEffect(() => { @@ -606,51 +625,24 @@ function Dashboard(props: DashboardProps) { return `${prefix}${highestProjectIndex + 1}` } - async function handleCreateProject(templateName: string | null) { + async function handleCreateProject(templateName?: string | null) { const projectName = getNewProjectName(templateName) - 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 body: cloudService.CreateProjectRequestBody = { + projectName, + projectTemplateName: templateName?.replace(/_/g, '').toLocaleLowerCase() ?? null, + parentDirectoryId: directoryId, } + const projectAsset = await backend.createProject(body) + setProjectAssets([ + ...projectAssets, + { + type: cloudService.AssetType.project, + title: projectAsset.name, + id: projectAsset.projectId, + parentId: '', + permissions: [], + }, + ]) } return ( @@ -664,19 +656,47 @@ 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

@@ -703,12 +723,16 @@ function Dashboard(props: DashboardProps) {
+
+ + +
+
+ + + + +
- +
- > + > items={visibleProjectAssets} getKey={proj => proj.id} placeholder={ @@ -801,8 +894,8 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, backend.AssetType.project), - render: renderer(column, backend.AssetType.project), + heading: ColumnHeading(column, cloudService.AssetType.project), + render: renderer(column, cloudService.AssetType.project), }))} onClick={projectAsset => { setSelectedAssets([projectAsset]) @@ -843,7 +936,7 @@ function Dashboard(props: DashboardProps) { name={projectAsset.title} assetType={projectAsset.type} doDelete={() => - backendService.deleteProject(projectAsset.id) + asCloudBackend(backend).deleteProject(projectAsset.id) } onSuccess={doRefresh} /> @@ -867,10 +960,10 @@ function Dashboard(props: DashboardProps) { )) }} /> - {platform === platformModule.Platform.cloud && ( + {backendPlatform === platformModule.Platform.cloud && ( <> - > + > items={visibleDirectoryAssets} getKey={dir => dir.id} placeholder={ @@ -881,8 +974,11 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, backend.AssetType.directory), - render: renderer(column, backend.AssetType.directory), + heading: ColumnHeading( + column, + cloudService.AssetType.directory + ), + render: renderer(column, cloudService.AssetType.directory), }))} onClick={directoryAsset => { setSelectedAssets([directoryAsset]) @@ -894,7 +990,7 @@ function Dashboard(props: DashboardProps) { }} /> - > + > items={visibleSecretAssets} getKey={secret => secret.id} placeholder={ @@ -905,8 +1001,8 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, backend.AssetType.secret), - render: renderer(column, backend.AssetType.secret), + heading: ColumnHeading(column, cloudService.AssetType.secret), + render: renderer(column, cloudService.AssetType.secret), }))} onClick={secret => { setSelectedAssets([secret]) @@ -922,7 +1018,7 @@ function Dashboard(props: DashboardProps) { name={secret.title} assetType={secret.type} doDelete={() => - backendService.deleteSecret(secret.id) + asCloudBackend(backend).deleteSecret(secret.id) } onSuccess={doRefresh} /> @@ -938,7 +1034,7 @@ function Dashboard(props: DashboardProps) { }} /> - > + > items={visibleFileAssets} getKey={file => file.id} placeholder={ @@ -949,8 +1045,8 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, backend.AssetType.file), - render: renderer(column, backend.AssetType.file), + heading: ColumnHeading(column, cloudService.AssetType.file), + render: renderer(column, cloudService.AssetType.file), }))} onClick={file => { setSelectedAssets([file]) @@ -971,7 +1067,9 @@ function Dashboard(props: DashboardProps) { backendService.deleteFile(file.id)} + doDelete={() => + asCloudBackend(backend).deleteFile(file.id) + } onSuccess={doRefresh} /> )) @@ -1001,7 +1099,7 @@ function Dashboard(props: DashboardProps) { )}
- {isFileBeingDragged ? ( + {isFileBeingDragged && backendPlatform === platformModule.Platform.cloud ? (
{ @@ -1014,7 +1112,7 @@ function Dashboard(props: DashboardProps) { event.preventDefault() setIsFileBeingDragged(false) await uploadMultipleFiles.uploadMultipleFiles( - backendService, + asCloudBackend(backend), directoryId, Array.from(event.dataTransfer.files) ) @@ -1025,7 +1123,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 8f13d6e4da19..e3b8f617227b 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 backendModule from '../service' +import * as cloudService from '../cloudService' import * as error from '../../error' import * as modalProvider from '../../providers/modal' import CreateForm, * as createForm from './createForm' export interface DirectoryCreateFormProps extends createForm.CreateFormPassthroughProps { - backend: backendModule.Backend - directoryId: backendModule.DirectoryId + backend: cloudService.Backend + directoryId: cloudService.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 20ae40b26c9b..b41008190b2e 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 backendModule from '../service' +import * as cloudService from '../cloudService' import * as error from '../../error' import * as modalProvider from '../../providers/modal' import CreateForm, * as createForm from './createForm' export interface FileCreateFormProps extends createForm.CreateFormPassthroughProps { - backend: backendModule.Backend - directoryId: backendModule.DirectoryId + backend: cloudService.Backend + directoryId: cloudService.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 d453f5b67fb7..0fe9ef42edb9 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,36 +1,35 @@ /** @file Container that launches the IDE. */ import * as react from 'react' -import * as service from '../service' +import * as backendProvider from '../../providers/backend' +import * as cloudService from '../cloudService' +import * as platformModule from '../../platform' // ================= // === Constants === // ================= -/** The `id` attribute of the element that the IDE will be rendered into. */ +/** The `id` attribute of the element into which the IDE will be rendered. */ 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: service.Project - backendService: service.Backend + project: cloudService.Project + backendPlatform: platformModule.Platform } /** Container that launches the IDE. */ function Ide(props: Props) { - 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] - }) + const { project, backendPlatform } = props + const { backend } = backendProvider.useBackend() react.useEffect(() => { document.getElementById(IDE_ELEMENT_ID)?.classList.remove('hidden') @@ -41,62 +40,77 @@ function Ide(props: Props) { react.useEffect(() => { void (async () => { - 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 = + 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 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 95764520bd71..575d458ef20c 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,10 +1,8 @@ /** @file An interactive button displaying the status of a project. */ import * as react from 'react' -import * as reactDom from 'react-dom' -import * as auth from '../../authentication/providers/auth' -import * as backend from '../service' -import * as loggerProvider from '../../providers/logger' +import * as backendProvider from '../../providers/backend' +import * as cloudService from '../cloudService' import * as svg from '../../components/svg' // ============= @@ -23,7 +21,7 @@ enum SpinnerState { // ================= /** The interval between requests checking whether the IDE is ready. */ -const STATUS_CHECK_INTERVAL = 10000 +const CHECK_STATUS_INTERVAL = 10000 const SPINNER_CSS_CLASSES: Record = { [SpinnerState.initial]: 'dasharray-5 ease-linear', @@ -31,86 +29,68 @@ 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: backend.Asset + project: cloudService.Asset openIde: () => void } /** An interactive button displaying the status of a project. */ function ProjectActionButton(props: ProjectActionButtonProps) { const { project, openIde } = props - const { accessToken } = auth.useFullUserSession() - const logger = loggerProvider.useLogger() - const backendService = backend.createBackend(accessToken, logger) + const { backend } = backendProvider.useBackend() - const [state, setState] = react.useState(backend.ProjectState.created) - const [checkStatusInterval, setCheckStatusInterval] = react.useState(null) + const [state, setState] = react.useState(cloudService.ProjectState.created) + const [isCheckingStatus, setIsCheckingStatus] = react.useState(false) 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 backendService.getProjectDetails(project.id) + const projectDetails = await backend.getProjectDetails(project.id) setState(projectDetails.state.type) + if (projectDetails.state.type === cloudService.ProjectState.openInProgress) { + setSpinnerState(SpinnerState.initial) + setIsCheckingStatus(true) + } })() }, []) function closeProject() { - setState(backend.ProjectState.closed) - void backendService.closeProject(project.id) - - reactDom.unstable_batchedUpdates(() => { - setCheckStatusInterval(null) - if (checkStatusInterval != null) { - clearInterval(checkStatusInterval) - } - }) + setState(cloudService.ProjectState.closed) + window.tryStopProject() + void backend.closeProject(project.id) + setIsCheckingStatus(false) } function openProject() { - setState(backend.ProjectState.openInProgress) + setState(cloudService.ProjectState.openInProgress) setSpinnerState(SpinnerState.initial) // The `setTimeout` is required so that the completion percentage goes from // the `initial` fraction to the `loading` fraction, @@ -118,41 +98,27 @@ function ProjectActionButton(props: ProjectActionButtonProps) { setTimeout(() => { setSpinnerState(SpinnerState.loading) }, 0) - - 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) - ) - }) + void backend.openProject(project.id) + setIsCheckingStatus(true) } switch (state) { - case backend.ProjectState.created: - case backend.ProjectState.new: - case backend.ProjectState.closed: + case cloudService.ProjectState.created: + case cloudService.ProjectState.new: + case cloudService.ProjectState.closed: return - case backend.ProjectState.openInProgress: - return - case backend.ProjectState.opened: + case cloudService.ProjectState.openInProgress: + return ( + + ) + case cloudService.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 12b6a0ee486b..68dd4bf13f93 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 backendModule from '../service' +import * as cloudService from '../cloudService' import * as error from '../../error' import * as modalProvider from '../../providers/modal' import CreateForm, * as createForm from './createForm' export interface ProjectCreateFormProps extends createForm.CreateFormPassthroughProps { - backend: backendModule.Backend - directoryId: backendModule.DirectoryId + backend: cloudService.Backend + directoryId: cloudService.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 d6994a085a01..c77ee7d32b21 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 backendModule from '../service' +import * as cloudService from '../cloudService' import * as error from '../../error' import * as modalProvider from '../../providers/modal' import CreateForm, * as createForm from './createForm' export interface SecretCreateFormProps extends createForm.CreateFormPassthroughProps { - backend: backendModule.Backend - directoryId: backendModule.DirectoryId + backend: cloudService.Backend + directoryId: cloudService.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 d34a67373f3c..0bdfca9c6dfe 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,18 +1,7 @@ /** @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 === // ================= @@ -22,37 +11,70 @@ interface Template { title: string description: string id: string + background: string } -/** All templates for creating projects that have contents. */ -const TEMPLATES: Template[] = [ +/** The full list of templates available to cloud projects. */ +const CLOUD_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 === // ======================= @@ -95,7 +117,12 @@ function TemplatesRender(props: TemplatesRenderProps) { onTemplateClick(template.id) }} > -
+

{template.title}

@@ -115,16 +142,20 @@ function TemplatesRender(props: TemplatesRenderProps) { /** The `TemplatesRender`'s container. */ interface TemplatesProps { - onTemplateClick: (name: string | null) => void + backendPlatform: platformModule.Platform + onTemplateClick: (name?: string | null) => void } function Templates(props: TemplatesProps) { - const { onTemplateClick } = props + const { backendPlatform, 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 b4adfdebf138..133d8d151417 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,8 +1,10 @@ /** @file The top-bar of dashboard. */ import * as dashboard from './dashboard' -import * as modalProvider from '../../providers/modal' +import * as platformModule from '../../platform' import * as svg from '../../components/svg' +import * as modalProvider from '../../providers/modal' + import UserMenu from './userMenu' // ============== @@ -10,9 +12,12 @@ 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 } @@ -22,12 +27,49 @@ interface TopBarProps { * because `searchVal` may change parent component's project list. */ function TopBar(props: TopBarProps) { - const { projectName, tab, toggleTab, query, setQuery } = props + const { + platform, + projectName, + tab, + toggleTab, + backendPlatform, + setBackendPlatform, + 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 3cc1dfbf42e5..61b205bc9401 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,6 +35,7 @@ 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 new file mode 100644 index 000000000000..8a399353be51 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localService.ts @@ -0,0 +1,150 @@ +/** @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 new file mode 100644 index 000000000000..c507c6a39916 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts @@ -0,0 +1,227 @@ +/** @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/index.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx index ae802a6a5023..3ceecf34906b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx @@ -40,17 +40,13 @@ 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 { - // 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' - } + ideElement.hidden = true 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 new file mode 100644 index 000000000000..e497503f7aa3 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx @@ -0,0 +1,43 @@ +/** @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 31bca4b83914..1f5e48d11a21 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 backend from './dashboard/service' +import * as cloudService from './dashboard/cloudService' export async function uploadMultipleFiles( - backendService: backend.Backend, - directoryId: backend.DirectoryId, + backendService: cloudService.Backend, + directoryId: cloudService.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 b1e5f72a8c3d..a242b1648cc9 100644 --- a/app/ide-desktop/lib/dashboard/src/serviceWorker.ts +++ b/app/ide-desktop/lib/dashboard/src/serviceWorker.ts @@ -1,6 +1,17 @@ /** @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 @@ -14,6 +25,9 @@ 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 27ca6b15a947..962502fa7c62 100644 --- a/app/ide-desktop/lib/types/globals.d.ts +++ b/app/ide-desktop/lib/types/globals.d.ts @@ -8,10 +8,6 @@ interface StringConfig { [key: string]: StringConfig | string } -interface Enso { - main: (inputConfig?: StringConfig) => Promise -} - interface BuildInfo { commit: string version: string @@ -44,7 +40,8 @@ interface AuthenticationApi { declare global { interface Window { - enso: Enso + tryStopProject: () => void + runProject: (inputConfig?: StringConfig) => Promise authenticationApi: AuthenticationApi }