From 0e20644e4793626aadfb97e6707723a145ca5eef Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 9 Aug 2023 19:30:40 +1000 Subject: [PATCH] Upload and download `.enso-project`s 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 --- app/gui/config.yaml | 3 + app/ide-desktop/eslint.config.js | 6 +- app/ide-desktop/lib/client/esbuild-config.ts | 2 + app/ide-desktop/lib/client/src/bin/server.ts | 73 +++++++ .../lib/client/src/file-associations.ts | 2 +- app/ide-desktop/lib/client/src/index.ts | 9 +- app/ide-desktop/lib/client/src/ipc.ts | 2 + app/ide-desktop/lib/client/src/paths.ts | 2 + app/ide-desktop/lib/client/src/preload.ts | 33 +++ .../lib/client/src/project-management.ts | 202 +++++++++++++----- app/ide-desktop/lib/client/src/security.ts | 3 + app/ide-desktop/lib/content/src/index.ts | 6 +- .../lib/content/src/serviceWorkerConstants.js | 2 +- .../authentication/src/dashboard/backend.ts | 35 +++ .../src/dashboard/components/assetsTable.tsx | 52 ++++- .../components/directoryNameColumn.tsx | 4 +- .../src/dashboard/components/driveBar.tsx | 52 +++-- .../src/dashboard/components/driveView.tsx | 11 +- .../dashboard/components/fileNameColumn.tsx | 51 +++-- .../components/projectContextMenu.tsx | 40 ++++ .../src/dashboard/components/projectIcon.tsx | 6 +- .../components/projectNameColumn.tsx | 83 ++++++- .../dashboard/components/secretNameColumn.tsx | 5 +- .../src/dashboard/events/assetEvent.ts | 8 +- .../src/dashboard/events/assetListEvent.ts | 2 +- .../src/dashboard/remoteBackend.ts | 22 +- .../src/authentication/src/download.ts | 11 + .../dashboard/src/authentication/src/error.ts | 25 ++- .../src/authentication/src/fileInfo.ts | 5 + .../dashboard/src/authentication/src/http.tsx | 26 +-- app/ide-desktop/lib/types/globals.d.ts | 18 ++ app/ide-desktop/lib/types/modules.d.ts | 1 + 32 files changed, 637 insertions(+), 165 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/download.ts diff --git a/app/gui/config.yaml b/app/gui/config.yaml index 08ece4c7e1..7e5613620b 100644 --- a/app/gui/config.yaml +++ b/app/gui/config.yaml @@ -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" diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index 9541f59c99..6a95321787 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -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 `.`', + }, ] // ============================ diff --git a/app/ide-desktop/lib/client/esbuild-config.ts b/app/ide-desktop/lib/client/esbuild-config.ts index 6dd6196545..aa4ac2c3d7 100644 --- a/app/ide-desktop/lib/client/esbuild-config.ts +++ b/app/ide-desktop/lib/client/esbuild-config.ts @@ -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' }, diff --git a/app/ide-desktop/lib/client/src/bin/server.ts b/app/ide-desktop/lib/client/src/bin/server.ts index 5ff4c7e527..accb05ca90 100644 --- a/app/ide-desktop/lib/client/src/bin/server.ts +++ b/app/ide-desktop/lib/client/src/bin/server.ts @@ -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 +} + /** 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 diff --git a/app/ide-desktop/lib/client/src/file-associations.ts b/app/ide-desktop/lib/client/src/file-associations.ts index 6e1cee3da6..87049eccdd 100644 --- a/app/ide-desktop/lib/client/src/file-associations.ts +++ b/app/ide-desktop/lib/client/src/file-associations.ts @@ -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. diff --git a/app/ide-desktop/lib/client/src/index.ts b/app/ide-desktop/lib/client/src/index.ts index 73494ca2ec..5cef5f9a7d 100644 --- a/app/ide-desktop/lib/client/src/index.ts +++ b/app/ide-desktop/lib/client/src/index.ts @@ -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 diff --git a/app/ide-desktop/lib/client/src/ipc.ts b/app/ide-desktop/lib/client/src/ipc.ts index 301ab8615c..7aefe02e1d 100644 --- a/app/ide-desktop/lib/client/src/ipc.ts +++ b/app/ide-desktop/lib/client/src/ipc.ts @@ -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', } diff --git a/app/ide-desktop/lib/client/src/paths.ts b/app/ide-desktop/lib/client/src/paths.ts index 7ce18b7a1f..c21454c314 100644 --- a/app/ide-desktop/lib/client/src/paths.ts +++ b/app/ide-desktop/lib/client/src/paths.ts @@ -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') diff --git a/app/ide-desktop/lib/client/src/preload.ts b/app/ide-desktop/lib/client/src/preload.ts index 5ed5444aaa..776ccc8a9f 100644 --- a/app/ide-desktop/lib/client/src/preload.ts +++ b/app/ide-desktop/lib/client/src/preload.ts @@ -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(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) => { diff --git a/app/ide-desktop/lib/client/src/project-management.ts b/app/ide-desktop/lib/client/src/project-management.ts index 665f6a51fe..4a793d798f 100644 --- a/app/ide-desktop/lib/client/src/project-management.ts +++ b/app/ide-desktop/lib/client/src/project-management.ts @@ -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 { + logger.log(`Uploading project from bundle.`) + let target = generateDirectoryName('Project') + fs.mkdirSync(target.path, { recursive: true }) + await new Promise(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 } diff --git a/app/ide-desktop/lib/client/src/security.ts b/app/ide-desktop/lib/client/src/security.ts index fe524ce12a..2cdfab0f6e 100644 --- a/app/ide-desktop/lib/client/src/security.ts +++ b/app/ide-desktop/lib/client/src/security.ts @@ -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. */ diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index f44d18cdeb..c4637fbe49 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -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 === diff --git a/app/ide-desktop/lib/content/src/serviceWorkerConstants.js b/app/ide-desktop/lib/content/src/serviceWorkerConstants.js index 0cd2bc744b..c80edfab74 100644 --- a/app/ide-desktop/lib/content/src/serviceWorkerConstants.js +++ b/app/ide-desktop/lib/content/src/serviceWorkerConstants.js @@ -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` ), ] diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts index 85618130c9..29c3d6f380 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts @@ -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(asset: Asset) { 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 === // ============================== diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx index 1207514965..b213b03fbd 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx @@ -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) { item: rawItem, initialRowState, columns, + selected, state: { assetEvents, dispatchAssetEvent, dispatchAssetListEvent, getDepth }, } = props const { backend } = backendProvider.useBackend() @@ -147,6 +149,15 @@ function AssetRow(props: AssetRowProps) { } 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`. diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryNameColumn.tsx index c45ec6021c..6830f3a544 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryNameColumn.tsx @@ -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: { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveBar.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveBar.tsx index c701c3d66f..b75d28bdf8 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveBar.tsx @@ -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(null) - const doUploadFiles = React.useCallback( - (event: React.FormEvent) => { - if (event.currentTarget.files != null) { - doUploadFilesRaw(event.currentTarget.files) - } - }, - [/* should never change */ doUploadFilesRaw] - ) - return (
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveView.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveView.tsx index 48346cd8d1..bbf9b3934b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveView.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/driveView.tsx @@ -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} />
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileNameColumn.tsx index 266d231c48..c12f0c9c19 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileNameColumn.tsx @@ -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 diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectContextMenu.tsx index 27f61cb506..f3a3f532bf 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectContextMenu.tsx @@ -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 ( Open for editing + {backend.type === backendModule.BackendType.local && ( + Upload to cloud + )} Rename { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectIcon.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectIcon.tsx index 66e810b4a8..a7c8d1f837 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectIcon.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectIcon.tsx @@ -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: { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx index f451c563b1..cce492069b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx @@ -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 + } } }) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretNameColumn.tsx index 936e329147..b0e0fd5968 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretNameColumn.tsx @@ -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: { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts index 9ece51ff55..6e068e17ed 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts @@ -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 { - files: Map + files: Map } /** A signal to create a secret. */ @@ -90,5 +92,9 @@ export interface AssetDeleteMultipleEvent extends AssetBaseEvent } +/** A signal to download the currently selected assets. */ +export interface AssetDownloadSelectedEvent + extends AssetBaseEvent {} + /** Every possible type of asset event. */ export type AssetEvent = AssetEvents[keyof AssetEvents] diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetListEvent.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetListEvent.ts index bf2fe33892..37c179b2f0 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetListEvent.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetListEvent.ts @@ -63,7 +63,7 @@ interface AssetListCreateProjectEvent extends AssetListBaseEvent { parentId: backend.DirectoryId | null - files: FileList + files: File[] } /** A signal to create a new secret. */ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts index febfe85961..6f1443e021 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts @@ -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 { - const response = await this.postBase64( + const response = await this.postBinary( 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(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(path: string, payload: Blob) { - return this.client.postBase64(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload) + private postBinary(path: string, payload: Blob) { + return this.client.postBinary(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload) } /** Send a JSON HTTP PUT request to the given path. */ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/download.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/download.ts new file mode 100644 index 0000000000..2492af87bf --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/download.ts @@ -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) +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts index e1b14c2987..f8e756e594 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts @@ -22,15 +22,26 @@ export type MustNotBeKnown = // eslint-disable-next-line @typescript-eslint/ban-types, no-restricted-syntax MustBe | MustBe | MustBe | MustBeAny -export function tryGetMessage(error: MustNotBeKnown): 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(error: MustNotBeKnown): 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(error: MustNotBeKnown): string | null { + const unknownError: unknown = error + return unknownError != null && + typeof unknownError === 'object' && + 'error' in unknownError && + typeof unknownError.error === 'string' + ? unknownError.error : null } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts index 8294988160..21a07a1cca 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts @@ -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] ?? '' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx index aefc18eeca..17150adc91 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx @@ -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(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(url: string, payload: Blob) { - return await this.request( - HttpMethod.post, - url, - await blobToBase64(payload), - 'application/octet-stream' - ) + async postBinary(url: string, payload: Blob) { + return await this.request(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( method: HttpMethod, url: string, - payload?: string, + payload?: BodyInit, mimetype?: string ) { const headers = new Headers(this.defaultHeaders) diff --git a/app/ide-desktop/lib/types/globals.d.ts b/app/ide-desktop/lib/types/globals.d.ts index 806831284b..0be1cf636f 100644 --- a/app/ide-desktop/lib/types/globals.d.ts +++ b/app/ide-desktop/lib/types/globals.d.ts @@ -19,6 +19,23 @@ interface Enso { main: (inputConfig?: StringConfig) => Promise } +// =================== +// === 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 +} + // ========================== // === Authentication API === // ========================== @@ -51,6 +68,7 @@ declare global { /** */ interface Window { enso?: AppRunner & Enso + backendApi?: BackendApi authenticationApi: AuthenticationApi } diff --git a/app/ide-desktop/lib/types/modules.d.ts b/app/ide-desktop/lib/types/modules.d.ts index 533e8ecdfb..c9cc1c7b0a 100644 --- a/app/ide-desktop/lib/types/modules.d.ts +++ b/app/ide-desktop/lib/types/modules.d.ts @@ -37,6 +37,7 @@ declare module '*/gui/config.yaml' { windowAppScopeConfigName: string windowAppScopeThemeName: string projectManagerEndpoint: string + projectManagerHttpEndpoint: string minimumSupportedVersion: string engineVersionSupported: string languageEditionSupported: string