mirror of
https://github.com/enso-org/enso.git
synced 2025-01-09 01:56:52 +03:00
parent
a00efb28f3
commit
d0e1dd582e
@ -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 */
|
||||
|
@ -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 }) => ({
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
39
app/ide-desktop/lib/content/src/newtype.ts
Normal file
39
app/ide-desktop/lib/content/src/newtype.ts
Normal 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
|
||||
}
|
166
app/ide-desktop/lib/content/src/project_manager.ts
Normal file
166
app/ide-desktop/lib/content/src/project_manager.ts
Normal 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)
|
||||
}
|
||||
}
|
4
app/ide-desktop/lib/content/src/run.js
Normal file
4
app/ide-desktop/lib/content/src/run.js
Normal 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()
|
@ -73,6 +73,11 @@
|
||||
|
||||
/* End of fonts */
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overscroll-behavior: none;
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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 ===
|
||||
// ===========
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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'}`}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 = () => {
|
||||
|
@ -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()
|
||||
}
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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} />)
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
7
app/ide-desktop/lib/types/globals.d.ts
vendored
7
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -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
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user