Revert "Cloud/desktop mode switcher (#6308)" (#6444)

This commit is contained in:
Wojciech Daniło 2023-04-26 23:21:24 +02:00 committed by GitHub
parent a00efb28f3
commit d0e1dd582e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 723 additions and 1209 deletions

View File

@ -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 */

View File

@ -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 }) => ({

View File

@ -37,6 +37,7 @@
<link rel="stylesheet" href="/docsStyle.css" />
<link rel="stylesheet" href="/tailwind.css" />
<script type="module" src="/index.js" defer></script>
<script type="module" src="/run.js" defer></script>
</head>
<body>
<div id="enso-dashboard" class="enso-dashboard"></div>

View File

@ -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
)
inputConfig
)
currentAppInstance = new app.App({
config,
configOptions: contentConfig.OPTIONS,
packageInfo: {
version: BUILD_INFO.version,
engineVersion: BUILD_INFO.engineVersion,
},
})
console.log('bruh', currentAppInstance)
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()
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}'.`)
}
}
} 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}'.`)
}
void currentAppInstance.run()
console.error('Failed to initialize the application.')
}
}
}
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()
}
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

View File

@ -0,0 +1,39 @@
/** @file TypeScript's closest equivalent of `newtype`s. */
interface NewtypeVariant<TypeName extends string> {
// 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<string, 'A'>` and `Newtype<string, 'B'>` 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<Newtype<string, 'Name'>>(b)` successfully typechecks. */
export type Newtype<T, TypeName extends string> = NewtypeVariant<TypeName> & T
interface NotNewtype {
// eslint-disable-next-line @typescript-eslint/naming-convention
_$type?: never
}
export function asNewtype<T extends Newtype<unknown, string>>(
s: NotNewtype & Omit<T, '_$type'>
): 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
}

View File

@ -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<T> {
result: T
}
// This intentionally has the same brand as in the cloud backend API.
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
export type ProjectName = newtype.Newtype<string, 'ProjectName'>
export type UTCDateTime = newtype.Newtype<string, 'UTCDateTime'>
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<T = void>(method: string, params: unknown): Promise<Result<T>> {
const req = {
jsonrpc: '2.0',
id: 0,
method,
params,
}
const ws = new WebSocket(this.connectionUrl)
return new Promise<Result<T>>((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<Result<OpenProject>> {
return this.sendRequest<OpenProject>('project/open', params)
}
/** * Close an open project. */
public async closeProject(params: CloseProjectParams): Promise<Result<void>> {
return this.sendRequest('project/close', params)
}
/** * Get the projects list, sorted by open time. */
public async listProjects(params: ListProjectsParams): Promise<Result<ProjectList>> {
return this.sendRequest<ProjectList>('project/list', params)
}
/** * Create a new project. */
public async createProject(params: CreateProjectParams): Promise<Result<CreateProject>> {
return this.sendRequest<CreateProject>('project/create', {
missingComponentAction: MissingComponentAction.install,
...params,
})
}
/** * Rename a project. */
public async renameProject(params: RenameProjectParams): Promise<Result<void>> {
return this.sendRequest('project/rename', params)
}
/** * Delete a project. */
public async deleteProject(params: DeleteProjectParams): Promise<Result<void>> {
return this.sendRequest('project/delete', params)
}
/** * Get the list of sample projects that are available to the user. */
public async listSamples(params: ListSamplesParams): Promise<Result<ProjectList>> {
return this.sendRequest<ProjectList>('project/listSample', params)
}
}

View File

@ -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()

View File

@ -73,6 +73,11 @@
/* End of fonts */
html,
body {
height: 100vh;
}
body {
margin: 0;
overscroll-behavior: none;

View File

@ -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<cloudService.EmailAddress>(email),
userEmail: newtype.asNewtype<backendService.EmailAddress>(email),
})
navigate(app.DASHBOARD_PATH)
toast.success(MESSAGES.setUsernameSuccess)

View File

@ -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<platformModule.Platform, platformModule.Platform.desktop>
}
/** 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. */}
<backendProvider.BackendProvider initialBackend={null}>
<authProvider.AuthProvider
authService={memoizedAuthService}
onAuthenticated={onAuthenticated}
>
<modalProvider.ModalProvider>{routes}</modalProvider.ModalProvider>
</authProvider.AuthProvider>
</backendProvider.BackendProvider>
<authProvider.AuthProvider
authService={memoizedAuthService}
onAuthenticated={onAuthenticated}
>
<modalProvider.ModalProvider>{routes}</modalProvider.ModalProvider>
</authProvider.AuthProvider>
</sessionProvider.SessionProvider>
</loggerProvider.LoggerProvider>
)

