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:
somebody1234 2023-05-03 03:48:07 +10:00 committed by GitHub
parent 42cc42c878
commit a1d48e7d0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 2311 additions and 1650 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[]
) {

View 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,
})

View File

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

View File

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

View File

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