Upload and download .enso-projects from the local backend (PM backend) (#7305)

- Closes https://github.com/enso-org/cloud-v2/issues/478
- Download local project as `.enso-project` archive
- Requires latest nightly version of Project Manager
- Closes https://github.com/enso-org/cloud-v2/issues/510
- Allow uploading `.enso-project` to local backend
- Closes https://github.com/enso-org/cloud-v2/issues/477
- Promote local project to cloud
- Currently errors with 500 (when uploading a small bundle) or 413 (when uploading a large bundle). May be fixed soon

# Important Notes
The "upload project to cloud" context menu action does not currently have an entry in the new context menu, so that will probably need an official design at some point
This commit is contained in:
somebody1234 2023-08-09 19:30:40 +10:00 committed by GitHub
parent 59329bd59a
commit 0e20644e47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 637 additions and 165 deletions

View File

@ -15,6 +15,9 @@ windowAppScopeThemeName: "theme"
# This MUST be kept in sync with the corresponding value in `app/gui/src/constants.rs`.
projectManagerEndpoint: "ws://127.0.0.1:30535"
# The URL to the base path of the HTTP endpoints of the Project Manager.
projectManagerHttpEndpoint: "http://127.0.0.1:30535"
# TODO [ao] add description here.
minimumSupportedVersion": "2.0.0-alpha.6"

View File

@ -34,7 +34,7 @@ const DEFAULT_IMPORT_ONLY_MODULES =
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss`
const OUR_MODULES = 'enso-.*'
const RELATIVE_MODULES =
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|detect|file-associations|index|ipc|log|naming|paths|preload|security|url-associations'
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|detect|file-associations|index|ipc|log|naming|paths|preload|project-management|security|url-associations'
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
const JSX = ':matches(JSXElement, JSXFragment)'
const NOT_PASCAL_CASE = '/^(?!do[A-Z])(?!_?([A-Z][a-z0-9]*)+$)/'
@ -235,6 +235,10 @@ const RESTRICTED_SYNTAXES = [
selector: 'VariableDeclarator[id.name=ENVIRONMENT][init.value!=production]',
message: "Environment must be 'production' when committing",
},
{
selector: 'CallExpression[callee.name=toastAndLog][arguments.0.value=/\\.$/]',
message: '`toastAndLog` already includes a trailing `.`',
},
]
// ============================

View File

@ -2,6 +2,7 @@
import * as path from 'node:path'
import * as esbuild from 'esbuild'
import esbuildPluginYaml from 'esbuild-plugin-yaml'
import * as paths from './paths'
@ -41,6 +42,7 @@ export function bundlerOptions(
outbase: 'src',
format: 'cjs',
platform: 'node',
plugins: [esbuildPluginYaml.yamlPlugin({})],
// The names come from a third-party API and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
outExtension: { '.js': '.cjs' },

View File

@ -3,6 +3,7 @@
import * as fs from 'node:fs'
import * as http from 'node:http'
import * as path from 'node:path'
import * as stream from 'node:stream'
import * as mime from 'mime-types'
import * as portfinder from 'portfinder'
@ -11,8 +12,12 @@ import createServer from 'create-servers'
import * as common from 'enso-common'
import * as contentConfig from 'enso-content-config'
import * as projectManagement from 'project-management'
import * as paths from '../paths'
import GLOBAL_CONFIG from '../../../../../gui/config.yaml' assert { type: 'yaml' }
const logger = contentConfig.logger
// =================
@ -20,25 +25,34 @@ const logger = contentConfig.logger
// =================
const HTTP_STATUS_OK = 200
const HTTP_STATUS_NOT_FOUND = 404
// ==============
// === Config ===
// ==============
/** External functions for a {@link Server}. */
export interface ExternalFunctions {
uploadProjectBundle: (project: stream.Readable) => Promise<projectManagement.BundleInfo>
}
/** Constructor parameter for the server configuration. */
interface ConfigConfig {
dir: string
port: number
externalFunctions: ExternalFunctions
}
/** Server configuration. */
export class Config {
dir: string
port: number
externalFunctions: ExternalFunctions
/** Create a server configuration. */
constructor(cfg: ConfigConfig) {
this.dir = path.resolve(cfg.dir)
this.port = cfg.port
this.externalFunctions = cfg.externalFunctions
}
}
@ -100,6 +114,63 @@ export class Server {
const requestUrl = request.url
if (requestUrl == null) {
logger.error('Request URL is null.')
} else if (requestUrl.startsWith('/api/project-manager/')) {
const actualUrl = new URL(
requestUrl.replace(
/^\/api\/project-manager/,
GLOBAL_CONFIG.projectManagerHttpEndpoint
)
)
request.pipe(
http.request(
// `...actualUrl` does NOT work because `URL` properties are not enumerable.
{
headers: request.headers,
host: actualUrl.host,
hostname: actualUrl.hostname,
method: request.method,
path: actualUrl.pathname,
port: actualUrl.port,
protocol: actualUrl.protocol,
},
actualResponse => {
response.writeHead(
// This is SAFE. The documentation says:
// Only valid for response obtained from ClientRequest.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
actualResponse.statusCode!,
actualResponse.statusMessage,
actualResponse.headers
)
actualResponse.pipe(response, { end: true })
}
),
{ end: true }
)
} else if (request.method === 'POST') {
const requestPath = requestUrl.split('?')[0]?.split('#')[0]
switch (requestPath) {
// This endpoint should only be used when accessing the app from the browser.
// When accessing the app from Electron, the file input event will have the
// full system path.
case '/api/upload-project': {
void this.config.externalFunctions.uploadProjectBundle(request).then(info => {
const body = JSON.stringify(info)
response
.writeHead(HTTP_STATUS_OK, [
['Content-Length', `${body.length}`],
['Content-Type', 'application/json'],
...common.COOP_COEP_CORP_HEADERS,
])
.end(body)
})
break
}
default: {
response.writeHead(HTTP_STATUS_NOT_FOUND, common.COOP_COEP_CORP_HEADERS).end()
break
}
}
} else {
const url = requestUrl.split('?')[0]
const resource = url === '/' ? '/index.html' : requestUrl
@ -116,6 +187,8 @@ export class Server {
fs.readFile(resourceFile, (err, data) => {
if (err) {
logger.error(`Resource '${resource}' not found.`)
response.writeHead(HTTP_STATUS_NOT_FOUND)
response.end()
} else {
const contentType = mime.contentType(path.extname(resourceFile))
const contentLength = data.length

View File

@ -158,7 +158,7 @@ export function setOpenFileEventHandler(setProjectToOpen: (id: string) => void)
* @throws {Error} if the project from the file cannot be opened or imported. */
export function handleOpenFile(openedFile: string): string {
try {
return project.importProjectFromPath(openedFile)
return project.importProjectFromPath(openedFile).id
} catch (error) {
// Since the user has explicitly asked us to open a file, in case of an error, we should
// display a message box with the error details.

View File

@ -21,12 +21,12 @@ import * as config from 'config'
import * as configParser from 'config/parser'
import * as debug from 'debug'
import * as detect from 'detect'
// eslint-disable-next-line no-restricted-syntax
import * as fileAssociations from 'file-associations'
import * as ipc from 'ipc'
import * as log from 'log'
import * as naming from 'naming'
import * as paths from 'paths'
import * as projectManagement from 'project-management'
import * as projectManager from 'bin/project-manager'
import * as security from 'security'
import * as server from 'bin/server'
@ -238,6 +238,9 @@ class App {
const serverCfg = new server.Config({
dir: paths.ASSETS_PATH,
port: this.args.groups.server.options.port.value,
externalFunctions: {
uploadProjectBundle: projectManagement.uploadBundle,
},
})
this.server = await server.Server.create(serverCfg)
})
@ -345,6 +348,10 @@ class App {
electron.ipcMain.on(ipc.Channel.quit, () => {
electron.app.quit()
})
electron.ipcMain.on(ipc.Channel.importProjectFromPath, (event, path: string) => {
const info = projectManagement.importProjectFromPath(path)
event.reply(ipc.Channel.importProjectFromPath, path, info)
})
}
/** The server port. In case the server was not started, the port specified in the configuration

View File

@ -21,4 +21,6 @@ export enum Channel {
openDeepLink = 'open-deep-link',
/** Channel for signaling that access token be saved to a credentials file. */
saveAccessToken = 'save-access-token',
/** Channel for importing a project or project bundle from the given path. */
importProjectFromPath = 'import-project-from-path',
}

View File

@ -41,3 +41,5 @@ export const PROJECT_MANAGER_PATH = path.join(
/** Relative path of Enso Project PM metadata relative to project's root. */
export const PROJECT_METADATA_RELATIVE = path.join('.enso', 'project.json')
/** Relative path of Enso Project bundle metadata relative to project's root. */
export const BUNDLE_METADATA_RELATIVE = path.join('package.yaml')

View File

@ -6,15 +6,46 @@
import * as electron from 'electron'
import * as ipc from 'ipc'
import * as projectManagement from 'project-management'
// =================
// === Constants ===
// =================
/** Name given to the {@link AUTHENTICATION_API} object, when it is exposed on the Electron main
* window. */
const BACKEND_API_KEY = 'backendApi'
/** Name given to the {@link AUTHENTICATION_API} object, when it is exposed on the Electron main
* window. */
const AUTHENTICATION_API_KEY = 'authenticationApi'
// =============================
// === importProjectFromPath ===
// =============================
const IMPORT_PROJECT_RESOLVE_FUNCTIONS = new Map<
string,
(projectId: projectManagement.BundleInfo) => void
>()
electron.contextBridge.exposeInMainWorld(BACKEND_API_KEY, {
importProjectFromPath: (projectPath: string) => {
electron.ipcRenderer.send(ipc.Channel.importProjectFromPath, projectPath)
return new Promise<projectManagement.BundleInfo>(resolve => {
IMPORT_PROJECT_RESOLVE_FUNCTIONS.set(projectPath, resolve)
})
},
})
electron.ipcRenderer.on(
ipc.Channel.importProjectFromPath,
(_event, projectPath: string, projectInfo: projectManagement.BundleInfo) => {
const resolveFunction = IMPORT_PROJECT_RESOLVE_FUNCTIONS.get(projectPath)
IMPORT_PROJECT_RESOLVE_FUNCTIONS.delete(projectPath)
resolveFunction?.(projectInfo)
}
)
// =======================
// === Debug Info APIs ===
// =======================
@ -35,6 +66,7 @@ electron.contextBridge.exposeInMainWorld('enso_lifecycle', {
// Save and load profile data.
let onProfiles: ((profiles: string[]) => void)[] = []
let profilesLoaded: string[] | null
electron.ipcRenderer.on(ipc.Channel.profilesLoaded, (_event, profiles: string[]) => {
for (const callback of onProfiles) {
callback(profiles)
@ -42,6 +74,7 @@ electron.ipcRenderer.on(ipc.Channel.profilesLoaded, (_event, profiles: string[])
onProfiles = []
profilesLoaded = profiles
})
electron.contextBridge.exposeInMainWorld('enso_profiling_data', {
// Delivers profiling log.
saveProfile: (data: unknown) => {

View File

@ -9,9 +9,9 @@
* - if the project is a bundle, we extract it to the Project Manager's location and open it. */
import * as crypto from 'node:crypto'
import * as fsSync from 'node:fs'
import * as fss from 'node:fs'
import * as fs from 'node:fs'
import * as pathModule from 'node:path'
import * as stream from 'node:stream'
import * as electron from 'electron'
import * as tar from 'tar'
@ -28,13 +28,19 @@ const logger = config.logger
// === Project Import ===
// ======================
/** Information required to display a project bundle. */
export interface BundleInfo {
name: string
id: string
}
/** Open a project from the given path. Path can be either a source file under the project root,
* or the project bundle. If needed, the project will be imported into the Project Manager-enabled
* location.
*
* @returns Project ID (from Project Manager's metadata) identifying the imported project.
* @throws {Error} if the path does not belong to a valid project. */
export function importProjectFromPath(openedPath: string): string {
export function importProjectFromPath(openedPath: string): BundleInfo {
if (pathModule.extname(openedPath).endsWith(fileAssociations.BUNDLED_PROJECT_SUFFIX)) {
logger.log(`Path '${openedPath}' denotes a bundled project.`)
// The second part of condition is for the case when someone names a directory
@ -64,12 +70,13 @@ export function importProjectFromPath(openedPath: string): string {
/** Import the project from a bundle.
*
* @returns Project ID (from Project Manager's metadata) identifying the imported project. */
export function importBundle(bundlePath: string): string {
logger.log(`Importing project from bundle: '${bundlePath}'.`)
export function importBundle(bundlePath: string): BundleInfo {
logger.log(`Importing project '${bundlePath}' from bundle.`)
// The bundle is a tarball, so we just need to extract it to the right location.
const bundleRoot = directoryWithinBundle(bundlePath)
const targetDirectory = generateDirectoryName(bundleRoot ?? bundlePath)
fss.mkdirSync(targetDirectory, { recursive: true })
const target = generateDirectoryName(bundleRoot ?? bundlePath)
logger.log(`Importing project as '${target.name}'.`)
fs.mkdirSync(target.path, { recursive: true })
// To be more resilient against different ways that user might attempt to create a bundle,
// we try to support both archives that:
// * contain a single directory with the project files - that directory name will be used
@ -79,13 +86,47 @@ export function importBundle(bundlePath: string): string {
// We try to tell apart these two cases by looking at the common prefix of the paths
// of the files in the archive. If there is any, everything is under a single directory,
// and we need to strip it.
tar.x({
tar.extract({
file: bundlePath,
cwd: targetDirectory,
cwd: target.path,
sync: true,
strip: bundleRoot != null ? 1 : 0,
})
return updateId(targetDirectory)
updateName(target.path, target.name)
return { name: target.name, id: updateIdAndDate(target.path) }
}
/** Upload the project from a bundle. */
export async function uploadBundle(bundle: stream.Readable): Promise<BundleInfo> {
logger.log(`Uploading project from bundle.`)
let target = generateDirectoryName('Project')
fs.mkdirSync(target.path, { recursive: true })
await new Promise<void>(resolve => {
bundle.pipe(tar.extract({ cwd: target.path })).on('finish', resolve)
})
const entries = fs.readdirSync(target.path)
const firstEntry = entries[0]
// If the directory only contains one subdirectory, replace the directory with its sole
// subdirectory.
if (entries.length === 1 && firstEntry != null) {
if (fs.statSync(pathModule.join(target.path, firstEntry)).isDirectory()) {
const temporaryDirectoryName =
target.path + `_${crypto.randomUUID().split('-')[0] ?? ''}`
fs.renameSync(target.path, temporaryDirectoryName)
fs.renameSync(pathModule.join(temporaryDirectoryName, firstEntry), target.path)
fs.rmdirSync(temporaryDirectoryName)
}
}
const projectName = tryGetName(target.path)
if (projectName != null) {
const oldPath = target.path
target = generateDirectoryName(projectName)
if (target.path !== oldPath) {
fs.renameSync(oldPath, target.path)
}
}
updateName(target.path, target.name)
return { name: target.name, id: updateIdAndDate(target.path) }
}
/** Import the project so it becomes visible to the Project Manager.
@ -93,23 +134,28 @@ export function importBundle(bundlePath: string): string {
* @param rootPath - The path to the project root.
* @returns The project ID (from the Project Manager's metadata) identifying the imported project.
* @throws {Error} if a race condition occurs when generating a unique project directory name. */
export function importDirectory(rootPath: string): string {
export function importDirectory(rootPath: string): BundleInfo {
if (isProjectInstalled(rootPath)) {
// Project is already visible to Project Manager, so we can just return its ID.
logger.log(`Project already installed: '${rootPath}'.`)
return getProjectId(rootPath)
} else {
logger.log(`Importing a project copy from: '${rootPath}'.`)
const targetDirectory = generateDirectoryName(rootPath)
if (fsSync.existsSync(targetDirectory)) {
const message = `Project directory already exists: ${targetDirectory}.`
throw new Error(message)
logger.log(`Project already installed at '${rootPath}'.`)
const id = getProjectId(rootPath)
if (id != null) {
return { name: getProjectName(rootPath), id }
} else {
logger.log(`Copying: '${rootPath}' -> '${targetDirectory}'.`)
fsSync.cpSync(rootPath, targetDirectory, { recursive: true })
throw new Error(`Project already installed, but missing metadata.`)
}
} else {
logger.log(`Importing a project copy from '${rootPath}'.`)
const target = generateDirectoryName(rootPath)
if (fs.existsSync(target.path)) {
throw new Error(`Project directory '${target.path}' already exists.`)
} else {
logger.log(`Copying: '${rootPath}' -> '${target.path}'.`)
fs.cpSync(rootPath, target.path, { recursive: true })
updateName(target.path, target.name)
// Update the project ID, so we are certain that it is unique.
// This would be violated, if we imported the same project multiple times.
return updateId(targetDirectory)
return { name: target.name, id: updateIdAndDate(target.path) }
}
}
}
@ -118,13 +164,17 @@ export function importDirectory(rootPath: string): string {
// === Metadata ===
// ================
/** The Project Manager's metadata associated with a project.
*
* The property list is not exhaustive; it only contains the properties that we need. */
/** The Project Manager's metadata associated with a project. */
interface ProjectMetadata {
/** The ID of the project. It is only used in communication with project manager;
* it has no semantic meaning. */
id: string
/** The project variant. This is currently always `UserProject`. */
kind: 'UserProject'
/** The date at which the project was created, in RFC3339 format. */
created: string
/** The date at which the project was last opened, in RFC3339 format. */
lastOpened: string
}
/** A type guard function to check if an object conforms to the {@link ProjectMetadata} interface.
@ -143,28 +193,37 @@ function isProjectMetadata(value: unknown): value is ProjectMetadata {
}
/** Get the ID from the project metadata. */
export function getProjectId(projectRoot: string): string {
return getMetadata(projectRoot).id
export function getProjectId(projectRoot: string): string | null {
return getMetadata(projectRoot)?.id ?? null
}
/** Retrieve the project's metadata.
*
* @throws {Error} if the metadata file is missing or ill-formed. */
export function getMetadata(projectRoot: string): ProjectMetadata {
/** Create */
export function createMetadata(): ProjectMetadata {
return {
id: generateId(),
kind: 'UserProject',
created: new Date().toISOString(),
lastOpened: new Date().toISOString(),
}
}
/** Retrieve the project's metadata. */
export function getMetadata(projectRoot: string): ProjectMetadata | null {
const metadataPath = pathModule.join(projectRoot, paths.PROJECT_METADATA_RELATIVE)
const jsonText = fss.readFileSync(metadataPath, 'utf8')
const metadata: unknown = JSON.parse(jsonText)
if (isProjectMetadata(metadata)) {
return metadata
} else {
throw new Error('Invalid project metadata')
try {
const jsonText = fs.readFileSync(metadataPath, 'utf8')
const metadata: unknown = JSON.parse(jsonText)
return isProjectMetadata(metadata) ? metadata : null
} catch {
return null
}
}
/** Write the project's metadata. */
export function writeMetadata(projectRoot: string, metadata: ProjectMetadata): void {
const metadataPath = pathModule.join(projectRoot, paths.PROJECT_METADATA_RELATIVE)
fss.writeFileSync(metadataPath, JSON.stringify(metadata, null, utils.INDENT_SIZE))
fs.mkdirSync(pathModule.dirname(metadataPath), { recursive: true })
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, utils.INDENT_SIZE))
}
/** Update the project's metadata.
@ -176,7 +235,7 @@ export function updateMetadata(
updater: (initialMetadata: ProjectMetadata) => ProjectMetadata
): ProjectMetadata {
const metadata = getMetadata(projectRoot)
const updatedMetadata = updater(metadata)
const updatedMetadata = updater(metadata ?? createMetadata())
writeMetadata(projectRoot, updatedMetadata)
return updatedMetadata
}
@ -188,13 +247,18 @@ export function updateMetadata(
/** Check if the given path represents the root of an Enso project.
* This is decided by the presence of the Project Manager's metadata. */
export function isProjectRoot(candidatePath: string): boolean {
const packageYamlPath = pathModule.join(candidatePath, paths.BUNDLE_METADATA_RELATIVE)
const projectJsonPath = pathModule.join(candidatePath, paths.PROJECT_METADATA_RELATIVE)
let isRoot = false
try {
fss.accessSync(projectJsonPath, fss.constants.R_OK)
isRoot = true
} catch (e) {
// No need to do anything, isRoot is already set to false
fs.accessSync(packageYamlPath, fs.constants.R_OK)
} catch {
try {
fs.accessSync(projectJsonPath, fs.constants.R_OK)
isRoot = true
} catch {
// No need to do anything, isRoot is already set to false
}
}
return isRoot
}
@ -215,7 +279,13 @@ export function directoryWithinBundle(bundlePath: string): string | null {
})
// ESLint doesn't know that `commonPrefix` can be not `null` here due to the `onentry` callback.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return commonPrefix != null ? pathModule.basename(commonPrefix) : null
return commonPrefix != null && commonPrefix !== '' ? pathModule.basename(commonPrefix) : null
}
/** An object containing the name and path of a project. */
interface NameAndPath {
name: string
path: string
}
/** Generate a name for a project using given base string. A suffix is added if there is a
@ -225,7 +295,7 @@ export function directoryWithinBundle(bundlePath: string): string | null {
* If given a name like `Name_1` it will become `Name_2` if there is already a directory named
* `Name_1`. If a path containing multiple components is given, only the last component is used
* for the name. */
export function generateDirectoryName(name: string): string {
export function generateDirectoryName(name: string): NameAndPath {
// Use only the last path component.
name = pathModule.parse(name).name
@ -243,13 +313,11 @@ export function generateDirectoryName(name: string): string {
const projectsDirectory = getProjectsDirectory()
while (true) {
suffix++
const candidatePath = pathModule.join(
projectsDirectory,
`${name}${suffix === 0 ? '' : `_${suffix}`}`
)
if (!fss.existsSync(candidatePath)) {
const newName = `${name}${suffix === 0 ? '' : `_${suffix}`}`
const candidatePath = pathModule.join(projectsDirectory, newName)
if (!fs.existsSync(candidatePath)) {
// eslint-disable-next-line no-restricted-syntax
return candidatePath
return { name: newName, path: candidatePath }
}
}
// Unreachable.
@ -285,6 +353,33 @@ export function isProjectInstalled(projectRoot: string): boolean {
return pathModule.resolve(projectRootParent) === pathModule.resolve(projectsDirectory)
}
/** Get the name of the project from the bundle metadata (`package.yaml`). */
export function getProjectName(projectRoot: string) {
const metadataPath = pathModule.join(projectRoot, paths.BUNDLE_METADATA_RELATIVE)
const metadata = fs.readFileSync(metadataPath, 'utf-8')
return metadata.match(/^name: (.+)$/)?.[1] ?? ''
}
/** Update the name of the project in the bundle metadata (`package.yaml`). */
export function updateName(projectRoot: string, newName: string) {
const metadataPath = pathModule.join(projectRoot, paths.BUNDLE_METADATA_RELATIVE)
const oldMetadata = fs.readFileSync(metadataPath, 'utf-8')
const newMetadata = oldMetadata.replace(/^name: .+$/m, `name: ${newName}`)
fs.writeFileSync(metadataPath, newMetadata)
}
/** Gets the name of a bundle from the bundle metadata (`package.yaml`). */
export function tryGetName(projectRoot: string) {
const metadataPath = pathModule.join(projectRoot, paths.BUNDLE_METADATA_RELATIVE)
try {
const metadata = fs.readFileSync(metadataPath, 'utf-8')
return metadata.match(/^name: (.+)$/m)?.[1] ?? null
} catch {
/// The bundle metadata file was not found.
return null
}
}
// ==================
// === Project ID ===
// ==================
@ -294,10 +389,11 @@ export function generateId(): string {
return crypto.randomUUID()
}
/** Update the project's ID to a new, unique value. */
export function updateId(projectRoot: string): string {
/** Update the project's ID to a new, unique value, and its last opened date to the current date. */
export function updateIdAndDate(projectRoot: string): string {
return updateMetadata(projectRoot, metadata => ({
...metadata,
id: generateId(),
lastOpened: new Date().toISOString(),
})).id
}

View File

@ -14,6 +14,9 @@ const TRUSTED_HOSTS = [
'github.com',
'production-enso-domain.auth.eu-west-1.amazoncognito.com',
'pb-enso-domain.auth.eu-west-1.amazoncognito.com',
// This (`localhost`) is required to access Project Manager HTTP endpoints.
// This should be changed appropriately if the Project Manager's port number becomes dynamic.
'127.0.0.1:30535',
]
/** The list of hosts that the app can open external links to. */

View File

@ -47,7 +47,11 @@ if (IS_DEV_MODE && !detect.isRunningInElectron()) {
location.href = location.href.toString()
})
}
void navigator.serviceWorker.register(SERVICE_WORKER_PATH)
void (async () => {
const registration = await navigator.serviceWorker.getRegistration()
await registration?.unregister()
await navigator.serviceWorker.register(SERVICE_WORKER_PATH)
})()
// =============
// === Fetch ===

View File

@ -59,7 +59,7 @@ export const DEPENDENCIES = [
// Loaded by https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;700&display=swap
...M_PLUS_1_SECTIONS.map(
number =>
`https://fonts.gstatic.com/s/mplus1/v6/` +
'https://fonts.gstatic.com/s/mplus1/v6/' +
`R70ZjygA28ymD4HgBVu92j6eR1mYP_TX-Bb-rTg93gHfHe9F4Q.${number}.woff2`
),
]