View File

@ -235,71 +235,6 @@ export const CLOSE_ICON = (
</svg>
)
export const CLOUD_ICON = (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 16A2.9 2.9 0 1 1 8 10.5 4 4 0 0 1 15.5 11 2 2 0 0 1 17.5 12 1.9 1.9 0 1 1 18.5 16"
fill="currentColor"
/>
</svg>
)
export const COMPUTER_ICON = (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.5 18.5a1 1 0 0 1 0-2h3.5v-1.5h-3.5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1h-3.5v1.5h3.5a1 1 0 0 1 0 2ZM4 14a.5.5 0 0 1-.5-.5v-6a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5ZM17.3 18.5a1 1 0 0 1-1-1v-10.5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v10.5a1 1 0 0 1-1 1ZM17.3 9a.3.3 0 1 1 0-.6h3a.3.3 0 1 1 0 .6ZM18.8 16a.7.7 0 1 1 0-1.4.7.7 0 1 1 0 1.4Z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
)
export interface StopIconProps {
className?: string
}
/** Icon displayed when a project is ready to stop. */
export function StopIcon(props: StopIconProps) {
const { className } = props
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m9 8L15 8a1 1 0 0 1 1 1L16 15a1 1 0 0 1 -1 1L9 16a1 1 0 0 1 -1 -1L8 9a1 1 0 0 1 1 -1"
fill="currentColor"
/>
<rect
x={1.5}
y={1.5}
width={21}
height={21}
rx={10.5}
stroke="currentColor"
strokeOpacity={0.1}
strokeWidth={3}
/>
<rect
x={1.5}
y={1.5}
width={21}
height={21}
rx={10.5}
stroke="currentColor"
strokeLinecap="round"
strokeWidth={3}
className={`animate-spin-ease origin-center transition-stroke-dasharray ${
className ?? ''
}`}
/>
</svg>
)
}
// ===========
// === Svg ===
// ===========

View File

