mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 22:10:15 +03:00
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
This commit is contained in:
parent
59329bd59a
commit
0e20644e47
@ -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"
|
||||
|
||||
|
@ -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 `.`',
|
||||
},
|
||||
]
|
||||
|
||||
// ============================
|
||||
|
@ -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' },
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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')
|
||||
|
@ -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) => {
|
||||
|
@ -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)
|
||||
logger.log(`Project already installed at '${rootPath}'.`)
|
||||
const id = getProjectId(rootPath)
|
||||
if (id != null) {
|
||||
return { name: getProjectName(rootPath), id }
|
||||
} 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)
|
||||
throw new Error(`Project already installed, but missing metadata.`)
|
||||
}
|
||||
} else {
|
||||
logger.log(`Copying: '${rootPath}' -> '${targetDirectory}'.`)
|
||||
fsSync.cpSync(rootPath, targetDirectory, { recursive: true })
|
||||
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')
|
||||
try {
|
||||
const jsonText = fs.readFileSync(metadataPath, 'utf8')
|
||||
const metadata: unknown = JSON.parse(jsonText)
|
||||
if (isProjectMetadata(metadata)) {
|
||||
return metadata
|
||||
} else {
|
||||
throw new Error('Invalid project metadata')
|
||||
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,14 +247,19 @@ 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)
|
||||
fs.accessSync(packageYamlPath, fs.constants.R_OK)
|
||||
} catch {
|
||||
try {
|
||||
fs.accessSync(projectJsonPath, fs.constants.R_OK)
|
||||
isRoot = true
|
||||
} catch (e) {
|
||||
} 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
|
||||
}
|
||||
|
@ -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. */
|
||||
|
@ -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 ===
|
||||
|
@ -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`
|
||||
),
|
||||
]
|
||||
|
@ -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 ===
|
||||
// ==============================
|
||||
|
@ -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 =>
|
||||
const projectTypeOrder =
|
||||
backendModule.ASSET_TYPE_ORDER[backendModule.AssetType.project]
|
||||
setItems(oldItems => {
|
||||
const ret = array.spliceBefore(
|
||||
array.splicedBefore(
|
||||
oldItems,
|
||||
placeholderItems,
|
||||
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] >= 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`.
|
||||
|
@ -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: {
|
||||
|
@ -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>
|
||||
|
@ -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),
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
@ -50,17 +50,15 @@ 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(
|
||||
@ -82,8 +80,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
type: assetListEventModule.AssetListEventType.delete,
|
||||
id: key,
|
||||
})
|
||||
toastAndLog('Error creating new file', error)
|
||||
}
|
||||
toastAndLog('Could not upload file', error)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
@ -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={() => {
|
||||
|
@ -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: {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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: {
|
||||
|
@ -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]
|
||||
|
@ -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. */
|
||||
|
@ -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. */
|
||||
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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] ?? ''
|
||||
|
@ -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)
|
||||
|
18
app/ide-desktop/lib/types/globals.d.ts
vendored
18
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -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
|
||||
}
|
||||
|
||||
|
1
app/ide-desktop/lib/types/modules.d.ts
vendored
1
app/ide-desktop/lib/types/modules.d.ts
vendored
@ -37,6 +37,7 @@ declare module '*/gui/config.yaml' {
|
||||
windowAppScopeConfigName: string
|
||||
windowAppScopeThemeName: string
|
||||
projectManagerEndpoint: string
|
||||
projectManagerHttpEndpoint: string
|
||||
minimumSupportedVersion: string
|
||||
engineVersionSupported: string
|
||||
languageEditionSupported: string
|
||||
|
Loading…
Reference in New Issue
Block a user