View File

@ -182,6 +182,7 @@ export interface FileInfo {
* but it's just string on the backend. */
path: string
id: FileId
project: CreatedProject | null
}
/** A secret environment variable. */
@ -562,6 +563,40 @@ export function getAssetId<Type extends AssetType>(asset: Asset<Type>) {
return asset.id
}
// =====================
// === fileIsProject ===
// =====================
/** A subset of properties of the JS `File` type. */
interface JSFile {
name: string
}
/** Whether a `File` is a project. */
export function fileIsProject(file: JSFile) {
return (
file.name.endsWith('.tar.gz') ||
file.name.endsWith('.zip') ||
file.name.endsWith('.enso-project')
)
}
/** Whether a `File` is not a project. */
export function fileIsNotProject(file: JSFile) {
return !fileIsProject(file)
}
// =============================
// === stripProjectExtension ===
// =============================
/** Remove the extension of the project file name (if any). */
/** Whether a `File` is a project. */
export function stripProjectExtension(name: string) {
return name.replace(/\.tar\.gz$|\.zip$|\.enso-project/, '')
}
// ==============================
// === groupPermissionsByUser ===
// ==============================

View File

@ -13,6 +13,7 @@ import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as columnModule from '../column'
import * as dateTime from '../dateTime'
import * as download from '../../download'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as modalProvider from '../../providers/modal'
@ -88,6 +89,7 @@ function AssetRow(props: AssetRowProps<backendModule.AnyAsset>) {
item: rawItem,
initialRowState,
columns,
selected,
state: { assetEvents, dispatchAssetEvent, dispatchAssetListEvent, getDepth },
} = props
const { backend } = backendProvider.useBackend()
@ -147,6 +149,15 @@ function AssetRow(props: AssetRowProps<backendModule.AnyAsset>) {
}
break
}
case assetEventModule.AssetEventType.downloadSelected: {
if (selected) {
download.download(
'./api/project-manager/' + `projects/${item.id}/enso-project`,
`${item.title}.enso-project`
)
}
break
}
}
})
@ -486,8 +497,9 @@ export default function AssetsTable(props: AssetsTableProps) {
break
}
case assetListEventModule.AssetListEventType.uploadFiles: {
const placeholderItems: backendModule.FileAsset[] = Array.from(event.files)
.reverse()
const reversedFiles = Array.from(event.files).reverse()
const placeholderFiles: backendModule.FileAsset[] = reversedFiles
.filter(backendModule.fileIsNotProject)
.map(file => ({
type: backendModule.AssetType.file,
id: backendModule.FileId(uniqueString.uniqueString()),
@ -497,20 +509,42 @@ export default function AssetsTable(props: AssetsTableProps) {
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: null,
}))
const placeholderProjects: backendModule.ProjectAsset[] = reversedFiles
.filter(backendModule.fileIsProject)
.map(file => ({
type: backendModule.AssetType.project,
id: backendModule.ProjectId(uniqueString.uniqueString()),
title: file.name,
parentId: event.parentId ?? backendModule.DirectoryId(''),
permissions: permissions.tryGetSingletonOwnerPermission(organization),
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: {
type: backendModule.ProjectState.new,
},
}))
const fileTypeOrder = backendModule.ASSET_TYPE_ORDER[backendModule.AssetType.file]
setItems(oldItems =>
array.splicedBefore(
oldItems,
placeholderItems,
const projectTypeOrder =
backendModule.ASSET_TYPE_ORDER[backendModule.AssetType.project]
setItems(oldItems => {
const ret = array.spliceBefore(
array.splicedBefore(
oldItems,
placeholderFiles,
item =>
item.parentId === event.parentId &&
backendModule.ASSET_TYPE_ORDER[item.type] >= fileTypeOrder
),
placeholderProjects,
item =>
item.parentId === event.parentId &&
backendModule.ASSET_TYPE_ORDER[item.type] >= fileTypeOrder
backendModule.ASSET_TYPE_ORDER[item.type] >= projectTypeOrder
)
)
return ret
})
dispatchAssetEvent({
type: assetEventModule.AssetEventType.uploadFiles,
files: new Map(
placeholderItems.map((placeholderItem, i) => [
[...placeholderFiles, ...placeholderProjects].map((placeholderItem, i) => [
placeholderItem.id,
// This is SAFE, as `placeholderItems` is created using a map on
// `event.files`.

View File

@ -59,8 +59,10 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple: {
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected: {
// Ignored. These events should all be unrelated to directories.
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.createDirectory: {

View File

@ -7,8 +7,10 @@ import AddFolderIcon from 'enso-assets/add_folder.svg'
import DataDownloadIcon from 'enso-assets/data_download.svg'
import DataUploadIcon from 'enso-assets/data_upload.svg'
import * as assetEventModule from '../events/assetEvent'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as modalProvider from '../../providers/modal'
import Button from './button'
// ================
@ -19,31 +21,25 @@ import Button from './button'
export interface DriveBarProps {
doCreateProject: (templateId: string | null) => void
doCreateDirectory: () => void
doUploadFiles: (files: FileList) => void
doUploadFiles: (files: File[]) => void
dispatchAssetEvent: (event: assetEventModule.AssetEvent) => void
}
/** Displays the current directory path and permissions, upload and download buttons,
* and a column display mode switcher. */
export default function DriveBar(props: DriveBarProps) {
const { doCreateProject, doCreateDirectory, doUploadFiles: doUploadFilesRaw } = props
const { doCreateProject, doCreateDirectory, doUploadFiles, dispatchAssetEvent } = props
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
const doUploadFiles = React.useCallback(
(event: React.FormEvent<HTMLInputElement>) => {
if (event.currentTarget.files != null) {
doUploadFilesRaw(event.currentTarget.files)
}
},
[/* should never change */ doUploadFilesRaw]
)
return (
<div className="flex py-0.5">
<div className="flex gap-2.5">
<button
className="flex items-center bg-frame-bg rounded-full h-8 px-2.5"
onClick={() => {
unsetModal()
doCreateProject(null)
}}
>
@ -52,7 +48,14 @@ export default function DriveBar(props: DriveBarProps) {
<div className="flex items-center bg-frame-bg rounded-full gap-3 h-8 px-3">
{backend.type !== backendModule.BackendType.local && (
<>
<Button active image={AddFolderIcon} onClick={doCreateDirectory} />
<Button
active
image={AddFolderIcon}
onClick={() => {
unsetModal()
doCreateDirectory()
}}
/>
<Button
active
disabled
@ -70,25 +73,38 @@ export default function DriveBar(props: DriveBarProps) {
multiple
id="upload_files_input"
name="upload_files_input"
{...(backend.type !== backendModule.BackendType.local
? {}
: { accept: '.enso-project' })}
className="hidden"
onInput={doUploadFiles}
onInput={event => {
if (event.currentTarget.files != null) {
doUploadFiles(Array.from(event.currentTarget.files))
}
// Clear the list of selected files. Otherwise, `onInput` will not be
// dispatched again if the same file is selected.
event.currentTarget.value = ''
}}
/>
<Button
active
disabled={backend.type === backendModule.BackendType.local}
image={DataUploadIcon}
error="Cannot upload files from the local backend."
onClick={() => {
unsetModal()
uploadFilesRef.current?.click()
}}
/>
<Button
active
disabled
disabled={backend.type !== backendModule.BackendType.local}
image={DataDownloadIcon}
error="Not implemented yet."
onClick={() => {
// No backend support yet.
onClick={event => {
event.stopPropagation()
unsetModal()
dispatchAssetEvent({
type: assetEventModule.AssetEventType.downloadSelected,
})
}}
/>
</div>

View File

@ -215,12 +215,8 @@ export default function DriveView(props: DriveViewProps) {
)
const doUploadFiles = React.useCallback(
(files: FileList) => {
if (backend.type === backendModule.BackendType.local) {
// TODO[sb]: Allow uploading `.enso-project`s
// https://github.com/enso-org/cloud-v2/issues/510
toastAndLog('Files cannot be uploaded to the local backend')
} else if (directoryId == null) {
(files: File[]) => {
if (backend.type !== backendModule.BackendType.local && directoryId == null) {
// This should never happen, however display a nice error message in case it does.
toastAndLog('Files cannot be uploaded while offline')
} else {
@ -272,6 +268,7 @@ export default function DriveView(props: DriveViewProps) {
doCreateProject={doCreateProject}
doUploadFiles={doUploadFiles}
doCreateDirectory={doCreateDirectory}
dispatchAssetEvent={dispatchAssetEvent}
/>
</div>
<AssetsTable
@ -303,7 +300,7 @@ export default function DriveView(props: DriveViewProps) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.uploadFiles,
parentId: directoryId,
files: event.dataTransfer.files,
files: Array.from(event.dataTransfer.files),
})
}}
>

View File

@ -50,40 +50,37 @@ export default function FileNameColumn(props: FileNameColumnProps) {
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple: {
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected: {
// Ignored. These events should all be unrelated to projects.
// `deleteMultiple` is handled by `AssetRow`.
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.uploadFiles: {
const file = event.files.get(key)
if (file != null) {
if (backend.type !== backendModule.BackendType.remote) {
toastAndLog('Files cannot be uploaded on the local backend')
} else {
rowState.setPresence(presence.Presence.inserting)
try {
const createdFile = await backend.uploadFile(
{
fileId: null,
fileName: item.title,
parentDirectoryId: item.parentId,
},
file
)
rowState.setPresence(presence.Presence.present)
const newItem: backendModule.FileAsset = {
...item,
...createdFile,
}
setItem(newItem)
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
id: key,
})
toastAndLog('Error creating new file', error)
rowState.setPresence(presence.Presence.inserting)
try {
const createdFile = await backend.uploadFile(
{
fileId: null,
fileName: item.title,
parentDirectoryId: item.parentId,
},
file
)
rowState.setPresence(presence.Presence.present)
const newItem: backendModule.FileAsset = {
...item,
...createdFile,
}
setItem(newItem)
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
id: key,
})
toastAndLog('Could not upload file', error)
}
}
break

View File

@ -1,9 +1,16 @@
/** @file The context menu for a {@link backendModule.ProjectAsset}. */
import * as React from 'react'
import * as toast from 'react-toastify'
import * as assetEventModule from '../events/assetEvent'
import * as authProvider from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as hooks from '../../hooks'
import * as http from '../../http'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import * as remoteBackendModule from '../remoteBackend'
import * as assetContextMenu from './assetContextMenu'
import ConfirmDeleteModal from './confirmDeleteModal'
@ -33,7 +40,11 @@ export default function ProjectContextMenu(props: ProjectContextMenuProps) {
dispatchAssetEvent,
doDelete,
} = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { accessToken } = authProvider.useNonPartialUserSession()
const toastAndLog = hooks.useToastAndLog()
const doOpenForEditing = () => {
unsetModal()
@ -42,6 +53,32 @@ export default function ProjectContextMenu(props: ProjectContextMenuProps) {
id: item.id,
})
}
const doUploadToCloud = async () => {
unsetModal()
if (accessToken == null) {
toastAndLog('Cannot upload to cloud in offline mode')
} else {
try {
const headers = new Headers([['Authorization', `Bearer ${accessToken}`]])
const client = new http.Client(headers)
const remoteBackend = new remoteBackendModule.RemoteBackend(client, logger)
const projectResponse = await fetch(
`./api/project-manager/projects/${item.id}/enso-project`
)
await remoteBackend.uploadFile(
{
fileName: `${item.title}.enso-project`,
fileId: null,
parentDirectoryId: null,
},
await projectResponse.blob()
)
toast.toast.success('Successfully uploaded local project to cloud!')
} catch (error) {
toastAndLog('Could not upload local project to cloud', error)
}
}
}
const doRename = () => {
setRowState(oldRowState => ({
...oldRowState,
@ -52,6 +89,9 @@ export default function ProjectContextMenu(props: ProjectContextMenuProps) {
return (
<ContextMenu key={item.id} event={event}>
<ContextMenuEntry onClick={doOpenForEditing}>Open for editing</ContextMenuEntry>
{backend.type === backendModule.BackendType.local && (
<ContextMenuEntry onClick={doUploadToCloud}>Upload to cloud</ContextMenuEntry>
)}
<ContextMenuEntry onClick={doRename}>Rename</ContextMenuEntry>
<ContextMenuEntry
onClick={() => {

View File

@ -207,9 +207,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
case assetEventModule.AssetEventType.createDirectory:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.deleteMultiple: {
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected: {
// Ignored. Any missing project-related events should be handled by
// `ProjectNameColumn`. `deleteMultiple` is handled by `AssetRow`.
// `ProjectNameColumn`. `deleteMultiple` and `downloadSelected` are handled by
// `AssetRow`.
break
}
case assetEventModule.AssetEventType.openProject: {

View File

@ -68,13 +68,13 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.createDirectory:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple: {
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected: {
// Ignored. Any missing project-related events should be handled by `ProjectIcon`.
// `deleteMultiple` is handled by `AssetRow`.
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.createProject: {
@ -109,6 +109,83 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}
break
}
case assetEventModule.AssetEventType.uploadFiles: {
const file = event.files.get(key)
if (file != null) {
rowState.setPresence(presence.Presence.inserting)
try {
if (backend.type === backendModule.BackendType.local) {
/** Information required to display a bundle. */
interface BundleInfo {
name: string
id: string
}
// This non-standard property is defined in Electron.
let info: BundleInfo
if (
'backendApi' in window &&
'path' in file &&
typeof file.path === 'string'
) {
info = await window.backendApi.importProjectFromPath(file.path)
} else {
const response = await fetch('./api/upload-project', {
method: 'POST',
// Ideally this would use `file.stream()`, to minimize RAM
// requirements. for uploading large projects. Unfortunately,
// this requires HTTP/2, which is HTTPS-only, so it will not
// work on `http://localhost`.
body: await file.arrayBuffer(),
})
// This is SAFE, as the types of this API are statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
info = await response.json()
}
rowState.setPresence(presence.Presence.present)
setItem({
...item,
title: info.name,
id: backendModule.ProjectId(info.id),
})
} else {
const fileName = item.title
const title = backendModule.stripProjectExtension(item.title)
setItem({
...item,
title,
})
const createdFile = await backend.uploadFile(
{
fileId: null,
fileName,
parentDirectoryId: item.parentId,
},
file
)
const project = createdFile.project
if (project == null) {
throw new Error('The uploaded file was not a project.')
} else {
rowState.setPresence(presence.Presence.present)
setItem({
...item,
title,
id: project.projectId,
projectState: project.state,
})
return
}
}
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
id: key,
})
toastAndLog('Could not upload project', error)
}
}
break
}
}
})

View File

@ -51,9 +51,10 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple: {
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected: {
// Ignored. These events should all be unrelated to secrets.
// `deleteMultiple` is handled by `AssetRow`.
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.createSecret: {

View File

@ -25,6 +25,7 @@ export enum AssetEventType {
openProject = 'open-project',
cancelOpeningAllProjects = 'cancel-opening-all-projects',
deleteMultiple = 'delete-multiple',
downloadSelected = 'download-selected',
}
/** Properties common to all asset state change events. */
@ -41,6 +42,7 @@ interface AssetEvents {
openProject: AssetOpenProjectEvent
cancelOpeningAllProjects: AssetCancelOpeningAllProjectsEvent
deleteMultiple: AssetDeleteMultipleEvent
downloadSelected: AssetDownloadSelectedEvent
}
/** A type to ensure that {@link AssetEvents} contains every {@link AssetLEventType}. */
@ -67,7 +69,7 @@ export interface AssetCreateDirectoryEvent extends AssetBaseEvent<AssetEventType
/** A signal to upload files. */
export interface AssetUploadFilesEvent extends AssetBaseEvent<AssetEventType.uploadFiles> {
files: Map<backendModule.FileId, File>
files: Map<backendModule.FileId | backendModule.ProjectId, File>
}
/** A signal to create a secret. */
@ -90,5 +92,9 @@ export interface AssetDeleteMultipleEvent extends AssetBaseEvent<AssetEventType.
ids: Set<backendModule.AssetId>
}
/** A signal to download the currently selected assets. */
export interface AssetDownloadSelectedEvent
extends AssetBaseEvent<AssetEventType.downloadSelected> {}
/** Every possible type of asset event. */
export type AssetEvent = AssetEvents[keyof AssetEvents]

View File

@ -63,7 +63,7 @@ interface AssetListCreateProjectEvent extends AssetListBaseEvent<AssetListEventT
/** A signal to upload files. */
interface AssetListUploadFilesEvent extends AssetListBaseEvent<AssetListEventType.uploadFiles> {
parentId: backend.DirectoryId | null
files: FileList
files: File[]
}
/** A signal to create a new secret. */

View File

@ -5,6 +5,7 @@
* the response from the API. */
import * as backend from './backend'
import * as config from '../config'
import * as errorModule from '../error'
import * as http from '../http'
import * as loggerProvider from '../providers/logger'
@ -511,7 +512,7 @@ export class RemoteBackend extends backend.Backend {
params: backend.UploadFileRequestParams,
body: Blob
): Promise<backend.FileInfo> {
const response = await this.postBase64<backend.FileInfo>(
const response = await this.postBinary<backend.FileInfo>(
UPLOAD_FILE_PATH +
'?' +
new URLSearchParams({
@ -526,12 +527,21 @@ export class RemoteBackend extends backend.Backend {
body
)
if (!responseIsSuccessful(response)) {
let suffix = '.'
try {
const error = errorModule.tryGetError<unknown>(await response.json())
if (error != null) {
suffix = `: ${error}`
}
} catch {
// Ignored.
}
if (params.fileName != null) {
return this.throw(`Unable to upload file with name '${params.fileName}'.`)
return this.throw(`Could not upload file with name '${params.fileName}'${suffix}`)
} else if (params.fileId != null) {
return this.throw(`Unable to upload file with ID '${params.fileId}'.`)
return this.throw(`Could not upload file with ID '${params.fileId}'${suffix}`)
} else {
return this.throw('Unable to upload file.')
return this.throw(`Could not upload file${suffix}`)
}
} else {
return await response.json()
@ -687,8 +697,8 @@ export class RemoteBackend extends backend.Backend {
}
/** Send a binary HTTP POST request to the given path. */
private postBase64<T = void>(path: string, payload: Blob) {
return this.client.postBase64<T>(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload)
private postBinary<T = void>(path: string, payload: Blob) {
return this.client.postBinary<T>(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload)
}
/** Send a JSON HTTP PUT request to the given path. */

View File

@ -0,0 +1,11 @@
/** @file A function to initiate a download. */
/** Initiates a download for the specified url. */
export function download(url: string, name?: string) {
const link = document.createElement('a')
link.href = url
link.download = name ?? url.match(/[^/]+$/)?.[0] ?? ''
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}

View File

@ -22,15 +22,26 @@ export type MustNotBeKnown<T> =
// eslint-disable-next-line @typescript-eslint/ban-types, no-restricted-syntax
MustBe<T, {}> | MustBe<T, object> | MustBe<T, unknown> | MustBeAny<T>
export function tryGetMessage<T>(error: MustNotBeKnown<T>): string | null
/** Extracts the `message` property of a value if it is a string. Intended to be used on
* {@link Error}s. */
export function tryGetMessage(error: unknown): string | null {
return error != null &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string'
? error.message
export function tryGetMessage<T>(error: MustNotBeKnown<T>): string | null {
const unknownError: unknown = error
return unknownError != null &&
typeof unknownError === 'object' &&
'message' in unknownError &&
typeof unknownError.message === 'string'
? unknownError.message
: null
}
/** Extracts the `error` property of a value if it is a string. */
export function tryGetError<T>(error: MustNotBeKnown<T>): string | null {
const unknownError: unknown = error
return unknownError != null &&
typeof unknownError === 'object' &&
'error' in unknownError &&
typeof unknownError.error === 'string'
? unknownError.error
: null
}

View File

@ -5,6 +5,11 @@ import FileIcon from 'enso-assets/file.svg'
// === Extract file information ===
// ================================
/** Return just the file name, without the path and without the extension. */
export function baseName(fileName: string) {
return fileName.match(/(?:\/|^)([^./]+)(?:\.[^/]*)?$/)?.[1] ?? fileName
}
/** Extract the file extension from a file name. */
export function fileExtension(fileName: string) {
return fileName.match(/\.(.+?)$/)?.[1] ?? ''

View File

@ -18,21 +18,6 @@ enum HttpMethod {
// === Client ===
// ==============
/** A helper function to convert a `Blob` to a base64-encoded string. */
function blobToBase64(blob: Blob) {
return new Promise<string>(resolve => {
const reader = new FileReader()
reader.onload = () => {
resolve(
// This cast is always safe because we read as data URL (a string).
// eslint-disable-next-line no-restricted-syntax
(reader.result as string).replace(/^data:application\/octet-stream;base64,/, '')
)
}
reader.readAsDataURL(blob)
})
}
/** An HTTP client that can be used to create and send HTTP requests asynchronously. */
export class Client {
/** Create a new HTTP client with the specified headers to be sent on every request. */
@ -55,13 +40,8 @@ export class Client {
}
/** Send a base64-encoded binary HTTP POST request to the specified URL. */
async postBase64<T = void>(url: string, payload: Blob) {
return await this.request<T>(
HttpMethod.post,
url,
await blobToBase64(payload),
'application/octet-stream'
)
async postBinary<T = void>(url: string, payload: Blob) {
return await this.request<T>(HttpMethod.post, url, payload, 'application/octet-stream')
}
/** Send a JSON HTTP PUT request to the specified URL. */
@ -78,7 +58,7 @@ export class Client {
private request<T = void>(
method: HttpMethod,
url: string,
payload?: string,
payload?: BodyInit,
mimetype?: string
) {
const headers = new Headers(this.defaultHeaders)

View File

@ -19,6 +19,23 @@ interface Enso {
main: (inputConfig?: StringConfig) => Promise<void>
}
// ===================
// === Backend API ===
// ===================
/** Information required to display a bundle. */
interface BundleInfo {
name: string
id: string
}
/** `window.backendApi` is a context bridge to the main process, when we're running in an
* Electron context. It contains non-authentication-related functionality. */
interface BackendApi {
/** Return the ID of the new project. */
importProjectFromPath: (openedPath: string) => Promise<BundleInfo>
}
// ==========================
// === Authentication API ===
// ==========================
@ -51,6 +68,7 @@ declare global {
/** */
interface Window {
enso?: AppRunner & Enso
backendApi?: BackendApi
authenticationApi: AuthenticationApi
}

View File

@ -37,6 +37,7 @@ declare module '*/gui/config.yaml' {
windowAppScopeConfigName: string
windowAppScopeThemeName: string
projectManagerEndpoint: string
projectManagerHttpEndpoint: string
minimumSupportedVersion: string
engineVersionSupported: string
languageEditionSupported: string