@ -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, string> = {
[cloudService.AssetType.project]: 'Projects',
[cloudService.AssetType.file]: 'Files',
[cloudService.AssetType.secret]: 'Secrets',
[cloudService.AssetType.directory]: 'Folders',
const ASSET_TYPE_NAME: Record<backend.AssetType, string> = {
[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<backend.AssetType, (props: CreateFormProps) => 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<Exclude<Column, Column.name>, string> = {
} as const
/** The corresponding `Permissions` for each backend `PermissionAction`. */
const PERMISSION: Record<cloudService.PermissionAction, permissionDisplay.Permissions> = {
[cloudService.PermissionAction.own]: { type: permissionDisplay.Permission.owner },
[cloudService.PermissionAction.execute]: {
const PERMISSION: Record<backend.PermissionAction, permissionDisplay.Permissions> = {
[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<ColumnDisplayMode, Column[]> = {
// ========================
/** Returns the id of the root directory for a user or organization. */
function rootDirectoryId(userOrOrganizationId: cloudService.UserOrOrganizationId) {
return newtype.asNewtype<cloudService.DirectoryId>(
userOrOrganizationId.replace(/^organization-/, `${cloudService.AssetType.directory}-`)
function rootDirectoryId(userOrOrganizationId: backend.UserOrOrganizationId) {
return newtype.asNewtype<backend.DirectoryId>(
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<platformModule.Platform, platformModule.Platform.desktop>
}
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<cloudService.AssetType.directory>[]
backend.Asset<backend.AssetType.directory>[]
>([])
// 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<cloudService.Project | null>(null)
const [selectedAssets, setSelectedAssets] = react.useState<cloudService.Asset[]>([])
const [isFileBeingDragged, setIsFileBeingDragged] = react.useState(false)
const [projectAssets, setProjectAssetsRaw] = react.useState<
cloudService.Asset<cloudService.AssetType.project>[]
backend.Asset<backend.AssetType.project>[]
>([])
const [directoryAssets, setDirectoryAssetsRaw] = react.useState<
cloudService.Asset<cloudService.AssetType.directory>[]
backend.Asset<backend.AssetType.directory>[]
>([])
const [secretAssets, setSecretAssetsRaw] = react.useState<
cloudService.Asset<cloudService.AssetType.secret>[]
>([])
const [fileAssets, setFileAssetsRaw] = react.useState<
cloudService.Asset<cloudService.AssetType.file>[]
backend.Asset<backend.AssetType.secret>[]
>([])
const [fileAssets, setFileAssetsRaw] = react.useState<backend.Asset<backend.AssetType.file>[]>(
[]
)
const [visibleProjectAssets, setVisibleProjectAssets] = react.useState<
cloudService.Asset<cloudService.AssetType.project>[]
backend.Asset<backend.AssetType.project>[]
>([])
const [visibleDirectoryAssets, setVisibleDirectoryAssets] = react.useState<
cloudService.Asset<cloudService.AssetType.directory>[]
backend.Asset<backend.AssetType.directory>[]
>([])
const [visibleSecretAssets, setVisibleSecretAssets] = react.useState<
cloudService.Asset<cloudService.AssetType.secret>[]
backend.Asset<backend.AssetType.secret>[]
>([])
const [visibleFileAssets, setVisibleFileAssets] = react.useState<
cloudService.Asset<cloudService.AssetType.file>[]
backend.Asset<backend.AssetType.file>[]
>([])
const [tab, setTab] = react.useState(Tab.dashboard)
const [project, setProject] = react.useState<backend.Project | null>(null)
const [selectedAssets, setSelectedAssets] = react.useState<backend.Asset[]>([])
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<cloudService.AssetType.project>[]
) {
function setProjectAssets(newProjectAssets: backend.Asset<backend.AssetType.project>[]) {
setProjectAssetsRaw(newProjectAssets)
setVisibleProjectAssets(newProjectAssets.filter(asset => asset.title.includes(query)))
}
function setDirectoryAssets(
newDirectoryAssets: cloudService.Asset<cloudService.AssetType.directory>[]
) {
function setDirectoryAssets(newDirectoryAssets: backend.Asset<backend.AssetType.directory>[]) {
setDirectoryAssetsRaw(newDirectoryAssets)
setVisibleDirectoryAssets(newDirectoryAssets.filter(asset => asset.title.includes(query)))
}
function setSecretAssets(newSecretAssets: cloudService.Asset<cloudService.AssetType.secret>[]) {
function setSecretAssets(newSecretAssets: backend.Asset<backend.AssetType.secret>[]) {
setSecretAssetsRaw(newSecretAssets)
setVisibleSecretAssets(newSecretAssets.filter(asset => asset.title.includes(query)))
}
function setFileAssets(newFileAssets: cloudService.Asset<cloudService.AssetType.file>[]) {
function setFileAssets(newFileAssets: backend.Asset<backend.AssetType.file>[]) {
setFileAssetsRaw(newFileAssets)
setVisibleFileAssets(newFileAssets.filter(asset => asset.title.includes(query)))
}
@ -321,7 +287,7 @@ function Dashboard(props: DashboardProps) {
)
}
function enterDirectory(directoryAsset: cloudService.Asset<cloudService.AssetType.directory>) {
function enterDirectory(directoryAsset: backend.Asset<backend.AssetType.directory>) {
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<cloudService.AssetType.directory>[] =
const cachedDirectoryStack: backend.Asset<backend.AssetType.directory>[] =
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<Type>) => JSX.Element
[Type in backend.AssetType]: (asset: backend.Asset<Type>) => JSX.Element
} = {
[cloudService.AssetType.project]: projectAsset => (
[backend.AssetType.project]: projectAsset => (
<div
className="flex text-left items-center align-middle whitespace-nowrap"
onClick={event => {
@ -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))
}}
/>
<span className="px-2">{projectAsset.title}</span>
</div>
),
[cloudService.AssetType.directory]: directoryAsset => (
[backend.AssetType.directory]: directoryAsset => (
<div
className="flex text-left items-center align-middle whitespace-nowrap"
onClick={event => {
@ -407,7 +369,7 @@ function Dashboard(props: DashboardProps) {
{svg.DIRECTORY_ICON} <span className="px-2">{directoryAsset.title}</span>
</div>
),
[cloudService.AssetType.secret]: secret => (
[backend.AssetType.secret]: secret => (
<div
className="flex text-left items-center align-middle whitespace-nowrap"
onClick={event => {
@ -427,7 +389,7 @@ function Dashboard(props: DashboardProps) {
{svg.SECRET_ICON} <span className="px-2">{secret.title}</span>
</div>
),
[cloudService.AssetType.file]: file => (
[backend.AssetType.file]: file => (
<div
className="flex text-left items-center align-middle whitespace-nowrap"
onClick={event => {
@ -453,7 +415,7 @@ function Dashboard(props: DashboardProps) {
/** React components for every column except for the name column. */
const columnRenderer: Record<
Exclude<Column, Column.name>,
(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<Type extends cloudService.AssetType>(column: Column, assetType: Type) {
function renderer<Type extends backend.AssetType>(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<Type>) => JSX.Element)
(nameRenderers[assetType] as (asset: backend.Asset<Type>) => 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 ? (
<div className="inline-flex">
{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<projectManagerModule.ProjectName>(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) {
>
<div>
<TopBar
platform={platform}
projectName={project?.name ?? null}
tab={tab}
toggleTab={() => {
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}
/>
<Templates
backendPlatform={backendPlatform}
onTemplateClick={handleCreateProject}
/>
<Templates onTemplateClick={handleCreateProject} />
<div className="flex flex-row flex-nowrap">
<h1 className="text-xl font-bold mx-4 self-center">Drive</h1>
<div className="flex flex-row flex-nowrap mx-4">
@ -723,16 +703,12 @@ function Dashboard(props: DashboardProps) {
</div>
<div className="bg-gray-100 rounded-full flex flex-row flex-nowrap px-1.5 py-1 mx-4">
<button
className={`mx-1 ${
backendPlatform === platformModule.Platform.desktop
? 'opacity-50'
: ''
}`}
className="mx-1"
onClick={event => {
event.stopPropagation()
setModal(() => (
<UploadFileModal
backend={asCloudBackend(backend)}
backend={backendService}
directoryId={directoryId}
onSuccess={doRefresh}
/>
@ -809,81 +785,12 @@ function Dashboard(props: DashboardProps) {
</>
)}
</div>
<div className="bg-gray-100 rounded-full flex flex-row flex-nowrap p-1 mx-4">
<button
className="mx-1"
onClick={() => {
/* TODO */
}}
>
{svg.UPLOAD_ICON}
</button>
<button
className={`mx-1 ${selectedAssets.length === 0 ? 'opacity-50' : ''}`}
disabled={selectedAssets.length === 0}
onClick={() => {
/* TODO */
}}
>
{svg.DOWNLOAD_ICON}
</button>
</div>
<div className="bg-gray-100 rounded-full flex flex-row flex-nowrap p-1.5">
<button
className={`${
columnDisplayMode === ColumnDisplayMode.all
? 'bg-white shadow-soft'
: 'opacity-50'
} rounded-full px-1.5`}
onClick={() => {
setColumnDisplayMode(ColumnDisplayMode.all)
}}
>
All
</button>
<button
className={`${
columnDisplayMode === ColumnDisplayMode.compact
? 'bg-white shadow-soft'
: 'opacity-50'
} rounded-full px-1.5`}
onClick={() => {
setColumnDisplayMode(ColumnDisplayMode.compact)
}}
>
Compact
</button>
<button
className={`${
columnDisplayMode === ColumnDisplayMode.docs
? 'bg-white shadow-soft'
: 'opacity-50'
} rounded-full px-1.5`}
onClick={() => {
setColumnDisplayMode(ColumnDisplayMode.docs)
}}
>
Docs
</button>
<button
className={`${
columnDisplayMode === ColumnDisplayMode.settings
? 'bg-white shadow-soft'
: 'opacity-50'
} rounded-full px-1.5`}
onClick={() => {
setColumnDisplayMode(ColumnDisplayMode.settings)
}}
>
Settings
</button>
</div>
</div>
</div>
<table className="items-center w-full bg-transparent border-collapse m-2">
<table className="items-center w-full bg-transparent border-collapse">
<tbody>
<tr className="h-10" />
<Rows<cloudService.Asset<cloudService.AssetType.project>>
<Rows<backend.Asset<backend.AssetType.project>>
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 && (
<>
<tr className="h-10" />
<Rows<cloudService.Asset<cloudService.AssetType.directory>>
<Rows<backend.Asset<backend.AssetType.directory>>
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) {
}}
/>
<tr className="h-10" />
<Rows<cloudService.Asset<cloudService.AssetType.secret>>
<Rows<backend.Asset<backend.AssetType.secret>>
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) {
}}
/>
<tr className="h-10" />
<Rows<cloudService.Asset<cloudService.AssetType.file>>
<Rows<backend.Asset<backend.AssetType.file>>
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) {
<ConfirmDeleteModal
name={file.title}
assetType={file.type}
doDelete={() =>
asCloudBackend(backend).deleteFile(file.id)
}
doDelete={() => backendService.deleteFile(file.id)}
onSuccess={doRefresh}
/>
))
@ -1099,7 +1001,7 @@ function Dashboard(props: DashboardProps) {
)}
</tbody>
</table>
{isFileBeingDragged && backendPlatform === platformModule.Platform.cloud ? (
{isFileBeingDragged ? (
<div
className="text-white text-lg fixed w-screen h-screen inset-0 bg-primary grid place-items-center"
onDragLeave={() => {
@ -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) {
</div>
) : null}
{/* This should be just `{modal}`, however TypeScript incorrectly throws an error. */}
{project && <Ide backendPlatform={backendPlatform} project={project} />}
{project && <Ide backendService={backendService} project={project} />}
{modal && <>{modal}</>}
</div>
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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, string> = {
[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>, () => void] => {
let resolve!: () => void
const promise = new Promise<void>(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])

View File

@ -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, string> = {
[SpinnerState.initial]: 'dasharray-5 ease-linear',
@ -29,68 +31,86 @@ const SPINNER_CSS_CLASSES: Record<SpinnerState, string> = {
[SpinnerState.done]: 'dasharray-100 duration-1000 ease-in',
} as const
/** Displayed when a project is ready to stop. */
function StopIcon(spinnerState: SpinnerState) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m9 8L15 8a1 1 0 0 1 1 1L16 15a1 1 0 0 1 -1 1L9 16a1 1 0 0 1 -1 -1L8 9a1 1 0 0 1 1 -1"
fill="currentColor"
/>
<rect
x={1.5}
y={1.5}
width={21}
height={21}
rx={10.5}
stroke="currentColor"
strokeOpacity={0.1}
strokeWidth={3}
/>
<rect
x={1.5}
y={1.5}
width={21}
height={21}
rx={10.5}
stroke="currentColor"
strokeLinecap="round"
strokeWidth={3}
className={`animate-spin-ease origin-center transition-stroke-dasharray ${SPINNER_CSS_CLASSES[spinnerState]}`}
/>
</svg>
)
}
// =================
// === Component ===
// =================
export interface ProjectActionButtonProps {
project: cloudService.Asset<cloudService.AssetType.project>
project: backend.Asset<backend.AssetType.project>
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<number | null>(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 <button onClick={openProject}>{svg.PLAY_ICON}</button>
case cloudService.ProjectState.openInProgress:
return (
<button onClick={closeProject}>
<svg.StopIcon className={SPINNER_CSS_CLASSES[spinnerState]} />
</button>
)
case cloudService.ProjectState.opened:
case backend.ProjectState.openInProgress:
return <button onClick={closeProject}>{StopIcon(spinnerState)}</button>
case backend.ProjectState.opened:
return (
<>
<button onClick={closeProject}>
<svg.StopIcon className={SPINNER_CSS_CLASSES[spinnerState]} />
</button>
<button onClick={closeProject}>{StopIcon(spinnerState)}</button>
<button onClick={openIde}>{svg.ARROW_UP_ICON}</button>
</>
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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, Template[]> = {
[platformModule.Platform.cloud]: CLOUD_TEMPLATES,
[platformModule.Platform.desktop]: DESKTOP_TEMPLATES,
}
// =======================
// === TemplatesRender ===
// =======================
@ -117,12 +95,7 @@ function TemplatesRender(props: TemplatesRenderProps) {
onTemplateClick(template.id)
}}
>
<div
style={{
background: template.background,
}}
className="flex flex-col justify-end h-full w-full rounded-2xl overflow-hidden text-white text-left"
>
<div className="flex flex-col justify-end h-full w-full rounded-2xl overflow-hidden text-white text-left bg-cover bg-gray-500">
<div className="bg-black bg-opacity-30 px-4 py-2">
<h2 className="text-sm font-bold">{template.title}</h2>
<div className="text-xs h-16 text-ellipsis py-2">
@ -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 (
<div className="bg-white">
<div className="mx-auto py-2 px-4 sm:py-4 sm:px-6 lg:px-8">
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<TemplatesRender
templates={TEMPLATES[backendPlatform]}
onTemplateClick={onTemplateClick}
/>
<TemplatesRender templates={TEMPLATES} onTemplateClick={onTemplateClick} />
</div>
</div>
</div>

View File

@ -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 (
<div className="flex m-2 h-8">
<div className="flex text-primary">
{platform === platformModule.Platform.desktop && (
<div className="bg-gray-100 rounded-full flex flex-row flex-nowrap p-1.5">
<button
onClick={() => {
setBackendPlatform(platformModule.Platform.desktop)
}}
className={`${
backendPlatform === platformModule.Platform.desktop
? 'bg-white shadow-soft'
: 'opacity-50'
} rounded-full px-1.5 py-1`}
>
{svg.COMPUTER_ICON}
</button>
<button
onClick={() => {
setBackendPlatform(platformModule.Platform.cloud)
}}
className={`${
backendPlatform === platformModule.Platform.cloud
? 'bg-white shadow-soft'
: 'opacity-50'
} rounded-full px-1.5 py-1`}
>
{svg.CLOUD_ICON}
</button>
</div>
)}
<div
className={`flex items-center bg-label rounded-full pl-1
pr-2.5 mx-2 ${projectName ? 'cursor-pointer' : 'opacity-50'}`}

View File

@ -2,7 +2,7 @@
import * as react from 'react'
import toast from 'react-hot-toast'
import * as cloudService from '../cloudService'
import * as backendModule from '../service'
import * as fileInfo from '../../fileInfo'
import * as modalProvider from '../../providers/modal'
import * as svg from '../../components/svg'
@ -10,8 +10,8 @@ import * as svg from '../../components/svg'
import Modal from './modal'
export interface UploadFileModalProps {
backend: cloudService.Backend
directoryId: cloudService.DirectoryId
backend: backendModule.Backend
directoryId: backendModule.DirectoryId
onSuccess: () => void
}

View File

@ -35,7 +35,6 @@ function UserMenuItem(props: react.PropsWithChildren<UserMenuItemProps>) {
function UserMenu() {
const { signOut } = auth.useAuth()
const { accessToken, organization } = auth.useFullUserSession()
const { setModal } = modalProvider.useSetModal()
const goToProfile = () => {

View File

@ -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<cloudService.Address>(`ws://${ipWithSocket.host}:${ipWithSocket.port}`)
}
// ===============
// === Backend ===
// ===============
interface CurrentlyOpenProjectInfo {
id: projectManager.ProjectId
project: projectManager.OpenProject
}
export class Backend implements Partial<cloudService.Backend> {
private readonly projectManager = projectManager.ProjectManager.default
private currentlyOpenProject: CurrentlyOpenProjectInfo | null = null
async listDirectory(): Promise<cloudService.Asset[]> {
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<cloudService.ListedProject[]> {
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<cloudService.CreatedProject> {
const project = await this.projectManager.createProject({
name: newtype.asNewtype<projectManager.ProjectName>(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<void> {
await this.projectManager.closeProject({ projectId })
this.currentlyOpenProject = null
}
async getProjectDetails(projectId: cloudService.ProjectId): Promise<cloudService.Project> {
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<cloudService.Project>({
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<cloudService.Project>({
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<void> {
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()
}

View File

@ -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<T> extends JSONRPCBaseResponse {
result: T
}
interface JSONRPCErrorResponse extends JSONRPCBaseResponse {
error: JSONRPCError
}
type JSONRPCResponse<T> = JSONRPCErrorResponse | JSONRPCSuccessResponse<T>
// This intentionally has the same brand as in the cloud backend API.
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
export type ProjectName = newtype.Newtype<string, 'ProjectName'>
export type UTCDateTime = newtype.Newtype<string, 'UTCDateTime'>
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<number, (value: never) => void>()
protected rejecters = new Map<number, (reason?: JSONRPCError) => void>()
protected socketPromise: Promise<WebSocket>
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<WebSocket>((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<never> = 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<OpenProject> {
return this.sendRequest<OpenProject>('project/open', params)
}
/** Close an open project. */
public async closeProject(params: CloseProjectParams): Promise<void> {
return this.sendRequest('project/close', params)
}
/** Get the projects list, sorted by open time. */
public async listProjects(params: ListProjectsParams): Promise<ProjectList> {
return this.sendRequest<ProjectList>('project/list', params)
}
/** Create a new project. */
public async createProject(params: CreateProjectParams): Promise<CreateProject> {
return this.sendRequest<CreateProject>('project/create', {
missingComponentAction: MissingComponentAction.install,
...params,
})
}
/** Rename a project. */
public async renameProject(params: RenameProjectParams): Promise<void> {
return this.sendRequest('project/rename', params)
}
/** Delete a project. */
public async deleteProject(params: DeleteProjectParams): Promise<void> {
return this.sendRequest('project/delete', params)
}
/** Get the list of sample projects that are available to the user. */
public async listSamples(params: ListSamplesParams): Promise<ProjectList> {
return this.sendRequest<ProjectList>('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<T = void>(method: string, params: unknown): Promise<T> {
const socket = await this.socketPromise
const id = this.id++
socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
return new Promise<T>((resolve, reject) => {
this.resolvers.set(id, value => {
this.cleanup(id)
resolve(value)
})
this.rejecters.set(id, value => {
this.cleanup(id)
reject(value)
})
})
}
}

View File

@ -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<Address>(`${project.address}json`)
: null,
binaryAddress:
project.address != null
? newtype.asNewtype<Address>(`${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<Project> {
const response = await this.get<ProjectRaw>(getProjectDetailsPath(projectId))
const response = await this.get<Project>(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<Address>(`${project.address}json`)
: null,
binaryAddress:
project.address != null
? newtype.asNewtype<Address>(`${project.address}binary`)
: null,
}
return await response.json()
}
}

View File

@ -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(<App {...props} />)
}
}

View File

@ -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<BackendContextType>(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<object> {
initialBackend: cloudService.Backend | localService.Backend
}
export function BackendProvider(props: BackendProviderProps) {
const { initialBackend, children } = props
const [backend, setBackend] = react.useState<cloudService.Backend | localService.Backend>(
initialBackend
)
return (
<BackendContext.Provider value={{ backend, setBackend }}>
{children}
</BackendContext.Provider>
)
}
export function useBackend() {
const { backend } = react.useContext(BackendContext)
return { backend }
}
export function useSetBackend() {
const { setBackend } = react.useContext(BackendContext)
return { setBackend }
}

View File

@ -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

View File

@ -1,17 +1,6 @@
/** @file A service worker that redirects paths without extensions to `/index.html`. */
/// <reference lib="WebWorker" />
// =================
// === 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
}

View File

@ -8,6 +8,10 @@ interface StringConfig {
[key: string]: StringConfig | string
}
interface Enso {
main: (inputConfig?: StringConfig) => Promise<void>
}
interface BuildInfo {
commit: string
version: string
@ -40,8 +44,7 @@ interface AuthenticationApi {
declare global {
interface Window {
tryStopProject: () => void
runProject: (inputConfig?: StringConfig) => Promise<void>
enso: Enso
authenticationApi: AuthenticationApi
}