mirror of
https://github.com/enso-org/enso.git
synced 2024-11-10 12:48:25 +03:00
Cloud/desktop mode switcher (#6448)
This is a re-creation of #6308. Creates buttons to switch between cloud and local backends for listing directories, opening projects etc. # Important Notes The desktop backend currently uses a hardcoded list of templates, mostly because they look better because they have background images. However, it can easily be changed to use `listSamples` endpoint and switched to the default grey background.
This commit is contained in:
parent
42cc42c878
commit
a1d48e7d0c
@ -11,6 +11,10 @@ windowAppScopeConfigName: "config"
|
||||
# utilities and allowing for runtime theme modification.
|
||||
windowAppScopeThemeName: "theme"
|
||||
|
||||
# The URL to the JSON-RPC endpoint to the Project Manager.
|
||||
# This MUST be kept in sync with the corresponding value in `app/gui/src/constants.rs`.
|
||||
projectManagerEndpoint: "ws://127.0.0.1:30535"
|
||||
|
||||
# TODO [ao] add description here.
|
||||
minimumSupportedVersion": "2.0.0-alpha.6"
|
||||
|
||||
|
@ -198,6 +198,10 @@ const RESTRICTED_SYNTAXES = [
|
||||
'TSAsExpression:has(TSUnknownKeyword, TSNeverKeyword, TSAnyKeyword) > TSAsExpression',
|
||||
message: 'Use type assertions to specific types instead of `unknown`, `any` or `never`',
|
||||
},
|
||||
{
|
||||
selector: 'IfStatement > ExpressionStatement',
|
||||
message: 'Wrap `if` branches in `{}`',
|
||||
},
|
||||
]
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
@ -108,13 +108,9 @@ export function bundlerOptions(args: Arguments) {
|
||||
outbase: 'src',
|
||||
plugins: [
|
||||
{
|
||||
// This is a workaround that is needed
|
||||
// because esbuild panics when using `loader: { '.js': 'copy' }`.
|
||||
// See https://github.com/evanw/esbuild/issues/3041.
|
||||
// Setting `loader: 'copy'` prevents this file from being converted to ESM
|
||||
// because of the `"type": "module"` in the `package.json`.
|
||||
// This file MUST be in CommonJS format because it is loaded using `Function()`
|
||||
// in `ensogl/pack/js/src/runner/index.ts`
|
||||
// in `ensogl/pack/js/src/runner/index.ts`.
|
||||
// All other files are ESM because of `"type": "module"` in `package.json`.
|
||||
name: 'pkg-js-is-cjs',
|
||||
setup: build => {
|
||||
build.onLoad({ filter: /[/\\]pkg.js$/ }, async ({ path }) => ({
|
||||
|
@ -33,9 +33,9 @@
|
||||
user-scalable = no"
|
||||
/>
|
||||
<title>Enso</title>
|
||||
<link rel="stylesheet" href="/tailwind.css" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<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>
|
||||
|
@ -8,7 +8,6 @@ import * as authentication from 'enso-authentication'
|
||||
import * as contentConfig from 'enso-content-config'
|
||||
|
||||
import * as app from '../../../../../target/ensogl-pack/linked-dist/index'
|
||||
import * as projectManager from './project_manager'
|
||||
import GLOBAL_CONFIG from '../../../../gui/config.yaml' assert { type: 'yaml' }
|
||||
|
||||
const logger = app.log.logger
|
||||
@ -119,15 +118,26 @@ function displayDeprecatedVersionDialog() {
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === Main Entry Point ===
|
||||
// === Main entry point ===
|
||||
// ========================
|
||||
|
||||
interface StringConfig {
|
||||
[key: string]: StringConfig | string
|
||||
}
|
||||
|
||||
class Main {
|
||||
async main(inputConfig: StringConfig) {
|
||||
class Main implements AppRunner {
|
||||
app: app.App | null = null
|
||||
|
||||
stopApp() {
|
||||
this.app?.stop()
|
||||
}
|
||||
|
||||
async runApp(inputConfig?: StringConfig) {
|
||||
this.stopApp()
|
||||
|
||||
/** FIXME: https://github.com/enso-org/enso/issues/6475
|
||||
* Default values names are out of sync with values used in code.
|
||||
* Rather than setting fixed values here we need to fix default values in config. */
|
||||
const config = Object.assign(
|
||||
{
|
||||
loader: {
|
||||
@ -139,7 +149,7 @@ class Main {
|
||||
inputConfig
|
||||
)
|
||||
|
||||
const appInstance = new app.App({
|
||||
this.app = new app.App({
|
||||
config,
|
||||
configOptions: contentConfig.OPTIONS,
|
||||
packageInfo: {
|
||||
@ -148,75 +158,84 @@ class Main {
|
||||
},
|
||||
})
|
||||
|
||||
if (appInstance.initialized) {
|
||||
if (!this.app.initialized) {
|
||||
console.error('Failed to initialize the application.')
|
||||
} else {
|
||||
if (contentConfig.OPTIONS.options.dataCollection.value) {
|
||||
// TODO: Add remote-logging here.
|
||||
}
|
||||
if (!(await checkMinSupportedVersion(contentConfig.OPTIONS))) {
|
||||
displayDeprecatedVersionDialog()
|
||||
} else {
|
||||
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}'.`)
|
||||
}
|
||||
void this.app.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main(inputConfig?: StringConfig) {
|
||||
contentConfig.OPTIONS.loadAll([app.urlParams()])
|
||||
const isUsingAuthentication = contentConfig.OPTIONS.options.authentication.value
|
||||
const isUsingNewDashboard =
|
||||
contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value
|
||||
const isOpeningMainEntryPoint =
|
||||
contentConfig.OPTIONS.groups.startup.options.entry.value ===
|
||||
contentConfig.OPTIONS.groups.startup.options.entry.default
|
||||
if ((isUsingAuthentication || isUsingNewDashboard) && isOpeningMainEntryPoint) {
|
||||
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. */
|
||||
const currentPlatform = contentConfig.OPTIONS.groups.startup.options.platform.value
|
||||
let platform = authentication.Platform.desktop
|
||||
if (currentPlatform === 'web') {
|
||||
platform = authentication.Platform.cloud
|
||||
}
|
||||
/** 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 this.runApp(inputConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
authentication.run({
|
||||
appRunner: this,
|
||||
logger,
|
||||
platform,
|
||||
showDashboard:
|
||||
contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value,
|
||||
onAuthenticated,
|
||||
})
|
||||
} else {
|
||||
console.error('Failed to initialize the application.')
|
||||
void this.runApp(inputConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
window[GLOBAL_CONFIG.windowAppScopeName] = new Main()
|
||||
|
@ -1,39 +0,0 @@
|
||||
/** @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
|
||||
}
|
@ -1,166 +0,0 @@
|
||||
/** @file This module defines the Project Manager endpoint. */
|
||||
import * as newtype from './newtype'
|
||||
|
||||
const PROJECT_MANAGER_ENDPOINT = 'ws://127.0.0.1:30535'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
export enum MissingComponentAction {
|
||||
fail = 'Fail',
|
||||
install = 'Install',
|
||||
forceInstallBroken = 'ForceInstallBroken',
|
||||
}
|
||||
|
||||
interface Result<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)
|
||||
}
|
||||
}
|
@ -32,6 +32,8 @@ async function bundle() {
|
||||
path.resolve(THIS_PATH, 'src', 'index.html'),
|
||||
path.resolve(THIS_PATH, 'src', 'index.tsx')
|
||||
)
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
opts.loader = { '.html': 'copy' }
|
||||
await esbuild.build(opts)
|
||||
return
|
||||
} catch (error) {
|
||||
|
@ -15,6 +15,7 @@ import * as url from 'node:url'
|
||||
import * as esbuild from 'esbuild'
|
||||
import * as esbuildPluginNodeModules from '@esbuild-plugins/node-modules-polyfill'
|
||||
import esbuildPluginTime from 'esbuild-plugin-time'
|
||||
import esbuildPluginYaml from 'esbuild-plugin-yaml'
|
||||
|
||||
import postcss from 'postcss'
|
||||
import tailwindcss from 'tailwindcss'
|
||||
@ -112,6 +113,9 @@ export function bundlerOptions(args: Arguments) {
|
||||
plugins: [
|
||||
esbuildPluginNodeModules.NodeModulesPolyfillPlugin(),
|
||||
esbuildPluginTime(),
|
||||
// This is not strictly needed because the cloud frontend does not use the Project Manager,
|
||||
// however it is very difficult to conditionally exclude a module.
|
||||
esbuildPluginYaml.yamlPlugin({}),
|
||||
esbuildPluginGenerateTailwind(),
|
||||
],
|
||||
define: {
|
||||
|
@ -19,7 +19,7 @@ function Registration() {
|
||||
const [password, setPassword] = react.useState('')
|
||||
const [confirmPassword, setConfirmPassword] = react.useState('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const onSubmit = () => {
|
||||
/** The password & confirm password fields must match. */
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match.')
|
||||
@ -44,7 +44,7 @@ function Registration() {
|
||||
<form
|
||||
onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
await handleSubmit()
|
||||
await onSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col mb-4">
|
||||
|
@ -34,7 +34,7 @@ function ResetPassword() {
|
||||
const [newPassword, setNewPassword] = react.useState('')
|
||||
const [newPasswordConfirm, setNewPasswordConfirm] = react.useState('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const onSubmit = () => {
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
toast.error('Passwords do not match')
|
||||
return Promise.resolve()
|
||||
@ -58,7 +58,7 @@ function ResetPassword() {
|
||||
<form
|
||||
onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
await handleSubmit()
|
||||
await onSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col mb-6">
|
||||
|
@ -13,7 +13,7 @@ import SvgIcon from './svgIcon'
|
||||
|
||||
function SetUsername() {
|
||||
const { setUsername: authSetUsername } = auth.useAuth()
|
||||
const { accessToken, email } = auth.usePartialUserSession()
|
||||
const { email } = auth.usePartialUserSession()
|
||||
|
||||
const [username, setUsername] = react.useState('')
|
||||
|
||||
@ -32,7 +32,7 @@ function SetUsername() {
|
||||
<form
|
||||
onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
await authSetUsername(accessToken, username, email)
|
||||
await authSetUsername(username, email)
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col mb-6">
|
||||
|
@ -9,10 +9,14 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import * as app from '../../components/app'
|
||||
import * as authServiceModule from '../service'
|
||||
import * as backendService from '../../dashboard/service'
|
||||
import * as backendModule from '../../dashboard/backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as errorModule from '../../error'
|
||||
import * as http from '../../http'
|
||||
import * as loggerProvider from '../../providers/logger'
|
||||
import * as newtype from '../../newtype'
|
||||
import * as platform from '../../platform'
|
||||
import * as remoteBackend from '../../dashboard/remoteBackend'
|
||||
import * as sessionProvider from './session'
|
||||
|
||||
// =================
|
||||
@ -49,7 +53,7 @@ export interface FullUserSession {
|
||||
/** User's email address. */
|
||||
email: string
|
||||
/** User's organization information. */
|
||||
organization: backendService.UserOrOrganization
|
||||
organization: backendModule.UserOrOrganization
|
||||
}
|
||||
|
||||
/** Object containing the currently signed-in user's session data, if the user has not yet set their
|
||||
@ -82,7 +86,7 @@ export interface PartialUserSession {
|
||||
interface AuthContextType {
|
||||
signUp: (email: string, password: string) => Promise<boolean>
|
||||
confirmSignUp: (email: string, code: string) => Promise<boolean>
|
||||
setUsername: (accessToken: string, username: string, email: string) => Promise<boolean>
|
||||
setUsername: (username: string, email: string) => Promise<boolean>
|
||||
signInWithGoogle: () => Promise<boolean>
|
||||
signInWithGitHub: () => Promise<boolean>
|
||||
signInWithPassword: (email: string, password: string) => Promise<boolean>
|
||||
@ -138,6 +142,7 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
const { authService, children } = props
|
||||
const { cognito } = authService
|
||||
const { session } = sessionProvider.useSession()
|
||||
const { setBackend } = backendProvider.useSetBackend()
|
||||
const logger = loggerProvider.useLogger()
|
||||
const navigate = router.useNavigate()
|
||||
const onAuthenticated = react.useCallback(props.onAuthenticated, [])
|
||||
@ -156,8 +161,11 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
setUserSession(null)
|
||||
} else {
|
||||
const { accessToken, email } = session.val
|
||||
|
||||
const backend = backendService.createBackend(accessToken, logger)
|
||||
const headers = new Headers()
|
||||
headers.append('Authorization', `Bearer ${accessToken}`)
|
||||
const client = new http.Client(headers)
|
||||
const backend = new remoteBackend.RemoteBackend(client, logger)
|
||||
setBackend(backend)
|
||||
const organization = await backend.usersMe()
|
||||
let newUserSession: UserSession
|
||||
if (!organization) {
|
||||
@ -249,19 +257,23 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
return result.ok
|
||||
})
|
||||
|
||||
const setUsername = async (accessToken: string, username: string, email: string) => {
|
||||
/** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343
|
||||
* The API client is reinitialised on every request. That is an inefficient way of usage.
|
||||
* Fix it by using React context and implementing it as a singleton. */
|
||||
const backend = backendService.createBackend(accessToken, logger)
|
||||
|
||||
await backend.createUser({
|
||||
userName: username,
|
||||
userEmail: newtype.asNewtype<backendService.EmailAddress>(email),
|
||||
})
|
||||
navigate(app.DASHBOARD_PATH)
|
||||
toast.success(MESSAGES.setUsernameSuccess)
|
||||
return true
|
||||
const setUsername = async (username: string, email: string) => {
|
||||
const { backend } = backendProvider.useBackend()
|
||||
if (backend.platform === platform.Platform.desktop) {
|
||||
throw new Error('')
|
||||
} else {
|
||||
try {
|
||||
await backend.createUser({
|
||||
userName: username,
|
||||
userEmail: newtype.asNewtype<backendModule.EmailAddress>(email),
|
||||
})
|
||||
navigate(app.DASHBOARD_PATH)
|
||||
toast.success(MESSAGES.setUsernameSuccess)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const forgotPassword = async (email: string) =>
|
||||
|
@ -38,12 +38,11 @@ import * as react from 'react'
|
||||
import * as router from 'react-router-dom'
|
||||
import * as toast from 'react-hot-toast'
|
||||
|
||||
import * as projectManagerModule from 'enso-content/src/project_manager'
|
||||
|
||||
import * as authService from '../authentication/service'
|
||||
import * as platformModule from '../platform'
|
||||
|
||||
import * as authProvider from '../authentication/providers/auth'
|
||||
import * as backendProvider from '../providers/backend'
|
||||
import * as loggerProvider from '../providers/logger'
|
||||
import * as modalProvider from '../providers/modal'
|
||||
import * as sessionProvider from '../authentication/providers/session'
|
||||
@ -79,26 +78,16 @@ export const SET_USERNAME_PATH = '/set-username'
|
||||
// === App ===
|
||||
// ===========
|
||||
|
||||
interface BaseAppProps {
|
||||
/** Global configuration for the `App` component. */
|
||||
export interface AppProps {
|
||||
logger: loggerProvider.Logger
|
||||
platform: platformModule.Platform
|
||||
/** Whether the dashboard should be rendered. */
|
||||
showDashboard: boolean
|
||||
onAuthenticated: () => void
|
||||
appRunner: AppRunner | null
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
@ -171,12 +160,15 @@ function AppRouter(props: AppProps) {
|
||||
userSession={userSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
>
|
||||
<authProvider.AuthProvider
|
||||
authService={memoizedAuthService}
|
||||
onAuthenticated={onAuthenticated}
|
||||
>
|
||||
<modalProvider.ModalProvider>{routes}</modalProvider.ModalProvider>
|
||||
</authProvider.AuthProvider>
|
||||
{/* @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>
|
||||
</sessionProvider.SessionProvider>
|
||||
</loggerProvider.LoggerProvider>
|
||||
)
|
||||
|
@ -235,6 +235,71 @@ 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 ===
|
||||
// ===========
|
||||
|
@ -0,0 +1,402 @@
|
||||
/** @file Type definitions common between all backends. */
|
||||
import * as newtype from '../newtype'
|
||||
import * as platform from '../platform'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Unique identifier for a user/organization. */
|
||||
export type UserOrOrganizationId = newtype.Newtype<string, 'UserOrOrganizationId'>
|
||||
|
||||
/** Unique identifier for a directory. */
|
||||
export type DirectoryId = newtype.Newtype<string, 'DirectoryId'>
|
||||
|
||||
/** Unique identifier for a user's project. */
|
||||
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
|
||||
|
||||
/** Unique identifier for an uploaded file. */
|
||||
export type FileId = newtype.Newtype<string, 'FileId'>
|
||||
|
||||
/** Unique identifier for a secret environment variable. */
|
||||
export type SecretId = newtype.Newtype<string, 'SecretId'>
|
||||
|
||||
/** Unique identifier for a file tag or project tag. */
|
||||
export type TagId = newtype.Newtype<string, 'TagId'>
|
||||
|
||||
/** A URL. */
|
||||
export type Address = newtype.Newtype<string, 'Address'>
|
||||
|
||||
/** An email address. */
|
||||
export type EmailAddress = newtype.Newtype<string, 'EmailAddress'>
|
||||
|
||||
/** An AWS S3 file path. */
|
||||
export type S3FilePath = newtype.Newtype<string, 'S3FilePath'>
|
||||
|
||||
export type Ami = newtype.Newtype<string, 'Ami'>
|
||||
|
||||
export type Subject = newtype.Newtype<string, 'Subject'>
|
||||
|
||||
/** An RFC 3339 DateTime string. */
|
||||
export type Rfc3339DateTime = newtype.Newtype<string, 'Rfc3339DateTime'>
|
||||
|
||||
/** A user/organization in the application. These are the primary owners of a project. */
|
||||
export interface UserOrOrganization {
|
||||
id: UserOrOrganizationId
|
||||
name: string
|
||||
email: EmailAddress
|
||||
}
|
||||
|
||||
/** Possible states that a project can be in. */
|
||||
export enum ProjectState {
|
||||
created = 'Created',
|
||||
new = 'New',
|
||||
openInProgress = 'OpenInProgress',
|
||||
opened = 'Opened',
|
||||
closed = 'Closed',
|
||||
}
|
||||
|
||||
/** Wrapper around a project state value. */
|
||||
export interface ProjectStateType {
|
||||
type: ProjectState
|
||||
}
|
||||
|
||||
/** Common `Project` fields returned by all `Project`-related endpoints. */
|
||||
export interface BaseProject {
|
||||
organizationId: string
|
||||
projectId: ProjectId
|
||||
name: string
|
||||
}
|
||||
|
||||
/** A `Project` returned by `createProject`. */
|
||||
export interface CreatedProject extends BaseProject {
|
||||
state: ProjectStateType
|
||||
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
|
||||
}
|
||||
|
||||
/** A `Project` returned by `updateProject`. */
|
||||
export interface UpdatedProject extends BaseProject {
|
||||
ami: Ami | null
|
||||
ideVersion: VersionNumber | null
|
||||
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
|
||||
engineVersion: VersionNumber | null
|
||||
}
|
||||
|
||||
/** Metadata describing an uploaded file. */
|
||||
export interface File {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
file_id: FileId
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
file_name: string | null
|
||||
path: S3FilePath
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying an uploaded file. */
|
||||
export interface FileInfo {
|
||||
/* TODO: Should potentially be S3FilePath,
|
||||
* but it's just string on the backend. */
|
||||
path: string
|
||||
id: FileId
|
||||
}
|
||||
|
||||
/** A secret environment variable. */
|
||||
export interface Secret {
|
||||
id: SecretId
|
||||
value: string
|
||||
}
|
||||
|
||||
/** A secret environment variable and metadata uniquely identifying it. */
|
||||
export interface SecretAndInfo {
|
||||
id: SecretId
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying a secret environment variable. */
|
||||
export interface SecretInfo {
|
||||
name: string
|
||||
id: SecretId
|
||||
}
|
||||
|
||||
export enum TagObjectType {
|
||||
file = 'File',
|
||||
project = 'Project',
|
||||
}
|
||||
|
||||
/** A file tag or project tag. */
|
||||
export interface Tag {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
organization_id: UserOrOrganizationId
|
||||
id: TagId
|
||||
name: string
|
||||
value: string
|
||||
object_type: TagObjectType
|
||||
object_id: string
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying a file tag or project tag. */
|
||||
export interface TagInfo {
|
||||
id: TagId
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Type of application that a {@link Version} applies to.
|
||||
*
|
||||
* We keep track of both backend and IDE versions, so that we can update the two independently.
|
||||
* However the format of the version numbers is the same for both, so we can use the same type for
|
||||
* both. We just need this enum to disambiguate. */
|
||||
export enum VersionType {
|
||||
backend = 'Backend',
|
||||
ide = 'Ide',
|
||||
}
|
||||
|
||||
/** Stability of an IDE or backend version. */
|
||||
export enum VersionLifecycle {
|
||||
stable = 'Stable',
|
||||
releaseCandidate = 'ReleaseCandidate',
|
||||
nightly = 'Nightly',
|
||||
development = 'Development',
|
||||
}
|
||||
|
||||
/** Version number of an IDE or backend. */
|
||||
export interface VersionNumber {
|
||||
value: string
|
||||
lifecycle: VersionLifecycle
|
||||
}
|
||||
|
||||
/** A version describing a release of the backend or IDE. */
|
||||
export interface Version {
|
||||
number: VersionNumber
|
||||
ami: Ami | null
|
||||
created: Rfc3339DateTime
|
||||
// This does not follow our naming convention because it's defined this way in the backend,
|
||||
// so we need to match it.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
version_type: VersionType
|
||||
}
|
||||
|
||||
/** Resource usage of a VM. */
|
||||
export interface ResourceUsage {
|
||||
/** Percentage of memory used. */
|
||||
memory: number
|
||||
/** Percentage of CPU time used since boot. */
|
||||
cpu: number
|
||||
/** Percentage of disk space used. */
|
||||
storage: number
|
||||
}
|
||||
|
||||
export interface User {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
pk: Subject
|
||||
user_name: string
|
||||
user_email: EmailAddress
|
||||
organization_id: UserOrOrganizationId
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
|
||||
export enum PermissionAction {
|
||||
own = 'Own',
|
||||
execute = 'Execute',
|
||||
edit = 'Edit',
|
||||
read = 'Read',
|
||||
}
|
||||
|
||||
export interface UserPermission {
|
||||
user: User
|
||||
permission: PermissionAction
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying a directory entry.
|
||||
* These can be Projects, Files, Secrets, or other directories. */
|
||||
export interface BaseAsset {
|
||||
title: string
|
||||
id: string
|
||||
parentId: string
|
||||
permissions: UserPermission[] | null
|
||||
}
|
||||
|
||||
export enum AssetType {
|
||||
project = 'project',
|
||||
file = 'file',
|
||||
secret = 'secret',
|
||||
directory = 'directory',
|
||||
}
|
||||
|
||||
export interface IdType {
|
||||
[AssetType.project]: ProjectId
|
||||
[AssetType.file]: FileId
|
||||
[AssetType.secret]: SecretId
|
||||
[AssetType.directory]: DirectoryId
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying a directory entry.
|
||||
* These can be Projects, Files, Secrets, or other directories. */
|
||||
export interface Asset<Type extends AssetType = AssetType> extends BaseAsset {
|
||||
type: Type
|
||||
id: IdType[Type]
|
||||
}
|
||||
|
||||
/** The type returned from the "create directory" endpoint. */
|
||||
export interface Directory extends Asset<AssetType.directory> {}
|
||||
|
||||
// =================
|
||||
// === Endpoints ===
|
||||
// =================
|
||||
|
||||
/** HTTP request body for the "set username" endpoint. */
|
||||
export interface CreateUserRequestBody {
|
||||
userName: string
|
||||
userEmail: EmailAddress
|
||||
}
|
||||
|
||||
/** HTTP request body for the "create directory" endpoint. */
|
||||
export interface CreateDirectoryRequestBody {
|
||||
title: string
|
||||
parentId: DirectoryId | null
|
||||
}
|
||||
|
||||
/** HTTP request body for the "create project" endpoint. */
|
||||
export interface CreateProjectRequestBody {
|
||||
projectName: string
|
||||
projectTemplateName: string | null
|
||||
parentDirectoryId: DirectoryId | null
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request body for the "project update" endpoint.
|
||||
* Only updates of the `projectName` or `ami` are allowed.
|
||||
*/
|
||||
export interface ProjectUpdateRequestBody {
|
||||
projectName: string | null
|
||||
ami: Ami | null
|
||||
ideVersion: VersionNumber | null
|
||||
}
|
||||
|
||||
/** HTTP request body for the "open project" endpoint. */
|
||||
export interface OpenProjectRequestBody {
|
||||
forceCreate: boolean
|
||||
}
|
||||
|
||||
/** HTTP request body for the "create secret" endpoint. */
|
||||
export interface CreateSecretRequestBody {
|
||||
secretName: string
|
||||
secretValue: string
|
||||
parentDirectoryId: DirectoryId | null
|
||||
}
|
||||
|
||||
/** HTTP request body for the "create tag" endpoint. */
|
||||
export interface CreateTagRequestBody {
|
||||
name: string
|
||||
value: string
|
||||
objectType: TagObjectType
|
||||
objectId: string
|
||||
}
|
||||
|
||||
export interface ListDirectoryRequestParams {
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "upload file" endpoint. */
|
||||
export interface UploadFileRequestParams {
|
||||
fileId?: string
|
||||
fileName?: string
|
||||
parentDirectoryId?: DirectoryId
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "list tags" endpoint. */
|
||||
export interface ListTagsRequestParams {
|
||||
tagType: TagObjectType
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "list versions" endpoint. */
|
||||
export interface ListVersionsRequestParams {
|
||||
versionType: VersionType
|
||||
default: boolean
|
||||
}
|
||||
|
||||
// ===================
|
||||
// === Type guards ===
|
||||
// ===================
|
||||
|
||||
export function assetIsType<Type extends AssetType>(type: Type) {
|
||||
return (asset: Asset): asset is Asset<Type> => asset.type === type
|
||||
}
|
||||
|
||||
// ===============
|
||||
// === Backend ===
|
||||
// ===============
|
||||
|
||||
/** Interface for sending requests to a backend that manages assets and runs projects. */
|
||||
export interface Backend {
|
||||
readonly platform: platform.Platform
|
||||
|
||||
/** Set the username of the current user. */
|
||||
createUser: (body: CreateUserRequestBody) => Promise<UserOrOrganization>
|
||||
/** Return user details for the current user. */
|
||||
usersMe: () => Promise<UserOrOrganization | null>
|
||||
/** Return a list of assets in a directory. */
|
||||
listDirectory: (query: ListDirectoryRequestParams) => Promise<Asset[]>
|
||||
/** Create a directory. */
|
||||
createDirectory: (body: CreateDirectoryRequestBody) => Promise<Directory>
|
||||
/** Return a list of projects belonging to the current user. */
|
||||
listProjects: () => Promise<ListedProject[]>
|
||||
/** Create a project for the current user. */
|
||||
createProject: (body: CreateProjectRequestBody) => Promise<CreatedProject>
|
||||
/** Close the project identified by the given project ID. */
|
||||
closeProject: (projectId: ProjectId) => Promise<void>
|
||||
/** Return project details for the specified project ID. */
|
||||
getProjectDetails: (projectId: ProjectId) => Promise<Project>
|
||||
/** Set a project to an open state. */
|
||||
openProject: (projectId: ProjectId, body: OpenProjectRequestBody) => Promise<void>
|
||||
projectUpdate: (projectId: ProjectId, body: ProjectUpdateRequestBody) => Promise<UpdatedProject>
|
||||
/** Delete a project. */
|
||||
deleteProject: (projectId: ProjectId) => Promise<void>
|
||||
/** Return project memory, processor and storage usage. */
|
||||
checkResources: (projectId: ProjectId) => Promise<ResourceUsage>
|
||||
/** Return a list of files accessible by the current user. */
|
||||
listFiles: () => Promise<File[]>
|
||||
/** Upload a file. */
|
||||
uploadFile: (params: UploadFileRequestParams, body: Blob) => Promise<FileInfo>
|
||||
/** Delete a file. */
|
||||
deleteFile: (fileId: FileId) => Promise<void>
|
||||
/** Create a secret environment variable. */
|
||||
createSecret: (body: CreateSecretRequestBody) => Promise<SecretAndInfo>
|
||||
/** Return a secret environment variable. */
|
||||
getSecret: (secretId: SecretId) => Promise<Secret>
|
||||
/** Return the secret environment variables accessible by the user. */
|
||||
listSecrets: () => Promise<SecretInfo[]>
|
||||
/** Delete a secret environment variable. */
|
||||
deleteSecret: (secretId: SecretId) => Promise<void>
|
||||
/** Create a file tag or project tag. */
|
||||
createTag: (body: CreateTagRequestBody) => Promise<TagInfo>
|
||||
/** Return file tags or project tags accessible by the user. */
|
||||
listTags: (params: ListTagsRequestParams) => Promise<Tag[]>
|
||||
/** Delete a file tag or project tag. */
|
||||
deleteTag: (tagId: TagId) => Promise<void>
|
||||
/** Return a list of backend or IDE versions. */
|
||||
listVersions: (params: ListVersionsRequestParams) => Promise<[Version, ...Version[]]>
|
||||
}
|
@ -20,7 +20,8 @@ function ChangePasswordModal() {
|
||||
const [oldPassword, setOldPassword] = react.useState('')
|
||||
const [newPassword, setNewPassword] = react.useState('')
|
||||
const [confirmNewPassword, setConfirmNewPassword] = react.useState('')
|
||||
const handleSubmit = async () => {
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
toast.error('Passwords do not match.')
|
||||
} else {
|
||||
@ -46,7 +47,7 @@ function ChangePasswordModal() {
|
||||
<form
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
void handleSubmit()
|
||||
void onSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col mb-6">
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -2,62 +2,68 @@
|
||||
import * as react from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as backendModule from '../service'
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as error from '../../error'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import * as platform from '../../platform'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
|
||||
export interface DirectoryCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
backend: backendModule.Backend
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function DirectoryCreateForm(props: DirectoryCreateFormProps) {
|
||||
const { backend, directoryId, onSuccess, ...passThrough } = props
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const [name, setName] = react.useState<string | null>(null)
|
||||
|
||||
async function onSubmit(event: react.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (name == null) {
|
||||
toast.error('Please provide a directory name.')
|
||||
} else {
|
||||
unsetModal()
|
||||
await toast
|
||||
.promise(
|
||||
backend.createDirectory({
|
||||
parentId: directoryId,
|
||||
title: name,
|
||||
}),
|
||||
{
|
||||
loading: 'Creating directory...',
|
||||
success: 'Sucessfully created directory.',
|
||||
error: error.unsafeIntoErrorMessage,
|
||||
}
|
||||
)
|
||||
.then(onSuccess)
|
||||
if (backend.platform === platform.Platform.desktop) {
|
||||
return <></>
|
||||
} else {
|
||||
const onSubmit = async (event: react.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (name == null) {
|
||||
toast.error('Please provide a directory name.')
|
||||
} else {
|
||||
unsetModal()
|
||||
await toast
|
||||
.promise(
|
||||
backend.createDirectory({
|
||||
parentId: directoryId,
|
||||
title: name,
|
||||
}),
|
||||
{
|
||||
loading: 'Creating directory...',
|
||||
success: 'Sucessfully created directory.',
|
||||
error: error.unsafeIntoErrorMessage,
|
||||
}
|
||||
)
|
||||
.then(onSuccess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateForm title="New Directory" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="directory_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="directory_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
return (
|
||||
<CreateForm title="New Directory" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="directory_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="directory_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default DirectoryCreateForm
|
||||
|
@ -2,89 +2,95 @@
|
||||
import * as react from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as backendModule from '../service'
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as error from '../../error'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import * as platform from '../../platform'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
|
||||
export interface FileCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
backend: backendModule.Backend
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function FileCreateForm(props: FileCreateFormProps) {
|
||||
const { backend, directoryId, onSuccess, ...passThrough } = props
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const [name, setName] = react.useState<string | null>(null)
|
||||
const [file, setFile] = react.useState<File | null>(null)
|
||||
|
||||
async function onSubmit(event: react.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (file == null) {
|
||||
// TODO[sb]: Uploading a file may be a mistake when creating a new file.
|
||||
toast.error('Please select a file to upload.')
|
||||
} else {
|
||||
unsetModal()
|
||||
await toast
|
||||
.promise(
|
||||
backend.uploadFile(
|
||||
if (backend.platform === platform.Platform.desktop) {
|
||||
return <></>
|
||||
} else {
|
||||
const onSubmit = async (event: react.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (file == null) {
|
||||
// TODO[sb]: Uploading a file may be a mistake when creating a new file.
|
||||
toast.error('Please select a file to upload.')
|
||||
} else {
|
||||
unsetModal()
|
||||
await toast
|
||||
.promise(
|
||||
backend.uploadFile(
|
||||
{
|
||||
parentDirectoryId: directoryId,
|
||||
fileName: name ?? file.name,
|
||||
},
|
||||
file
|
||||
),
|
||||
{
|
||||
parentDirectoryId: directoryId,
|
||||
fileName: name ?? file.name,
|
||||
},
|
||||
file
|
||||
),
|
||||
{
|
||||
loading: 'Uploading file...',
|
||||
success: 'Sucessfully uploaded file.',
|
||||
error: error.unsafeIntoErrorMessage,
|
||||
}
|
||||
)
|
||||
.then(onSuccess)
|
||||
loading: 'Uploading file...',
|
||||
success: 'Sucessfully uploaded file.',
|
||||
error: error.unsafeIntoErrorMessage,
|
||||
}
|
||||
)
|
||||
.then(onSuccess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateForm title="New File" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="file_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="file_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
defaultValue={name ?? file?.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<div className="inline-block flex-1 grow m-1">File</div>
|
||||
<div className="inline-block bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1">
|
||||
<label className="bg-transparent rounded-full w-full" htmlFor="file_file">
|
||||
<div className="inline-block bg-gray-300 hover:bg-gray-400 rounded-l-full px-2 -ml-2">
|
||||
<u>𐌣</u>
|
||||
</div>
|
||||
<div className="inline-block px-2 -mr-2">
|
||||
{file?.name ?? 'No file chosen'}
|
||||
</div>
|
||||
return (
|
||||
<CreateForm title="New File" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="file_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="file_file"
|
||||
type="file"
|
||||
className="hidden"
|
||||
id="file_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setFile(event.target.files?.[0] ?? null)
|
||||
setName(event.target.value)
|
||||
}}
|
||||
defaultValue={name ?? file?.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<div className="inline-block flex-1 grow m-1">File</div>
|
||||
<div className="inline-block bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1">
|
||||
<label className="bg-transparent rounded-full w-full" htmlFor="file_file">
|
||||
<div className="inline-block bg-gray-300 hover:bg-gray-400 rounded-l-full px-2 -ml-2">
|
||||
<u>𐌣</u>
|
||||
</div>
|
||||
<div className="inline-block px-2 -mr-2">
|
||||
{file?.name ?? 'No file chosen'}
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
id="file_file"
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={event => {
|
||||
setFile(event.target.files?.[0] ?? null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default FileCreateForm
|
||||
|
@ -1,36 +1,35 @@
|
||||
/** @file Container that launches the IDE. */
|
||||
import * as react from 'react'
|
||||
|
||||
import * as service from '../service'
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as platformModule from '../../platform'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The `id` attribute of the element that the IDE will be rendered into. */
|
||||
/** The `id` attribute of the element into which the IDE will be rendered. */
|
||||
const IDE_ELEMENT_ID = 'root'
|
||||
const IDE_CDN_URL = 'https://ensocdn.s3.us-west-1.amazonaws.com/ide'
|
||||
const JS_EXTENSION: Record<platformModule.Platform, string> = {
|
||||
[platformModule.Platform.cloud]: '.js.gz',
|
||||
[platformModule.Platform.desktop]: '.js',
|
||||
} as const
|
||||
|
||||
// =================
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
interface Props {
|
||||
project: service.Project
|
||||
backendService: service.Backend
|
||||
project: backendModule.Project
|
||||
appRunner: AppRunner | null
|
||||
}
|
||||
|
||||
/** Container that launches the IDE. */
|
||||
function Ide(props: Props) {
|
||||
const { project, backendService } = props
|
||||
const [ideElement] = react.useState(() => document.querySelector(IDE_ELEMENT_ID))
|
||||
const [[loaded, resolveLoaded]] = react.useState((): [Promise<void>, () => void] => {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(innerResolve => {
|
||||
resolve = innerResolve
|
||||
})
|
||||
return [promise, resolve]
|
||||
})
|
||||
const { project, appRunner } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
||||
react.useEffect(() => {
|
||||
document.getElementById(IDE_ELEMENT_ID)?.classList.remove('hidden')
|
||||
@ -41,62 +40,84 @@ function Ide(props: Props) {
|
||||
|
||||
react.useEffect(() => {
|
||||
void (async () => {
|
||||
const ideVersion = (
|
||||
await backendService.listVersions({
|
||||
versionType: service.VersionType.ide,
|
||||
default: true,
|
||||
})
|
||||
)[0]
|
||||
const projectIdeVersion = project.ideVersion?.value ?? ideVersion.number.value
|
||||
const stylesheetLink = document.createElement('link')
|
||||
stylesheetLink.rel = 'stylesheet'
|
||||
stylesheetLink.href = `${IDE_CDN_URL}/${projectIdeVersion}/style.css`
|
||||
const indexScript = document.createElement('script')
|
||||
indexScript.src = `${IDE_CDN_URL}/${projectIdeVersion}/index.js.gz`
|
||||
indexScript.addEventListener('load', () => {
|
||||
console.log('loaded')
|
||||
resolveLoaded()
|
||||
})
|
||||
document.head.append(stylesheetLink)
|
||||
document.body.append(indexScript)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
react.useEffect(() => {
|
||||
void (async () => {
|
||||
while (ideElement?.firstChild) {
|
||||
ideElement.removeChild(ideElement.firstChild)
|
||||
const ideVersion =
|
||||
project.ideVersion?.value ??
|
||||
('listVersions' in backend
|
||||
? await backend.listVersions({
|
||||
versionType: backendModule.VersionType.ide,
|
||||
default: true,
|
||||
})
|
||||
: null)?.[0].number.value
|
||||
const engineVersion =
|
||||
project.engineVersion?.value ??
|
||||
('listVersions' in backend
|
||||
? await backend.listVersions({
|
||||
versionType: backendModule.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 (backend.platform) {
|
||||
case platformModule.Platform.cloud:
|
||||
return `${IDE_CDN_URL}/${ideVersion}/`
|
||||
case platformModule.Platform.desktop:
|
||||
return ''
|
||||
}
|
||||
})()
|
||||
const runNewProject = async () => {
|
||||
await appRunner?.runApp({
|
||||
loader: {
|
||||
assetsUrl: `${assetsRoot}dynamic-assets`,
|
||||
wasmUrl: `${assetsRoot}pkg-opt.wasm`,
|
||||
jsUrl: `${assetsRoot}pkg${JS_EXTENSION[backend.platform]}`,
|
||||
},
|
||||
engine: {
|
||||
rpcUrl: jsonAddress,
|
||||
dataUrl: binaryAddress,
|
||||
preferredVersion: engineVersion,
|
||||
},
|
||||
startup: {
|
||||
project: project.packageName,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (backend.platform === platformModule.Platform.desktop) {
|
||||
await runNewProject()
|
||||
return
|
||||
} else {
|
||||
const script = document.createElement('script')
|
||||
script.src = `${IDE_CDN_URL}/${engineVersion}/index.js.gz`
|
||||
script.onload = async () => {
|
||||
document.body.removeChild(script)
|
||||
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 runNewProject()
|
||||
// Restore original URL so that initialization works correctly on refresh.
|
||||
history.replaceState(null, '', originalUrl)
|
||||
}
|
||||
document.body.appendChild(script)
|
||||
const style = document.createElement('link')
|
||||
style.rel = 'stylesheet'
|
||||
style.href = `${IDE_CDN_URL}/${engineVersion}/style.css`
|
||||
document.body.appendChild(style)
|
||||
return () => {
|
||||
style.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
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,10 +1,9 @@
|
||||
/** @file An interactive button displaying the status of a project. */
|
||||
import * as react from 'react'
|
||||
import * as reactDom from 'react-dom'
|
||||
|
||||
import * as auth from '../../authentication/providers/auth'
|
||||
import * as backend from '../service'
|
||||
import * as loggerProvider from '../../providers/logger'
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as platform from '../../platform'
|
||||
import * as svg from '../../components/svg'
|
||||
|
||||
// =============
|
||||
@ -23,7 +22,9 @@ enum SpinnerState {
|
||||
// =================
|
||||
|
||||
/** The interval between requests checking whether the IDE is ready. */
|
||||
const STATUS_CHECK_INTERVAL = 10000
|
||||
const CHECK_STATUS_INTERVAL_MS = 5000
|
||||
/** The interval between requests checking whether the VM is ready. */
|
||||
const CHECK_RESOURCES_INTERVAL_MS = 1000
|
||||
|
||||
const SPINNER_CSS_CLASSES: Record<SpinnerState, string> = {
|
||||
[SpinnerState.initial]: 'dasharray-5 ease-linear',
|
||||
@ -31,86 +32,102 @@ 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: backend.Asset<backend.AssetType.project>
|
||||
project: backendModule.Asset<backendModule.AssetType.project>
|
||||
appRunner: AppRunner | null
|
||||
onClose: () => void
|
||||
openIde: () => void
|
||||
}
|
||||
|
||||
/** An interactive button displaying the status of a project. */
|
||||
function ProjectActionButton(props: ProjectActionButtonProps) {
|
||||
const { project, openIde } = props
|
||||
const { accessToken } = auth.useFullUserSession()
|
||||
const logger = loggerProvider.useLogger()
|
||||
const backendService = backend.createBackend(accessToken, logger)
|
||||
const { project, onClose, appRunner, openIde } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
||||
const [state, setState] = react.useState(backend.ProjectState.created)
|
||||
const [checkStatusInterval, setCheckStatusInterval] = react.useState<number | null>(null)
|
||||
const [state, setState] = react.useState(backendModule.ProjectState.created)
|
||||
const [isCheckingStatus, setIsCheckingStatus] = react.useState(false)
|
||||
const [isCheckingResources, setIsCheckingResources] = react.useState(false)
|
||||
const [spinnerState, setSpinnerState] = react.useState(SpinnerState.done)
|
||||
|
||||
react.useEffect(() => {
|
||||
if (!isCheckingStatus) {
|
||||
return
|
||||
} else {
|
||||
const checkProjectStatus = async () => {
|
||||
const response = await backend.getProjectDetails(project.id)
|
||||
if (response.state.type === backendModule.ProjectState.opened) {
|
||||
setIsCheckingStatus(false)
|
||||
setIsCheckingResources(true)
|
||||
} else {
|
||||
setState(response.state.type)
|
||||
}
|
||||
}
|
||||
const handle = window.setInterval(
|
||||
() => void checkProjectStatus(),
|
||||
CHECK_STATUS_INTERVAL_MS
|
||||
)
|
||||
return () => {
|
||||
clearInterval(handle)
|
||||
}
|
||||
}
|
||||
}, [isCheckingStatus])
|
||||
|
||||
react.useEffect(() => {
|
||||
if (!isCheckingResources) {
|
||||
return
|
||||
} else {
|
||||
const checkProjectResources = async () => {
|
||||
if (!('checkResources' in backend)) {
|
||||
setState(backendModule.ProjectState.opened)
|
||||
setIsCheckingResources(false)
|
||||
setSpinnerState(SpinnerState.done)
|
||||
} else {
|
||||
try {
|
||||
// This call will error if the VM is not ready yet.
|
||||
await backend.checkResources(project.id)
|
||||
setState(backendModule.ProjectState.opened)
|
||||
setIsCheckingResources(false)
|
||||
setSpinnerState(SpinnerState.done)
|
||||
} catch {
|
||||
// Ignored.
|
||||
}
|
||||
}
|
||||
}
|
||||
const handle = window.setInterval(
|
||||
() => void checkProjectResources(),
|
||||
CHECK_RESOURCES_INTERVAL_MS
|
||||
)
|
||||
return () => {
|
||||
clearInterval(handle)
|
||||
}
|
||||
}
|
||||
}, [isCheckingResources])
|
||||
|
||||
react.useEffect(() => {
|
||||
void (async () => {
|
||||
const projectDetails = await backendService.getProjectDetails(project.id)
|
||||
const projectDetails = await backend.getProjectDetails(project.id)
|
||||
setState(projectDetails.state.type)
|
||||
if (projectDetails.state.type === backendModule.ProjectState.openInProgress) {
|
||||
setSpinnerState(SpinnerState.initial)
|
||||
setIsCheckingStatus(true)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
function closeProject() {
|
||||
setState(backend.ProjectState.closed)
|
||||
void backendService.closeProject(project.id)
|
||||
|
||||
reactDom.unstable_batchedUpdates(() => {
|
||||
setCheckStatusInterval(null)
|
||||
if (checkStatusInterval != null) {
|
||||
clearInterval(checkStatusInterval)
|
||||
}
|
||||
})
|
||||
setState(backendModule.ProjectState.closed)
|
||||
appRunner?.stopApp()
|
||||
void backend.closeProject(project.id)
|
||||
setIsCheckingStatus(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
function openProject() {
|
||||
setState(backend.ProjectState.openInProgress)
|
||||
async function openProject() {
|
||||
setState(backendModule.ProjectState.openInProgress)
|
||||
setSpinnerState(SpinnerState.initial)
|
||||
// The `setTimeout` is required so that the completion percentage goes from
|
||||
// the `initial` fraction to the `loading` fraction,
|
||||
@ -118,41 +135,36 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
|
||||
setTimeout(() => {
|
||||
setSpinnerState(SpinnerState.loading)
|
||||
}, 0)
|
||||
|
||||
void backendService.openProject(project.id)
|
||||
|
||||
const checkProjectStatus = async () => {
|
||||
const response = await backendService.getProjectDetails(project.id)
|
||||
|
||||
setState(response.state.type)
|
||||
|
||||
if (response.state.type === backend.ProjectState.opened) {
|
||||
setCheckStatusInterval(null)
|
||||
if (checkStatusInterval != null) {
|
||||
clearInterval(checkStatusInterval)
|
||||
}
|
||||
switch (backend.platform) {
|
||||
case platform.Platform.cloud:
|
||||
await backend.openProject(project.id)
|
||||
setIsCheckingStatus(true)
|
||||
break
|
||||
case platform.Platform.desktop:
|
||||
await backend.openProject(project.id)
|
||||
setState(backendModule.ProjectState.opened)
|
||||
setSpinnerState(SpinnerState.done)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
reactDom.unstable_batchedUpdates(() => {
|
||||
setCheckStatusInterval(
|
||||
window.setInterval(() => void checkProjectStatus(), STATUS_CHECK_INTERVAL)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case backend.ProjectState.created:
|
||||
case backend.ProjectState.new:
|
||||
case backend.ProjectState.closed:
|
||||
case backendModule.ProjectState.created:
|
||||
case backendModule.ProjectState.new:
|
||||
case backendModule.ProjectState.closed:
|
||||
return <button onClick={openProject}>{svg.PLAY_ICON}</button>
|
||||
case backend.ProjectState.openInProgress:
|
||||
return <button onClick={closeProject}>{StopIcon(spinnerState)}</button>
|
||||
case backend.ProjectState.opened:
|
||||
case backendModule.ProjectState.openInProgress:
|
||||
return (
|
||||
<button onClick={closeProject}>
|
||||
<svg.StopIcon className={SPINNER_CSS_CLASSES[spinnerState]} />
|
||||
</button>
|
||||
)
|
||||
case backendModule.ProjectState.opened:
|
||||
return (
|
||||
<>
|
||||
<button onClick={closeProject}>{StopIcon(spinnerState)}</button>
|
||||
<button onClick={closeProject}>
|
||||
<svg.StopIcon className={SPINNER_CSS_CLASSES[spinnerState]} />
|
||||
</button>
|
||||
<button onClick={openIde}>{svg.ARROW_UP_ICON}</button>
|
||||
</>
|
||||
)
|
||||
|
@ -2,81 +2,87 @@
|
||||
import * as react from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as backendModule from '../service'
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as error from '../../error'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import * as platform from '../../platform'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
|
||||
export interface ProjectCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
backend: backendModule.Backend
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
// FIXME[sb]: Extract shared shape to a common component.
|
||||
function ProjectCreateForm(props: ProjectCreateFormProps) {
|
||||
const { backend, directoryId, onSuccess, ...passThrough } = props
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const [name, setName] = react.useState<string | null>(null)
|
||||
const [template, setTemplate] = react.useState<string | null>(null)
|
||||
|
||||
async function onSubmit(event: react.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (name == null) {
|
||||
toast.error('Please provide a project name.')
|
||||
} else {
|
||||
unsetModal()
|
||||
await toast
|
||||
.promise(
|
||||
backend.createProject({
|
||||
parentDirectoryId: directoryId,
|
||||
projectName: name,
|
||||
projectTemplateName: template,
|
||||
}),
|
||||
{
|
||||
loading: 'Creating project...',
|
||||
success: 'Sucessfully created project.',
|
||||
error: error.unsafeIntoErrorMessage,
|
||||
}
|
||||
)
|
||||
.then(onSuccess)
|
||||
if (backend.platform === platform.Platform.desktop) {
|
||||
return <></>
|
||||
} else {
|
||||
const onSubmit = async (event: react.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (name == null) {
|
||||
toast.error('Please provide a project name.')
|
||||
} else {
|
||||
unsetModal()
|
||||
await toast
|
||||
.promise(
|
||||
backend.createProject({
|
||||
parentDirectoryId: directoryId,
|
||||
projectName: name,
|
||||
projectTemplateName: template,
|
||||
}),
|
||||
{
|
||||
loading: 'Creating project...',
|
||||
success: 'Sucessfully created project.',
|
||||
error: error.unsafeIntoErrorMessage,
|
||||
}
|
||||
)
|
||||
.then(onSuccess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateForm title="New Project" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="project_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="project_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
{/* FIXME[sb]: Use the array of templates in a dropdown when it becomes available. */}
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="project_template_name">
|
||||
Template
|
||||
</label>
|
||||
<input
|
||||
id="project_template_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setTemplate(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
return (
|
||||
<CreateForm title="New Project" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="project_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="project_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
{/* FIXME[sb]: Use the array of templates in a dropdown when it becomes available. */}
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="project_template_name">
|
||||
Template
|
||||
</label>
|
||||
<input
|
||||
id="project_template_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setTemplate(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ProjectCreateForm
|
||||
|
@ -2,82 +2,88 @@
|
||||
import * as react from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as backendModule from '../service'
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as error from '../../error'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import * as platform from '../../platform'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
|
||||
export interface SecretCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
backend: backendModule.Backend
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function SecretCreateForm(props: SecretCreateFormProps) {
|
||||
const { backend, directoryId, onSuccess, ...passThrough } = props
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const [name, setName] = react.useState<string | null>(null)
|
||||
const [value, setValue] = react.useState<string | null>(null)
|
||||
|
||||
async function onSubmit(event: react.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!name) {
|
||||
toast.error('Please provide a secret name.')
|
||||
} else if (value == null) {
|
||||
// Secret value explicitly can be empty.
|
||||
toast.error('Please provide a secret value.')
|
||||
} else {
|
||||
unsetModal()
|
||||
await toast
|
||||
.promise(
|
||||
backend.createSecret({
|
||||
parentDirectoryId: directoryId,
|
||||
secretName: name,
|
||||
secretValue: value,
|
||||
}),
|
||||
{
|
||||
loading: 'Creating secret...',
|
||||
success: 'Sucessfully created secret.',
|
||||
error: error.unsafeIntoErrorMessage,
|
||||
}
|
||||
)
|
||||
.then(onSuccess)
|
||||
if (backend.platform === platform.Platform.desktop) {
|
||||
return <></>
|
||||
} else {
|
||||
const onSubmit = async (event: react.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!name) {
|
||||
toast.error('Please provide a secret name.')
|
||||
} else if (value == null) {
|
||||
// Secret value explicitly can be empty.
|
||||
toast.error('Please provide a secret value.')
|
||||
} else {
|
||||
unsetModal()
|
||||
await toast
|
||||
.promise(
|
||||
backend.createSecret({
|
||||
parentDirectoryId: directoryId,
|
||||
secretName: name,
|
||||
secretValue: value,
|
||||
}),
|
||||
{
|
||||
loading: 'Creating secret...',
|
||||
success: 'Sucessfully created secret.',
|
||||
error: error.unsafeIntoErrorMessage,
|
||||
}
|
||||
)
|
||||
.then(onSuccess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateForm title="New Secret" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="project_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="project_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="secret_value">
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
id="secret_value"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setValue(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
return (
|
||||
<CreateForm title="New Secret" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="project_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="project_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="secret_value">
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
id="secret_value"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setValue(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default SecretCreateForm
|
||||
|
@ -1,18 +1,8 @@
|
||||
/** @file Renders the list of templates from which a project can be created. */
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as platformModule from '../../platform'
|
||||
import * as svg from '../../components/svg'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/**
|
||||
* Dash border spacing is not supported by native CSS.
|
||||
* Therefore, use a background image to create the border.
|
||||
* It is essentially an SVG image that was generated by the website.
|
||||
* @see {@link https://kovart.github.io/dashed-border-generator}
|
||||
*/
|
||||
const BORDER = `url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%233e515f' stroke-width='4' stroke-dasharray='15%2c 15' stroke-dashoffset='0' stroke-linecap='butt'/%3e%3c/svg%3e")`
|
||||
|
||||
// =================
|
||||
// === Templates ===
|
||||
// =================
|
||||
@ -22,37 +12,70 @@ interface Template {
|
||||
title: string
|
||||
description: string
|
||||
id: string
|
||||
background: string
|
||||
}
|
||||
|
||||
/** All templates for creating projects that have contents. */
|
||||
const TEMPLATES: Template[] = [
|
||||
/** The full list of templates available to cloud projects. */
|
||||
const CLOUD_TEMPLATES: Template[] = [
|
||||
{
|
||||
title: 'Colorado COVID',
|
||||
id: 'Colorado_COVID',
|
||||
description: 'Learn to glue multiple spreadsheets to analyses all your data at once.',
|
||||
background: '#6b7280',
|
||||
},
|
||||
{
|
||||
title: 'KMeans',
|
||||
id: 'Kmeans',
|
||||
description: 'Learn where to open a coffee shop to maximize your income.',
|
||||
background: '#6b7280',
|
||||
},
|
||||
{
|
||||
title: 'NASDAQ Returns',
|
||||
id: 'NASDAQ_Returns',
|
||||
description: 'Learn how to clean your data to prepare it for advanced analysis.',
|
||||
background: '#6b7280',
|
||||
},
|
||||
{
|
||||
title: 'Restaurants',
|
||||
id: 'Orders',
|
||||
description: 'Learn how to clean your data to prepare it for advanced analysis.',
|
||||
background: '#6b7280',
|
||||
},
|
||||
{
|
||||
title: 'Github Stars',
|
||||
id: 'Stargazers',
|
||||
description: 'Learn how to clean your data to prepare it for advanced analysis.',
|
||||
background: '#6b7280',
|
||||
},
|
||||
]
|
||||
|
||||
/** The full list of templates available to local projects. */
|
||||
const DESKTOP_TEMPLATES: Template[] = [
|
||||
{
|
||||
title: 'Combine spreadsheets',
|
||||
id: 'Orders',
|
||||
description: 'Glue multiple spreadsheets together to analyse all your data at once.',
|
||||
background: 'url("/spreadsheets.png") 50% 20% / 80% no-repeat, #479366',
|
||||
},
|
||||
{
|
||||
title: 'Geospatial analysis',
|
||||
id: 'Restaurants',
|
||||
description: 'Learn where to open a coffee shop to maximize your income.',
|
||||
background: 'url("/geo.png") center / cover',
|
||||
},
|
||||
{
|
||||
title: 'Analyze GitHub stars',
|
||||
id: 'Stargazers',
|
||||
description: "Find out which of Enso's repositories are most popular over time.",
|
||||
background: 'url("/visualize.png") center / cover',
|
||||
},
|
||||
]
|
||||
|
||||
const TEMPLATES: Record<platformModule.Platform, Template[]> = {
|
||||
[platformModule.Platform.cloud]: CLOUD_TEMPLATES,
|
||||
[platformModule.Platform.desktop]: DESKTOP_TEMPLATES,
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === TemplatesRender ===
|
||||
// =======================
|
||||
@ -95,7 +118,12 @@ function TemplatesRender(props: TemplatesRenderProps) {
|
||||
onTemplateClick(template.id)
|
||||
}}
|
||||
>
|
||||
<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
|
||||
style={{
|
||||
background: template.background,
|
||||
}}
|
||||
className="flex flex-col justify-end h-full w-full rounded-2xl overflow-hidden text-white text-left"
|
||||
>
|
||||
<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">
|
||||
@ -115,16 +143,21 @@ function TemplatesRender(props: TemplatesRenderProps) {
|
||||
|
||||
/** The `TemplatesRender`'s container. */
|
||||
interface TemplatesProps {
|
||||
onTemplateClick: (name: string | null) => void
|
||||
onTemplateClick: (name?: string | null) => void
|
||||
}
|
||||
|
||||
function Templates(props: TemplatesProps) {
|
||||
const { onTemplateClick } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<div className="bg-white my-2">
|
||||
<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} onTemplateClick={onTemplateClick} />
|
||||
<TemplatesRender
|
||||
templates={TEMPLATES[backend.platform]}
|
||||
onTemplateClick={onTemplateClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,8 +1,11 @@
|
||||
/** @file The top-bar of dashboard. */
|
||||
import * as dashboard from './dashboard'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import * as platformModule from '../../platform'
|
||||
import * as svg from '../../components/svg'
|
||||
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
|
||||
import UserMenu from './userMenu'
|
||||
|
||||
// ==============
|
||||
@ -10,9 +13,11 @@ import UserMenu from './userMenu'
|
||||
// ==============
|
||||
|
||||
interface TopBarProps {
|
||||
platform: platformModule.Platform
|
||||
projectName: string | null
|
||||
tab: dashboard.Tab
|
||||
toggleTab: () => void
|
||||
setBackendPlatform: (backendPlatform: platformModule.Platform) => void
|
||||
query: string
|
||||
setQuery: (value: string) => void
|
||||
}
|
||||
@ -22,12 +27,41 @@ interface TopBarProps {
|
||||
* because `searchVal` may change parent component's project list.
|
||||
*/
|
||||
function TopBar(props: TopBarProps) {
|
||||
const { projectName, tab, toggleTab, query, setQuery } = props
|
||||
const { platform, projectName, tab, toggleTab, setBackendPlatform, query, setQuery } = props
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
||||
return (
|
||||
<div className="flex m-2 h-8">
|
||||
<div className="flex mb-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={`${
|
||||
backend.platform === 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={`${
|
||||
backend.platform === 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,116 +2,130 @@
|
||||
import * as react from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as backendModule from '../service'
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as fileInfo from '../../fileInfo'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import * as platform from '../../platform'
|
||||
import * as svg from '../../components/svg'
|
||||
|
||||
import Modal from './modal'
|
||||
|
||||
export interface UploadFileModalProps {
|
||||
backend: backendModule.Backend
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function UploadFileModal(props: UploadFileModalProps) {
|
||||
const { backend, directoryId, onSuccess } = props
|
||||
const { directoryId, onSuccess } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const [name, setName] = react.useState<string | null>(null)
|
||||
const [file, setFile] = react.useState<File | null>(null)
|
||||
|
||||
async function onSubmit() {
|
||||
if (file == null) {
|
||||
toast.error('Please select a file to upload.')
|
||||
} else {
|
||||
unsetModal()
|
||||
const toastId = toast.loading('Uploading file...')
|
||||
await backend.uploadFile(
|
||||
{
|
||||
parentDirectoryId: directoryId,
|
||||
fileName: name ?? file.name,
|
||||
},
|
||||
file
|
||||
)
|
||||
toast.success('Sucessfully uploaded file.', { id: toastId })
|
||||
onSuccess()
|
||||
if (backend.platform === platform.Platform.desktop) {
|
||||
return <></>
|
||||
} else {
|
||||
const onSubmit = async () => {
|
||||
if (file == null) {
|
||||
toast.error('Please select a file to upload.')
|
||||
} else {
|
||||
unsetModal()
|
||||
const toastId = toast.loading('Uploading file...')
|
||||
await backend.uploadFile(
|
||||
{
|
||||
parentDirectoryId: directoryId,
|
||||
fileName: name ?? file.name,
|
||||
},
|
||||
file
|
||||
)
|
||||
toast.success('Sucessfully uploaded file.', { id: toastId })
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal className="bg-opacity-90">
|
||||
<form
|
||||
className="relative bg-white shadow-soft rounded-lg w-96 h-72 p-2"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<button type="button" className="absolute right-0 top-0 m-2" onClick={unsetModal}>
|
||||
{svg.CLOSE_ICON}
|
||||
</button>
|
||||
<div className="m-2">
|
||||
<label className="w-1/3" htmlFor="uploaded_file_name">
|
||||
File name
|
||||
</label>
|
||||
<input
|
||||
id="uploaded_file_name"
|
||||
type="text"
|
||||
required
|
||||
className="border-primary bg-gray-200 rounded-full w-2/3 px-2 mx-2"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
defaultValue={name ?? file?.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="m-2">
|
||||
<label
|
||||
htmlFor="uploaded_file"
|
||||
className="hover:cursor-pointer inline-block text-white bg-blue-600 rounded-full px-4 py-1"
|
||||
>
|
||||
Select file
|
||||
</label>
|
||||
</div>
|
||||
<div className="border border-primary rounded-md m-2">
|
||||
<input
|
||||
id="uploaded_file"
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={event => {
|
||||
setFile(event.target.files?.[0] ?? null)
|
||||
}}
|
||||
/>
|
||||
<div className="inline-flex flex-row flex-nowrap w-full p-2">
|
||||
<div className="grow">
|
||||
<div>{file?.name ?? 'No file selected'}</div>
|
||||
<div className="text-xs">
|
||||
{file ? fileInfo.toReadableSize(file.size) : '\u00a0'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{file ? fileInfo.fileIcon(fileInfo.fileExtension(file.name)) : <></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="m-1">
|
||||
<div
|
||||
className="hover:cursor-pointer inline-block text-white bg-blue-600 rounded-full px-4 py-1 m-1"
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Upload
|
||||
</div>
|
||||
<div
|
||||
className="hover:cursor-pointer inline-block bg-gray-200 rounded-full px-4 py-1 m-1"
|
||||
return (
|
||||
<Modal className="bg-opacity-90">
|
||||
<form
|
||||
className="relative bg-white shadow-soft rounded-lg w-96 h-72 p-2"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 m-2"
|
||||
onClick={unsetModal}
|
||||
>
|
||||
Cancel
|
||||
{svg.CLOSE_ICON}
|
||||
</button>
|
||||
<div className="m-2">
|
||||
<label className="w-1/3" htmlFor="uploaded_file_name">
|
||||
File name
|
||||
</label>
|
||||
<input
|
||||
id="uploaded_file_name"
|
||||
type="text"
|
||||
required
|
||||
className="border-primary bg-gray-200 rounded-full w-2/3 px-2 mx-2"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
defaultValue={name ?? file?.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
<div className="m-2">
|
||||
<label
|
||||
htmlFor="uploaded_file"
|
||||
className="hover:cursor-pointer inline-block text-white bg-blue-600 rounded-full px-4 py-1"
|
||||
>
|
||||
Select file
|
||||
</label>
|
||||
</div>
|
||||
<div className="border border-primary rounded-md m-2">
|
||||
<input
|
||||
id="uploaded_file"
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={event => {
|
||||
setFile(event.target.files?.[0] ?? null)
|
||||
}}
|
||||
/>
|
||||
<div className="inline-flex flex-row flex-nowrap w-full p-2">
|
||||
<div className="grow">
|
||||
<div>{file?.name ?? 'No file selected'}</div>
|
||||
<div className="text-xs">
|
||||
{file ? fileInfo.toReadableSize(file.size) : '\u00a0'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{file ? (
|
||||
fileInfo.fileIcon(fileInfo.fileExtension(file.name))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="m-1">
|
||||
<div
|
||||
className="hover:cursor-pointer inline-block text-white bg-blue-600 rounded-full px-4 py-1 m-1"
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Upload
|
||||
</div>
|
||||
<div
|
||||
className="hover:cursor-pointer inline-block bg-gray-200 rounded-full px-4 py-1 m-1"
|
||||
onClick={unsetModal}
|
||||
>
|
||||
Cancel
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default UploadFileModal
|
||||
|
@ -35,6 +35,7 @@ function UserMenuItem(props: react.PropsWithChildren<UserMenuItemProps>) {
|
||||
function UserMenu() {
|
||||
const { signOut } = auth.useAuth()
|
||||
const { accessToken, organization } = auth.useFullUserSession()
|
||||
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
|
||||
const goToProfile = () => {
|
||||
|
@ -0,0 +1,150 @@
|
||||
/** @file Module containing the API client for the local backend API.
|
||||
*
|
||||
* Each exported function in the {@link LocalBackend} in this module corresponds to an API endpoint.
|
||||
* The functions are asynchronous and return a {@link Promise} that resolves to the response from
|
||||
* the API. */
|
||||
import * as backend from './backend'
|
||||
import * as newtype from '../newtype'
|
||||
import * as platformModule from '../platform'
|
||||
import * as projectManager from './projectManager'
|
||||
|
||||
// ========================
|
||||
// === Helper functions ===
|
||||
// ========================
|
||||
|
||||
function ipWithSocketToAddress(ipWithSocket: projectManager.IpWithSocket) {
|
||||
return newtype.asNewtype<backend.Address>(`ws://${ipWithSocket.host}:${ipWithSocket.port}`)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// === LocalBackend ===
|
||||
// ====================
|
||||
|
||||
/** The currently open project and its ID. */
|
||||
interface CurrentlyOpenProjectInfo {
|
||||
id: projectManager.ProjectId
|
||||
project: projectManager.OpenProject
|
||||
}
|
||||
|
||||
/** Class for sending requests to the Project Manager API endpoints.
|
||||
* This is used instead of the cloud backend API when managing local projects from the dashboard. */
|
||||
export class LocalBackend implements Partial<backend.Backend> {
|
||||
readonly platform = platformModule.Platform.desktop
|
||||
private readonly projectManager = projectManager.ProjectManager.default()
|
||||
private currentlyOpeningProjectId: string | null = null
|
||||
private currentlyOpenProject: CurrentlyOpenProjectInfo | null = null
|
||||
|
||||
async listDirectory(): Promise<backend.Asset[]> {
|
||||
const result = await this.projectManager.listProjects({})
|
||||
return result.projects.map(project => ({
|
||||
type: backend.AssetType.project,
|
||||
title: project.name,
|
||||
id: project.id,
|
||||
parentId: '',
|
||||
permissions: [],
|
||||
}))
|
||||
}
|
||||
|
||||
async listProjects(): Promise<backend.ListedProject[]> {
|
||||
const result = await this.projectManager.listProjects({})
|
||||
return result.projects.map(project => ({
|
||||
name: project.name,
|
||||
organizationId: '',
|
||||
projectId: project.id,
|
||||
packageName: project.name,
|
||||
state: {
|
||||
type: backend.ProjectState.created,
|
||||
},
|
||||
jsonAddress: null,
|
||||
binaryAddress: null,
|
||||
}))
|
||||
}
|
||||
|
||||
async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> {
|
||||
const project = await this.projectManager.createProject({
|
||||
name: newtype.asNewtype<projectManager.ProjectName>(body.projectName),
|
||||
...(body.projectTemplateName ? { projectTemplate: body.projectTemplateName } : {}),
|
||||
missingComponentAction: projectManager.MissingComponentAction.install,
|
||||
})
|
||||
return {
|
||||
name: body.projectName,
|
||||
organizationId: '',
|
||||
projectId: project.projectId,
|
||||
packageName: body.projectName,
|
||||
state: {
|
||||
type: backend.ProjectState.created,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async closeProject(projectId: backend.ProjectId): Promise<void> {
|
||||
await this.projectManager.closeProject({ projectId })
|
||||
this.currentlyOpenProject = null
|
||||
}
|
||||
|
||||
async getProjectDetails(projectId: backend.ProjectId): Promise<backend.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<backend.Project>({
|
||||
name: project.name,
|
||||
engineVersion: {
|
||||
lifecycle: backend.VersionLifecycle.stable,
|
||||
value: engineVersion,
|
||||
},
|
||||
ideVersion: {
|
||||
lifecycle: backend.VersionLifecycle.stable,
|
||||
value: engineVersion,
|
||||
},
|
||||
jsonAddress: null,
|
||||
binaryAddress: null,
|
||||
organizationId: '',
|
||||
packageName: project.name,
|
||||
projectId,
|
||||
state: {
|
||||
type:
|
||||
projectId === this.currentlyOpeningProjectId
|
||||
? backend.ProjectState.openInProgress
|
||||
: backend.ProjectState.closed,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const project = this.currentlyOpenProject.project
|
||||
return Promise.resolve<backend.Project>({
|
||||
name: project.projectName,
|
||||
engineVersion: {
|
||||
lifecycle: backend.VersionLifecycle.stable,
|
||||
value: project.engineVersion,
|
||||
},
|
||||
ideVersion: {
|
||||
lifecycle: backend.VersionLifecycle.stable,
|
||||
value: project.engineVersion,
|
||||
},
|
||||
jsonAddress: ipWithSocketToAddress(project.languageServerJsonAddress),
|
||||
binaryAddress: ipWithSocketToAddress(project.languageServerBinaryAddress),
|
||||
organizationId: '',
|
||||
packageName: project.projectName,
|
||||
projectId,
|
||||
state: {
|
||||
type: backend.ProjectState.opened,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async openProject(projectId: backend.ProjectId): Promise<void> {
|
||||
this.currentlyOpeningProjectId = projectId
|
||||
const project = await this.projectManager.openProject({
|
||||
projectId,
|
||||
missingComponentAction: projectManager.MissingComponentAction.install,
|
||||
})
|
||||
this.currentlyOpenProject = { id: projectId, project }
|
||||
}
|
||||
}
|
@ -0,0 +1,245 @@
|
||||
/** @file This module defines the Project Manager endpoint.
|
||||
*
|
||||
* It should always be in sync with the Rust interface at
|
||||
* `app/gui/controller/engine-protocol/src/project_manager.rs`. */
|
||||
import * as newtype from '../newtype'
|
||||
|
||||
import GLOBAL_CONFIG from '../../../../../../../gui/config.yaml' assert { type: 'yaml' }
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** Duration before the {@link ProjectManager} tries to create a WebSocket again. */
|
||||
const RETRY_INTERVAL_MS = 1000
|
||||
/** Duration after which the {@link ProjectManager} stops re-trying to create a WebSocket. */
|
||||
const STOP_TRYING_AFTER_MS = 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 {@link WebSocket} endpoint to the project manager.
|
||||
*
|
||||
* It should always be in sync with the Rust interface at
|
||||
* `app/gui/controller/engine-protocol/src/project_manager.rs`. */
|
||||
export class ProjectManager {
|
||||
private static instance: ProjectManager
|
||||
protected id = 0
|
||||
protected resolvers = new Map<number, (value: never) => void>()
|
||||
protected rejecters = new Map<number, (reason?: JSONRPCError) => void>()
|
||||
protected socketPromise: Promise<WebSocket>
|
||||
|
||||
/** Create a {@link ProjectManager} */
|
||||
private 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.onopen = () => {
|
||||
resolve(socket)
|
||||
}
|
||||
socket.onerror = createSocket
|
||||
socket.onclose = createSocket
|
||||
} catch {
|
||||
// Ignored; the `setInterval` will retry again eventually.
|
||||
}
|
||||
}, RETRY_INTERVAL_MS)
|
||||
setTimeout(() => {
|
||||
clearInterval(handle)
|
||||
reject()
|
||||
}, STOP_TRYING_AFTER_MS)
|
||||
})
|
||||
return this.socketPromise
|
||||
}
|
||||
this.socketPromise = createSocket()
|
||||
}
|
||||
|
||||
/** Lazy initialization for the singleton instance. */
|
||||
static default() {
|
||||
// `this.instance` is initially undefined as an instance should only be created
|
||||
// if a `ProjectManager` is actually needed.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
return (this.instance ??= new ProjectManager(GLOBAL_CONFIG.projectManagerEndpoint))
|
||||
}
|
||||
|
||||
/** 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -1,11 +1,13 @@
|
||||
/** @file Module containing the API client for the Cloud backend API.
|
||||
*
|
||||
* Each exported function in the {@link Backend} in this module corresponds to an API endpoint. The
|
||||
* Each exported function in the {@link RemoteBackend} 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 backend from './backend'
|
||||
import * as config from '../config'
|
||||
import * as http from '../http'
|
||||
import * as loggerProvider from '../providers/logger'
|
||||
import * as newtype from '../newtype'
|
||||
import * as platformModule from '../platform'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -15,7 +17,7 @@ import * as newtype from '../newtype'
|
||||
const STATUS_OK = 200
|
||||
|
||||
/** Default HTTP body for an "open project" request. */
|
||||
const DEFAULT_OPEN_PROJECT_BODY: OpenProjectRequestBody = {
|
||||
const DEFAULT_OPEN_PROJECT_BODY: backend.OpenProjectRequestBody = {
|
||||
forceCreate: false,
|
||||
}
|
||||
|
||||
@ -46,43 +48,43 @@ const LIST_TAGS_PATH = 'tags'
|
||||
/** Relative HTTP path to the "list versions" endpoint of the Cloud backend API. */
|
||||
const LIST_VERSIONS_PATH = 'versions'
|
||||
/** Relative HTTP path to the "close project" endpoint of the Cloud backend API. */
|
||||
function closeProjectPath(projectId: ProjectId) {
|
||||
function closeProjectPath(projectId: backend.ProjectId) {
|
||||
return `projects/${projectId}/close`
|
||||
}
|
||||
/** Relative HTTP path to the "get project details" endpoint of the Cloud backend API. */
|
||||
function getProjectDetailsPath(projectId: ProjectId) {
|
||||
function getProjectDetailsPath(projectId: backend.ProjectId) {
|
||||
return `projects/${projectId}`
|
||||
}
|
||||
/** Relative HTTP path to the "open project" endpoint of the Cloud backend API. */
|
||||
function openProjectPath(projectId: ProjectId) {
|
||||
function openProjectPath(projectId: backend.ProjectId) {
|
||||
return `projects/${projectId}/open`
|
||||
}
|
||||
/** Relative HTTP path to the "project update" endpoint of the Cloud backend API. */
|
||||
function projectUpdatePath(projectId: ProjectId) {
|
||||
function projectUpdatePath(projectId: backend.ProjectId) {
|
||||
return `projects/${projectId}`
|
||||
}
|
||||
/** Relative HTTP path to the "delete project" endpoint of the Cloud backend API. */
|
||||
function deleteProjectPath(projectId: ProjectId) {
|
||||
function deleteProjectPath(projectId: backend.ProjectId) {
|
||||
return `projects/${projectId}`
|
||||
}
|
||||
/** Relative HTTP path to the "check resources" endpoint of the Cloud backend API. */
|
||||
function checkResourcesPath(projectId: ProjectId) {
|
||||
function checkResourcesPath(projectId: backend.ProjectId) {
|
||||
return `projects/${projectId}/resources`
|
||||
}
|
||||
/** Relative HTTP path to the "delete file" endpoint of the Cloud backend API. */
|
||||
function deleteFilePath(fileId: FileId) {
|
||||
function deleteFilePath(fileId: backend.FileId) {
|
||||
return `files/${fileId}`
|
||||
}
|
||||
/** Relative HTTP path to the "get project" endpoint of the Cloud backend API. */
|
||||
function getSecretPath(secretId: SecretId) {
|
||||
function getSecretPath(secretId: backend.SecretId) {
|
||||
return `secrets/${secretId}`
|
||||
}
|
||||
/** Relative HTTP path to the "delete secret" endpoint of the Cloud backend API. */
|
||||
function deleteSecretPath(secretId: SecretId) {
|
||||
function deleteSecretPath(secretId: backend.SecretId) {
|
||||
return `secrets/${secretId}`
|
||||
}
|
||||
/** Relative HTTP path to the "delete tag" endpoint of the Cloud backend API. */
|
||||
function deleteTagPath(tagId: TagId) {
|
||||
function deleteTagPath(tagId: backend.TagId) {
|
||||
return `secrets/${tagId}`
|
||||
}
|
||||
|
||||
@ -90,371 +92,45 @@ function deleteTagPath(tagId: TagId) {
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Unique identifier for a user/organization. */
|
||||
export type UserOrOrganizationId = newtype.Newtype<string, 'UserOrOrganizationId'>
|
||||
|
||||
/** Unique identifier for a directory. */
|
||||
export type DirectoryId = newtype.Newtype<string, 'DirectoryId'>
|
||||
|
||||
/** Unique identifier for a user's project. */
|
||||
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
|
||||
|
||||
/** Unique identifier for an uploaded file. */
|
||||
export type FileId = newtype.Newtype<string, 'FileId'>
|
||||
|
||||
/** Unique identifier for a secret environment variable. */
|
||||
export type SecretId = newtype.Newtype<string, 'SecretId'>
|
||||
|
||||
/** Unique identifier for a file tag or project tag. */
|
||||
export type TagId = newtype.Newtype<string, 'TagId'>
|
||||
|
||||
/** A URL. */
|
||||
export type Address = newtype.Newtype<string, 'Address'>
|
||||
|
||||
/** An email address. */
|
||||
export type EmailAddress = newtype.Newtype<string, 'EmailAddress'>
|
||||
|
||||
/** An AWS S3 file path. */
|
||||
export type S3FilePath = newtype.Newtype<string, 'S3FilePath'>
|
||||
|
||||
export type Ami = newtype.Newtype<string, 'Ami'>
|
||||
|
||||
export type Subject = newtype.Newtype<string, 'Subject'>
|
||||
|
||||
/** An RFC 3339 DateTime string. */
|
||||
export type Rfc3339DateTime = newtype.Newtype<string, 'Rfc3339DateTime'>
|
||||
|
||||
/** A user/organization in the application. These are the primary owners of a project. */
|
||||
export interface UserOrOrganization {
|
||||
id: UserOrOrganizationId
|
||||
name: string
|
||||
email: EmailAddress
|
||||
}
|
||||
|
||||
/** Possible states that a project can be in. */
|
||||
export enum ProjectState {
|
||||
created = 'Created',
|
||||
new = 'New',
|
||||
openInProgress = 'OpenInProgress',
|
||||
opened = 'Opened',
|
||||
closed = 'Closed',
|
||||
}
|
||||
|
||||
/** Wrapper around a project state value. */
|
||||
export interface ProjectStateType {
|
||||
type: ProjectState
|
||||
}
|
||||
|
||||
/** Common `Project` fields returned by all `Project`-related endpoints. */
|
||||
export interface BaseProject {
|
||||
organizationId: string
|
||||
projectId: ProjectId
|
||||
name: string
|
||||
}
|
||||
|
||||
/** A `Project` returned by `createProject`. */
|
||||
export interface CreatedProject extends BaseProject {
|
||||
state: ProjectStateType
|
||||
packageName: string
|
||||
}
|
||||
|
||||
/** A `Project` returned by `listProjects`. */
|
||||
export interface ListedProject extends CreatedProject {
|
||||
address: Address | null
|
||||
}
|
||||
|
||||
/** A `Project` returned by `updateProject`. */
|
||||
export interface UpdatedProject extends BaseProject {
|
||||
ami: Ami | null
|
||||
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
|
||||
engineVersion: VersionNumber | null
|
||||
}
|
||||
|
||||
/** Metadata describing an uploaded file. */
|
||||
export interface File {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
file_id: FileId
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
file_name: string | null
|
||||
path: S3FilePath
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying an uploaded file. */
|
||||
export interface FileInfo {
|
||||
/* TODO: Should potentially be S3FilePath,
|
||||
* but it's just string on the backend. */
|
||||
path: string
|
||||
id: FileId
|
||||
}
|
||||
|
||||
/** A secret environment variable. */
|
||||
export interface Secret {
|
||||
id: SecretId
|
||||
value: string
|
||||
}
|
||||
|
||||
/** A secret environment variable and metadata uniquely identifying it. */
|
||||
export interface SecretAndInfo {
|
||||
id: SecretId
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying a secret environment variable. */
|
||||
export interface SecretInfo {
|
||||
name: string
|
||||
id: SecretId
|
||||
}
|
||||
|
||||
export enum TagObjectType {
|
||||
file = 'File',
|
||||
project = 'Project',
|
||||
}
|
||||
|
||||
/** A file tag or project tag. */
|
||||
export interface Tag {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
organization_id: UserOrOrganizationId
|
||||
id: TagId
|
||||
name: string
|
||||
value: string
|
||||
object_type: TagObjectType
|
||||
object_id: string
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying a file tag or project tag. */
|
||||
export interface TagInfo {
|
||||
id: TagId
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Type of application that a {@link Version} applies to.
|
||||
*
|
||||
* We keep track of both backend and IDE versions, so that we can update the two independently.
|
||||
* However the format of the version numbers is the same for both, so we can use the same type for
|
||||
* both. We just need this enum to disambiguate. */
|
||||
export enum VersionType {
|
||||
backend = 'Backend',
|
||||
ide = 'Ide',
|
||||
}
|
||||
|
||||
/** Stability of an IDE or backend version. */
|
||||
export enum VersionLifecycle {
|
||||
stable = 'Stable',
|
||||
releaseCandidate = 'ReleaseCandidate',
|
||||
nightly = 'Nightly',
|
||||
development = 'Development',
|
||||
}
|
||||
|
||||
/** Version number of an IDE or backend. */
|
||||
export interface VersionNumber {
|
||||
value: string
|
||||
lifecycle: VersionLifecycle
|
||||
}
|
||||
|
||||
/** A version describing a release of the backend or IDE. */
|
||||
export interface Version {
|
||||
number: VersionNumber
|
||||
ami: Ami | null
|
||||
created: Rfc3339DateTime
|
||||
// This does not follow our naming convention because it's defined this way in the backend,
|
||||
// so we need to match it.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
version_type: VersionType
|
||||
}
|
||||
|
||||
/** Resource usage of a VM. */
|
||||
export interface ResourceUsage {
|
||||
/** Percentage of memory used. */
|
||||
memory: number
|
||||
/** Percentage of CPU time used since boot. */
|
||||
cpu: number
|
||||
/** Percentage of disk space used. */
|
||||
storage: number
|
||||
}
|
||||
|
||||
export interface User {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
pk: Subject
|
||||
user_name: string
|
||||
user_email: EmailAddress
|
||||
organization_id: UserOrOrganizationId
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
|
||||
export enum PermissionAction {
|
||||
own = 'Own',
|
||||
execute = 'Execute',
|
||||
edit = 'Edit',
|
||||
read = 'Read',
|
||||
}
|
||||
|
||||
export interface UserPermission {
|
||||
user: User
|
||||
permission: PermissionAction
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying a directory entry.
|
||||
* These can be Projects, Files, Secrets, or other directories. */
|
||||
interface BaseAsset {
|
||||
title: string
|
||||
id: string
|
||||
parentId: string
|
||||
permissions: UserPermission[] | null
|
||||
}
|
||||
|
||||
export enum AssetType {
|
||||
project = 'project',
|
||||
file = 'file',
|
||||
secret = 'secret',
|
||||
directory = 'directory',
|
||||
}
|
||||
|
||||
export interface IdType {
|
||||
[AssetType.project]: ProjectId
|
||||
[AssetType.file]: FileId
|
||||
[AssetType.secret]: SecretId
|
||||
[AssetType.directory]: DirectoryId
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying a directory entry.
|
||||
* These can be Projects, Files, Secrets, or other directories. */
|
||||
export interface Asset<Type extends AssetType = AssetType> extends BaseAsset {
|
||||
type: Type
|
||||
id: IdType[Type]
|
||||
}
|
||||
|
||||
/** The type returned from the "create directory" endpoint. */
|
||||
export interface Directory extends Asset<AssetType.directory> {}
|
||||
|
||||
// =================
|
||||
// === Endpoints ===
|
||||
// =================
|
||||
|
||||
/** HTTP request body for the "set username" endpoint. */
|
||||
export interface CreateUserRequestBody {
|
||||
userName: string
|
||||
userEmail: EmailAddress
|
||||
}
|
||||
|
||||
/** HTTP request body for the "create directory" endpoint. */
|
||||
export interface CreateDirectoryRequestBody {
|
||||
title: string
|
||||
parentId: DirectoryId | null
|
||||
}
|
||||
|
||||
/** HTTP request body for the "create project" endpoint. */
|
||||
export interface CreateProjectRequestBody {
|
||||
projectName: string
|
||||
projectTemplateName: string | null
|
||||
parentDirectoryId: DirectoryId | null
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request body for the "project update" endpoint.
|
||||
* Only updates of the `projectName` or `ami` are allowed.
|
||||
*/
|
||||
export interface ProjectUpdateRequestBody {
|
||||
projectName: string | null
|
||||
ami: Ami | null
|
||||
ideVersion: VersionNumber | null
|
||||
}
|
||||
|
||||
/** HTTP request body for the "open project" endpoint. */
|
||||
export interface OpenProjectRequestBody {
|
||||
forceCreate: boolean
|
||||
}
|
||||
|
||||
/** HTTP request body for the "create secret" endpoint. */
|
||||
export interface CreateSecretRequestBody {
|
||||
secretName: string
|
||||
secretValue: string
|
||||
parentDirectoryId: DirectoryId | null
|
||||
}
|
||||
|
||||
/** HTTP request body for the "create tag" endpoint. */
|
||||
export interface CreateTagRequestBody {
|
||||
name: string
|
||||
value: string
|
||||
objectType: TagObjectType
|
||||
objectId: string
|
||||
}
|
||||
|
||||
export interface ListDirectoryRequestParams {
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "upload file" endpoint. */
|
||||
export interface UploadFileRequestParams {
|
||||
fileId?: string
|
||||
fileName?: string
|
||||
parentDirectoryId?: DirectoryId
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "list tags" endpoint. */
|
||||
export interface ListTagsRequestParams {
|
||||
tagType: TagObjectType
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "list versions" endpoint. */
|
||||
export interface ListVersionsRequestParams {
|
||||
versionType: VersionType
|
||||
default: boolean
|
||||
}
|
||||
|
||||
/** HTTP response body for the "list projects" endpoint. */
|
||||
interface ListDirectoryResponseBody {
|
||||
assets: BaseAsset[]
|
||||
assets: backend.BaseAsset[]
|
||||
}
|
||||
|
||||
/** HTTP response body for the "list projects" endpoint. */
|
||||
interface ListProjectsResponseBody {
|
||||
projects: ListedProject[]
|
||||
projects: backend.ListedProjectRaw[]
|
||||
}
|
||||
|
||||
/** HTTP response body for the "list files" endpoint. */
|
||||
interface ListFilesResponseBody {
|
||||
files: File[]
|
||||
files: backend.File[]
|
||||
}
|
||||
|
||||
/** HTTP response body for the "list secrets" endpoint. */
|
||||
interface ListSecretsResponseBody {
|
||||
secrets: SecretInfo[]
|
||||
secrets: backend.SecretInfo[]
|
||||
}
|
||||
|
||||
/** HTTP response body for the "list tag" endpoint. */
|
||||
interface ListTagsResponseBody {
|
||||
tags: Tag[]
|
||||
tags: backend.Tag[]
|
||||
}
|
||||
|
||||
/** HTTP response body for the "list versions" endpoint. */
|
||||
interface ListVersionsResponseBody {
|
||||
versions: [Version, ...Version[]]
|
||||
versions: [backend.Version, ...backend.Version[]]
|
||||
}
|
||||
|
||||
// ===================
|
||||
// === Type guards ===
|
||||
// ===================
|
||||
|
||||
export function assetIsType<Type extends AssetType>(type: Type) {
|
||||
return (asset: Asset): asset is Asset<Type> => asset.type === type
|
||||
}
|
||||
|
||||
// ===============
|
||||
// === Backend ===
|
||||
// ===============
|
||||
// =====================
|
||||
// === RemoteBackend ===
|
||||
// =====================
|
||||
|
||||
/** Class for sending requests to the Cloud backend API endpoints. */
|
||||
export class Backend {
|
||||
/** Creates a new instance of the {@link Backend} API client.
|
||||
export class RemoteBackend implements backend.Backend {
|
||||
readonly platform = platformModule.Platform.cloud
|
||||
|
||||
/** Creates a new instance of the {@link RemoteBackend} API client.
|
||||
*
|
||||
* @throws An error if the `Authorization` header is not set on the given `client`. */
|
||||
constructor(
|
||||
@ -476,16 +152,16 @@ export class Backend {
|
||||
}
|
||||
|
||||
/** Sets the username of the current user, on the Cloud backend API. */
|
||||
async createUser(body: CreateUserRequestBody): Promise<UserOrOrganization> {
|
||||
const response = await this.post<UserOrOrganization>(CREATE_USER_PATH, body)
|
||||
async createUser(body: backend.CreateUserRequestBody): Promise<backend.UserOrOrganization> {
|
||||
const response = await this.post<backend.UserOrOrganization>(CREATE_USER_PATH, body)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/** Returns organization info for the current user, from the Cloud backend API.
|
||||
*
|
||||
* @returns `null` if status code 401 or 404 was received. */
|
||||
async usersMe(): Promise<UserOrOrganization | null> {
|
||||
const response = await this.get<UserOrOrganization>(USERS_ME_PATH)
|
||||
async usersMe(): Promise<backend.UserOrOrganization | null> {
|
||||
const response = await this.get<backend.UserOrOrganization>(USERS_ME_PATH)
|
||||
if (response.status !== STATUS_OK) {
|
||||
return null
|
||||
} else {
|
||||
@ -497,7 +173,7 @@ export class Backend {
|
||||
*
|
||||
* @throws An error if status code 401 or 404 was received.
|
||||
*/
|
||||
async listDirectory(query: ListDirectoryRequestParams): Promise<Asset[]> {
|
||||
async listDirectory(query: backend.ListDirectoryRequestParams): Promise<backend.Asset[]> {
|
||||
const response = await this.get<ListDirectoryResponseBody>(
|
||||
LIST_DIRECTORY_PATH +
|
||||
'?' +
|
||||
@ -516,7 +192,7 @@ export class Backend {
|
||||
return (await response.json()).assets.map(
|
||||
// This type assertion is safe; it is only needed to convert `type` to a newtype.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
asset => ({ ...asset, type: asset.id.match(/^(.+?)-/)?.[1] } as Asset)
|
||||
asset => ({ ...asset, type: asset.id.match(/^(.+?)-/)?.[1] } as backend.Asset)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -524,8 +200,8 @@ export class Backend {
|
||||
/** Creates a directory, on the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async createDirectory(body: CreateDirectoryRequestBody): Promise<Directory> {
|
||||
const response = await this.post<Directory>(CREATE_DIRECTORY_PATH, body)
|
||||
async createDirectory(body: backend.CreateDirectoryRequestBody): Promise<backend.Directory> {
|
||||
const response = await this.post<backend.Directory>(CREATE_DIRECTORY_PATH, body)
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to create directory with name '${body.title}'.`)
|
||||
} else {
|
||||
@ -537,20 +213,30 @@ export class Backend {
|
||||
*
|
||||
* @throws An error if status code 401 or 404 was received.
|
||||
*/
|
||||
async listProjects(): Promise<ListedProject[]> {
|
||||
async listProjects(): Promise<backend.ListedProject[]> {
|
||||
const response = await this.get<ListProjectsResponseBody>(LIST_PROJECTS_PATH)
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw('Unable to list projects.')
|
||||
} else {
|
||||
return (await response.json()).projects
|
||||
return (await response.json()).projects.map(project => ({
|
||||
...project,
|
||||
jsonAddress:
|
||||
project.address != null
|
||||
? newtype.asNewtype<backend.Address>(`${project.address}json`)
|
||||
: null,
|
||||
binaryAddress:
|
||||
project.address != null
|
||||
? newtype.asNewtype<backend.Address>(`${project.address}binary`)
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a project for the current user, on the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async createProject(body: CreateProjectRequestBody): Promise<CreatedProject> {
|
||||
const response = await this.post<CreatedProject>(CREATE_PROJECT_PATH, body)
|
||||
async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> {
|
||||
const response = await this.post<backend.CreatedProject>(CREATE_PROJECT_PATH, body)
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to create project with name '${body.projectName}'.`)
|
||||
} else {
|
||||
@ -561,7 +247,7 @@ export class Backend {
|
||||
/** Closes the project identified by the given project ID, on the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async closeProject(projectId: ProjectId): Promise<void> {
|
||||
async closeProject(projectId: backend.ProjectId): Promise<void> {
|
||||
const response = await this.post(closeProjectPath(projectId), {})
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to close project with ID '${projectId}'.`)
|
||||
@ -573,12 +259,23 @@ export class Backend {
|
||||
/** Returns project details for the specified project ID, from the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async getProjectDetails(projectId: ProjectId): Promise<Project> {
|
||||
const response = await this.get<Project>(getProjectDetailsPath(projectId))
|
||||
async getProjectDetails(projectId: backend.ProjectId): Promise<backend.Project> {
|
||||
const response = await this.get<backend.ProjectRaw>(getProjectDetailsPath(projectId))
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to get details of project with ID '${projectId}'.`)
|
||||
} else {
|
||||
return await response.json()
|
||||
const project = await response.json()
|
||||
return {
|
||||
...project,
|
||||
jsonAddress:
|
||||
project.address != null
|
||||
? newtype.asNewtype<backend.Address>(`${project.address}json`)
|
||||
: null,
|
||||
binaryAddress:
|
||||
project.address != null
|
||||
? newtype.asNewtype<backend.Address>(`${project.address}binary`)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -586,8 +283,8 @@ export class Backend {
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async openProject(
|
||||
projectId: ProjectId,
|
||||
body: OpenProjectRequestBody = DEFAULT_OPEN_PROJECT_BODY
|
||||
projectId: backend.ProjectId,
|
||||
body: backend.OpenProjectRequestBody = DEFAULT_OPEN_PROJECT_BODY
|
||||
): Promise<void> {
|
||||
const response = await this.post(openProjectPath(projectId), body)
|
||||
if (response.status !== STATUS_OK) {
|
||||
@ -598,10 +295,10 @@ export class Backend {
|
||||
}
|
||||
|
||||
async projectUpdate(
|
||||
projectId: ProjectId,
|
||||
body: ProjectUpdateRequestBody
|
||||
): Promise<UpdatedProject> {
|
||||
const response = await this.put<UpdatedProject>(projectUpdatePath(projectId), body)
|
||||
projectId: backend.ProjectId,
|
||||
body: backend.ProjectUpdateRequestBody
|
||||
): Promise<backend.UpdatedProject> {
|
||||
const response = await this.put<backend.UpdatedProject>(projectUpdatePath(projectId), body)
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to update project with ID '${projectId}'.`)
|
||||
} else {
|
||||
@ -612,7 +309,7 @@ export class Backend {
|
||||
/** Deletes project, on the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async deleteProject(projectId: ProjectId): Promise<void> {
|
||||
async deleteProject(projectId: backend.ProjectId): Promise<void> {
|
||||
const response = await this.delete(deleteProjectPath(projectId))
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to delete project with ID '${projectId}'.`)
|
||||
@ -624,8 +321,8 @@ export class Backend {
|
||||
/** Returns project memory, processor and storage usage, from the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async checkResources(projectId: ProjectId): Promise<ResourceUsage> {
|
||||
const response = await this.get<ResourceUsage>(checkResourcesPath(projectId))
|
||||
async checkResources(projectId: backend.ProjectId): Promise<backend.ResourceUsage> {
|
||||
const response = await this.get<backend.ResourceUsage>(checkResourcesPath(projectId))
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to get resource usage for project with ID '${projectId}'.`)
|
||||
} else {
|
||||
@ -636,7 +333,7 @@ export class Backend {
|
||||
/** Returns a list of files accessible by the current user, from the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async listFiles(): Promise<File[]> {
|
||||
async listFiles(): Promise<backend.File[]> {
|
||||
const response = await this.get<ListFilesResponseBody>(LIST_FILES_PATH)
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw('Unable to list files.')
|
||||
@ -648,8 +345,11 @@ export class Backend {
|
||||
/** Uploads a file, to the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async uploadFile(params: UploadFileRequestParams, body: Blob): Promise<FileInfo> {
|
||||
const response = await this.postBase64<FileInfo>(
|
||||
async uploadFile(
|
||||
params: backend.UploadFileRequestParams,
|
||||
body: Blob
|
||||
): Promise<backend.FileInfo> {
|
||||
const response = await this.postBase64<backend.FileInfo>(
|
||||
UPLOAD_FILE_PATH +
|
||||
'?' +
|
||||
new URLSearchParams({
|
||||
@ -679,7 +379,7 @@ export class Backend {
|
||||
/** Deletes a file, on the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async deleteFile(fileId: FileId): Promise<void> {
|
||||
async deleteFile(fileId: backend.FileId): Promise<void> {
|
||||
const response = await this.delete(deleteFilePath(fileId))
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to delete file with ID '${fileId}'.`)
|
||||
@ -691,8 +391,8 @@ export class Backend {
|
||||
/** Creates a secret environment variable, on the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async createSecret(body: CreateSecretRequestBody): Promise<SecretAndInfo> {
|
||||
const response = await this.post<SecretAndInfo>(CREATE_SECRET_PATH, body)
|
||||
async createSecret(body: backend.CreateSecretRequestBody): Promise<backend.SecretAndInfo> {
|
||||
const response = await this.post<backend.SecretAndInfo>(CREATE_SECRET_PATH, body)
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to create secret with name '${body.secretName}'.`)
|
||||
} else {
|
||||
@ -703,8 +403,8 @@ export class Backend {
|
||||
/** Returns a secret environment variable, from the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async getSecret(secretId: SecretId): Promise<Secret> {
|
||||
const response = await this.get<Secret>(getSecretPath(secretId))
|
||||
async getSecret(secretId: backend.SecretId): Promise<backend.Secret> {
|
||||
const response = await this.get<backend.Secret>(getSecretPath(secretId))
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to get secret with ID '${secretId}'.`)
|
||||
} else {
|
||||
@ -715,7 +415,7 @@ export class Backend {
|
||||
/** Returns the secret environment variables accessible by the user, from the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async listSecrets(): Promise<SecretInfo[]> {
|
||||
async listSecrets(): Promise<backend.SecretInfo[]> {
|
||||
const response = await this.get<ListSecretsResponseBody>(LIST_SECRETS_PATH)
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw('Unable to list secrets.')
|
||||
@ -727,7 +427,7 @@ export class Backend {
|
||||
/** Deletes a secret environment variable, on the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async deleteSecret(secretId: SecretId): Promise<void> {
|
||||
async deleteSecret(secretId: backend.SecretId): Promise<void> {
|
||||
const response = await this.delete(deleteSecretPath(secretId))
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to delete secret with ID '${secretId}'.`)
|
||||
@ -739,8 +439,8 @@ export class Backend {
|
||||
/** Creates a file tag or project tag, on the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async createTag(body: CreateTagRequestBody): Promise<TagInfo> {
|
||||
const response = await this.post<TagInfo>(CREATE_TAG_PATH, {
|
||||
async createTag(body: backend.CreateTagRequestBody): Promise<backend.TagInfo> {
|
||||
const response = await this.post<backend.TagInfo>(CREATE_TAG_PATH, {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
tag_name: body.name,
|
||||
tag_value: body.value,
|
||||
@ -758,7 +458,7 @@ export class Backend {
|
||||
/** Returns file tags or project tags accessible by the user, from the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async listTags(params: ListTagsRequestParams): Promise<Tag[]> {
|
||||
async listTags(params: backend.ListTagsRequestParams): Promise<backend.Tag[]> {
|
||||
const response = await this.get<ListTagsResponseBody>(
|
||||
LIST_TAGS_PATH +
|
||||
'?' +
|
||||
@ -777,7 +477,7 @@ export class Backend {
|
||||
/** Deletes a secret environment variable, on the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async deleteTag(tagId: TagId): Promise<void> {
|
||||
async deleteTag(tagId: backend.TagId): Promise<void> {
|
||||
const response = await this.delete(deleteTagPath(tagId))
|
||||
if (response.status !== STATUS_OK) {
|
||||
return this.throw(`Unable to delete tag with ID '${tagId}'.`)
|
||||
@ -789,7 +489,9 @@ export class Backend {
|
||||
/** Returns list of backend or IDE versions, from the Cloud backend API.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
async listVersions(params: ListVersionsRequestParams): Promise<[Version, ...Version[]]> {
|
||||
async listVersions(
|
||||
params: backend.ListVersionsRequestParams
|
||||
): Promise<[backend.Version, ...backend.Version[]]> {
|
||||
const response = await this.get<ListVersionsResponseBody>(
|
||||
LIST_VERSIONS_PATH +
|
||||
'?' +
|
||||
@ -831,20 +533,3 @@ export class Backend {
|
||||
return this.client.delete<T>(`${config.ACTIVE_CONFIG.apiUrl}/${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === createBackend ===
|
||||
// =====================
|
||||
|
||||
/** Shorthand method for creating a new instance of the backend API, along with the necessary
|
||||
* headers. */
|
||||
/* TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343
|
||||
* This is a hack to quickly create the backend in the format we want, until we get the provider
|
||||
* working. This should be removed entirely in favour of creating the backend once and using it from
|
||||
* the context. */
|
||||
export function createBackend(accessToken: string, logger: loggerProvider.Logger): Backend {
|
||||
const headers = new Headers()
|
||||
headers.append('Authorization', `Bearer ${accessToken}`)
|
||||
const client = new http.Client(headers)
|
||||
return new Backend(client, logger)
|
||||
}
|
@ -40,17 +40,13 @@ export function run(props: app.AppProps) {
|
||||
logger.log('Starting authentication/dashboard UI.')
|
||||
/** The root element that the authentication/dashboard app will be rendered into. */
|
||||
const root = document.getElementById(ROOT_ELEMENT_ID)
|
||||
const ideElement = document.getElementById(IDE_ELEMENT_ID)
|
||||
if (root == null) {
|
||||
logger.error(`Could not find root element with ID '${ROOT_ELEMENT_ID}'.`)
|
||||
} else if (ideElement == null) {
|
||||
logger.error(`Could not find IDE element with ID '${IDE_ELEMENT_ID}'.`)
|
||||
} else {
|
||||
// FIXME: https://github.com/enso-org/cloud-v2/issues/386
|
||||
// Temporary workaround on hiding the Enso root element preventing it from
|
||||
// rendering next to authentication templates. We are uncovering this once the
|
||||
// authentication library sets the user session.
|
||||
const ide = document.getElementById(IDE_ELEMENT_ID)
|
||||
if (ide != null) {
|
||||
ide.style.display = 'none'
|
||||
}
|
||||
ideElement.hidden = true
|
||||
reactDOM.createRoot(root).render(<App {...props} />)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,59 @@
|
||||
/** @file Defines the React provider for the project manager `Backend`, along with hooks to use the
|
||||
* provider via the shared React context. */
|
||||
import * as react from 'react'
|
||||
|
||||
import * as localBackend from '../dashboard/localBackend'
|
||||
import * as remoteBackend from '../dashboard/remoteBackend'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** A type representing a backend API that may be of any type. */
|
||||
type AnyBackendAPI = localBackend.LocalBackend | remoteBackend.RemoteBackend
|
||||
|
||||
// ======================
|
||||
// === BackendContext ===
|
||||
// ======================
|
||||
|
||||
export interface BackendContextType {
|
||||
backend: AnyBackendAPI
|
||||
setBackend: (backend: AnyBackendAPI) => 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)
|
||||
|
||||
export interface BackendProviderProps extends React.PropsWithChildren<object> {
|
||||
initialBackend: AnyBackendAPI
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === BackendProvider ===
|
||||
// =======================
|
||||
|
||||
/** A React Provider that lets components get and set the current backend. */
|
||||
export function BackendProvider(props: BackendProviderProps) {
|
||||
const { initialBackend, children } = props
|
||||
const [backend, setBackend] = react.useState<
|
||||
localBackend.LocalBackend | remoteBackend.RemoteBackend
|
||||
>(initialBackend)
|
||||
return (
|
||||
<BackendContext.Provider value={{ backend, setBackend }}>
|
||||
{children}
|
||||
</BackendContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Exposes a property to get the current backend. */
|
||||
export function useBackend() {
|
||||
const { backend } = react.useContext(BackendContext)
|
||||
return { backend }
|
||||
}
|
||||
|
||||
/** Exposes a property to set the current backend. */
|
||||
export function useSetBackend() {
|
||||
const { setBackend } = react.useContext(BackendContext)
|
||||
return { setBackend }
|
||||
}
|
@ -3,10 +3,11 @@
|
||||
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as backend from './dashboard/service'
|
||||
import * as backend from './dashboard/backend'
|
||||
import * as remoteBackend from './dashboard/remoteBackend'
|
||||
|
||||
export async function uploadMultipleFiles(
|
||||
backendService: backend.Backend,
|
||||
backendService: remoteBackend.RemoteBackend,
|
||||
directoryId: backend.DirectoryId,
|
||||
files: File[]
|
||||
) {
|
||||
|
@ -39,4 +39,5 @@ authentication.run({
|
||||
// The `onAuthenticated` parameter is required but we don't need it, so we pass an empty function.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onAuthenticated() {},
|
||||
appRunner: null,
|
||||
})
|
||||
|
@ -1,6 +1,11 @@
|
||||
/** @file A service worker that redirects paths without extensions to `/index.html`. */
|
||||
/** @file A service worker that redirects paths without extensions to `/index.html`.
|
||||
* This is only used in the cloud frontend. */
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
// =====================
|
||||
// === Fetch handler ===
|
||||
// =====================
|
||||
|
||||
// We `declare` a variable here because Service Workers have a different global scope.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare const self: ServiceWorkerGlobalScope
|
||||
|
14
app/ide-desktop/lib/types/modules.d.ts
vendored
14
app/ide-desktop/lib/types/modules.d.ts
vendored
@ -2,8 +2,18 @@
|
||||
*
|
||||
* This file MUST NOT `export {}` for the modules to be visible to other files. */
|
||||
|
||||
declare module '*.yaml' {
|
||||
const DATA: unknown
|
||||
declare module '*/gui/config.yaml' {
|
||||
interface Config {
|
||||
windowAppScopeName: string
|
||||
windowAppScopeConfigName: string
|
||||
windowAppScopeThemeName: string
|
||||
projectManagerEndpoint: string
|
||||
minimumSupportedVersion: string
|
||||
engineVersionSupported: string
|
||||
languageEditionSupported: string
|
||||
}
|
||||
|
||||
const DATA: Config
|
||||
export default DATA
|
||||
}
|
||||
|
||||
|
16
app/ide-desktop/lib/types/types.d.ts
vendored
Normal file
16
app/ide-desktop/lib/types/types.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
/** @file Interfaces common to multiple modules. */
|
||||
|
||||
// ======================================
|
||||
// === Globally accessible interfaces ===
|
||||
// ======================================
|
||||
|
||||
interface StringConfig {
|
||||
[key: string]: StringConfig | string
|
||||
}
|
||||
|
||||
/** The value passed from the entrypoint to the dashboard, which enables the dashboard to
|
||||
* open a new IDE instance. */
|
||||
interface AppRunner {
|
||||
stopApp: () => void
|
||||
runApp: (config?: StringConfig) => Promise<void>
|
||||
}
|
@ -22,6 +22,8 @@ export type { LogLevel } from 'runner/log/logger'
|
||||
export { logger, Logger, Consumer } from 'runner/log'
|
||||
export { Option } from 'runner/config'
|
||||
|
||||
export const urlParams: () => config.StringConfig = host.urlParams
|
||||
|
||||
// ==============================
|
||||
// === Files to be downloaded ===
|
||||
// ==============================
|
||||
@ -189,6 +191,10 @@ export class App {
|
||||
mainEntryPoints = new Map<string, wasm.EntryPoint>()
|
||||
progressIndicator: wasm.ProgressIndicator | null = null
|
||||
initialized = false
|
||||
/** Field indicating that application was stopped and any long running process
|
||||
* (like wasm compilation) should be aborted. Currently, we initialize application fully
|
||||
* before using stop, but in future we need to support interruption feature. */
|
||||
stopped = false
|
||||
|
||||
constructor(opts?: {
|
||||
configOptions?: config.Options
|
||||
@ -268,9 +274,17 @@ export class App {
|
||||
logger.log("App wasn't initialized properly. Skipping run.")
|
||||
} else {
|
||||
await this.loadAndInitWasm()
|
||||
await this.runEntryPoints()
|
||||
if (!this.stopped) {
|
||||
await this.runEntryPoints()
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Sets application stop to true and calls drop method which removes all rust memory references
|
||||
* and calls all destructors. */
|
||||
stop() {
|
||||
this.stopped = true
|
||||
this.wasm?.drop()
|
||||
}
|
||||
|
||||
/** Compiles and runs the downloaded WASM file. */
|
||||
async compileAndRunWasm(pkgJs: string, wasm: Buffer | Response): Promise<unknown> {
|
||||
@ -349,8 +363,8 @@ export class App {
|
||||
assetsResponses.map(response => response.blob().then(blob => blob.arrayBuffer()))
|
||||
)
|
||||
const assets = assetsInfo.map(info => {
|
||||
// The non-null assertion on the following line is safe since we are mapping `assetsBlobs` from
|
||||
// success assets response.
|
||||
// The non-null assertion on the following line is safe since we are mapping
|
||||
// `assetsBlobs` from success assets response.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const data = new Map(Array.from(info.data, ([k, i]) => [k, assetsBlobs[i]!]))
|
||||
return new Asset(info.type, info.key, data)
|
||||
|
Loading…
Reference in New Issue
Block a user