mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 10:42:05 +03:00
Enable require-jsdoc
lint and add two lints related to React (#6403)
- Enables the `require-jsdoc` lint - Fixes all lint errors caused by enabling this lint. # Important Notes - There is no option to require JSDoc for other constructs, like top-level constants.
This commit is contained in:
parent
4f71673718
commit
658395e011
@ -14,6 +14,10 @@ import tsEslint from '@typescript-eslint/eslint-plugin'
|
||||
import tsEslintParser from '@typescript-eslint/parser'
|
||||
/* eslint-enable no-restricted-syntax */
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const DIR_NAME = path.dirname(url.fileURLToPath(import.meta.url))
|
||||
const NAME = 'enso'
|
||||
/** An explicit whitelist of CommonJS modules, which do not support namespace imports.
|
||||
@ -35,7 +39,10 @@ const NOT_PASCAL_CASE = '/^(?!_?([A-Z][a-z0-9]*)+$)/'
|
||||
const NOT_CAMEL_CASE = '/^(?!_?[a-z][a-z0-9*]*([A-Z0-9][a-z0-9]*)*$)/'
|
||||
const WHITELISTED_CONSTANTS = 'logger|.+Context'
|
||||
const NOT_CONSTANT_CASE = `/^(?!${WHITELISTED_CONSTANTS}$|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$)/`
|
||||
const WITH_ROUTER = 'CallExpression[callee.name=withRouter]'
|
||||
|
||||
// =======================================
|
||||
// === Restricted syntactic constructs ===
|
||||
// =======================================
|
||||
|
||||
// Extracted to a variable because it needs to be used twice:
|
||||
// - once as-is for `.d.ts`
|
||||
@ -113,7 +120,7 @@ const RESTRICTED_SYNTAXES = [
|
||||
},
|
||||
{
|
||||
// Matches functions and arrow functions, but not methods.
|
||||
selector: `:matches(FunctionDeclaration[id.name=${NOT_PASCAL_CASE}]:has(${JSX}), VariableDeclarator[id.name=${NOT_PASCAL_CASE}]:has(:matches(ArrowFunctionExpression.init ${JSX}, ${WITH_ROUTER})))`,
|
||||
selector: `:matches(FunctionDeclaration[id.name=${NOT_PASCAL_CASE}]:has(${JSX}), VariableDeclarator[id.name=${NOT_PASCAL_CASE}]:has(:matches(ArrowFunctionExpression.init ${JSX})))`,
|
||||
message: 'Use `PascalCase` for React components',
|
||||
},
|
||||
{
|
||||
@ -123,7 +130,7 @@ const RESTRICTED_SYNTAXES = [
|
||||
},
|
||||
{
|
||||
// Matches non-functions.
|
||||
selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:has(:matches(ArrowFunctionExpression, ${WITH_ROUTER})))`,
|
||||
selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:has(:matches(ArrowFunctionExpression)))`,
|
||||
message: 'Use `CONSTANT_CASE` for top-level constants that are not functions',
|
||||
},
|
||||
{
|
||||
@ -198,12 +205,28 @@ const RESTRICTED_SYNTAXES = [
|
||||
'TSAsExpression:has(TSUnknownKeyword, TSNeverKeyword, TSAnyKeyword) > TSAsExpression',
|
||||
message: 'Use type assertions to specific types instead of `unknown`, `any` or `never`',
|
||||
},
|
||||
{
|
||||
selector: ':matches(MethodDeclaration, FunctionDeclaration) FunctionDeclaration',
|
||||
message: 'Use arrow functions for nested functions',
|
||||
},
|
||||
{
|
||||
selector: ':not(ExportNamedDeclaration) > TSInterfaceDeclaration[id.name=/Props$/]',
|
||||
message: 'All React component `Props` types must be exported',
|
||||
},
|
||||
{
|
||||
selector: 'FunctionDeclaration:has(:matches(ObjectPattern.params, ArrayPattern.params))',
|
||||
message: 'Destructure function parameters in the body instead of in the parameter list',
|
||||
},
|
||||
{
|
||||
selector: 'IfStatement > ExpressionStatement',
|
||||
message: 'Wrap `if` branches in `{}`',
|
||||
},
|
||||
]
|
||||
|
||||
// ============================
|
||||
// === ESLint configuration ===
|
||||
// ============================
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
export default [
|
||||
eslintJs.configs.recommended,
|
||||
@ -230,6 +253,26 @@ export default [
|
||||
...tsEslint.configs['recommended-requiring-type-checking']?.rules,
|
||||
...tsEslint.configs.strict?.rules,
|
||||
eqeqeq: ['error', 'always', { null: 'never' }],
|
||||
'jsdoc/require-jsdoc': [
|
||||
'error',
|
||||
{
|
||||
require: {
|
||||
FunctionDeclaration: true,
|
||||
MethodDefinition: true,
|
||||
ClassDeclaration: true,
|
||||
ArrowFunctionExpression: false,
|
||||
FunctionExpression: true,
|
||||
},
|
||||
// Top-level constants should require JSDoc as well,
|
||||
// however it does not seem like there is a way to do this.
|
||||
contexts: [
|
||||
'TSInterfaceDeclaration',
|
||||
'TSEnumDeclaration',
|
||||
'TSTypeAliasDeclaration',
|
||||
'TSMethodSignature',
|
||||
],
|
||||
},
|
||||
],
|
||||
'sort-imports': ['error', { allowSeparatedGroups: true }],
|
||||
'no-restricted-syntax': ['error', ...RESTRICTED_SYNTAXES],
|
||||
'prefer-arrow-callback': 'error',
|
||||
|
@ -1,9 +1,12 @@
|
||||
/**
|
||||
* @file This script creates a packaged IDE distribution using electron-builder.
|
||||
/** @file This script creates a packaged IDE distribution using electron-builder.
|
||||
* Behaviour details are controlled by the environment variables or CLI arguments.
|
||||
* @see Arguments
|
||||
* @see electronBuilderConfig.Arguments
|
||||
*/
|
||||
|
||||
import * as electronBuilderConfig from './electron-builder-config'
|
||||
|
||||
// ==============================
|
||||
// === Build Electron package ===
|
||||
// ==============================
|
||||
|
||||
await electronBuilderConfig.buildPackage(electronBuilderConfig.args)
|
||||
|
@ -23,10 +23,13 @@ import signArchivesMacOs from './tasks/signArchivesMacOs'
|
||||
|
||||
import BUILD_INFO from '../../build.json' assert { type: 'json' }
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** The parts of the electron-builder configuration that we want to keep configurable.
|
||||
*
|
||||
* @see `args` definition below for fields description.
|
||||
*/
|
||||
* @see `args` definition below for fields description. */
|
||||
export interface Arguments {
|
||||
// This is returned by a third-party library we do not control.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@ -38,7 +41,12 @@ export interface Arguments {
|
||||
platform: electronBuilder.Platform
|
||||
}
|
||||
|
||||
/** CLI argument parser (with support for environment variables) that provides the necessary options. */
|
||||
//======================================
|
||||
// === Argument parser configuration ===
|
||||
//======================================
|
||||
|
||||
/** CLI argument parser (with support for environment variables) that provides
|
||||
* the necessary options. */
|
||||
export const args: Arguments = await yargs(process.argv.slice(2))
|
||||
.env('ENSO_BUILD')
|
||||
.option({
|
||||
@ -79,6 +87,10 @@ export const args: Arguments = await yargs(process.argv.slice(2))
|
||||
},
|
||||
}).argv
|
||||
|
||||
// ======================================
|
||||
// === Electron builder configuration ===
|
||||
// ======================================
|
||||
|
||||
/** Based on the given arguments, creates a configuration for the Electron Builder. */
|
||||
export function createElectronBuilderConfig(passedArgs: Arguments): electronBuilder.Configuration {
|
||||
return {
|
||||
@ -91,8 +103,9 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
|
||||
artifactName: 'enso-${os}-${version}.${ext}',
|
||||
/** Definitions of URL {@link electronBuilder.Protocol} schemes used by the IDE.
|
||||
*
|
||||
* Electron will register all URL protocol schemes defined here with the OS. Once a URL protocol
|
||||
* scheme is registered with the OS, any links using that scheme will function as "deep links".
|
||||
* Electron will register all URL protocol schemes defined here with the OS.
|
||||
* Once a URL protocol scheme is registered with the OS, any links using that scheme
|
||||
* will function as "deep links".
|
||||
* Deep links are used to redirect the user from external sources (e.g., system web browser,
|
||||
* email client) to the IDE.
|
||||
*
|
||||
@ -104,7 +117,7 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
|
||||
* For details on how this works, see:
|
||||
* https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app. */
|
||||
protocols: [
|
||||
/** Electron URL protocol scheme definition for deep links to authentication flow pages. */
|
||||
/** Electron URL protocol scheme definition for deep links to authentication pages. */
|
||||
{
|
||||
name: `${common.PRODUCT_NAME} url`,
|
||||
schemes: [common.DEEP_LINK_SCHEME],
|
||||
@ -112,7 +125,8 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
|
||||
},
|
||||
],
|
||||
mac: {
|
||||
// We do not use compression as the build time is huge and file size saving is almost zero.
|
||||
// Compression is not used as the build time is huge and file size saving
|
||||
// almost zero.
|
||||
// This type assertion is UNSAFE, and any users MUST verify that
|
||||
// they are passing a valid value to `target`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@ -127,18 +141,20 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
|
||||
// This is a custom check that is not working correctly, so we disable it. See for more
|
||||
// details https://kilianvalkhof.com/2019/electron/notarizing-your-electron-application/
|
||||
gatekeeperAssess: false,
|
||||
// Location of the entitlements files with the entitlements we need to run our application
|
||||
// in the hardened runtime.
|
||||
// Location of the entitlements files with the entitlements we need to run
|
||||
// our application in the hardened runtime.
|
||||
entitlements: './entitlements.mac.plist',
|
||||
entitlementsInherit: './entitlements.mac.plist',
|
||||
},
|
||||
win: {
|
||||
// We do not use compression as the build time is huge and file size saving is almost zero.
|
||||
// Compression is not used as the build time is huge and file size saving
|
||||
// almost zero.
|
||||
target: passedArgs.target ?? 'nsis',
|
||||
icon: `${passedArgs.iconsDist}/icon.ico`,
|
||||
},
|
||||
linux: {
|
||||
// We do not use compression as the build time is huge and file size saving is almost zero.
|
||||
// Compression is not used as the build time is huge and file size saving
|
||||
// is almost zero.
|
||||
target: passedArgs.target ?? 'AppImage',
|
||||
icon: `${passedArgs.iconsDist}/png`,
|
||||
category: 'Development',
|
||||
@ -231,12 +247,14 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
|
||||
})
|
||||
|
||||
console.log(' • Notarizing.')
|
||||
// The type-cast is safe because this is only executes
|
||||
// when `platform === electronBuilder.Platform.MAC`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const macBuildOptions = buildOptions as macOptions.MacConfiguration
|
||||
await electronNotarize.notarize({
|
||||
// This will always be defined since we set it at the top of this object.
|
||||
// The type-cast is safe because this is only executes
|
||||
// when `platform === electronBuilder.Platform.MAC`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, no-restricted-syntax
|
||||
appBundleId: (buildOptions as macOptions.MacConfiguration).appId!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
appBundleId: macBuildOptions.appId!,
|
||||
appPath: `${appOutDir}/${appName}.app`,
|
||||
appleId: process.env.APPLEID,
|
||||
appleIdPassword: process.env.APPLEIDPASS,
|
||||
@ -250,12 +268,13 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
|
||||
|
||||
/** Build the IDE package with Electron Builder. */
|
||||
export async function buildPackage(passedArgs: Arguments) {
|
||||
// `electron-builder` checks for presence of `node_modules` directory. If it is not present, it will
|
||||
// install dependencies with `--production` flag (erasing all dev-only dependencies). This does not
|
||||
// work sensibly with NPM workspaces. We have our `node_modules` in the root directory, not here.
|
||||
// `electron-builder` checks for presence of `node_modules` directory. If it is not present, it
|
||||
// will install dependencies with the`--production` flag(erasing all dev - only dependencies).
|
||||
// This does not work sensibly with NPM workspaces. We have our `node_modules` in
|
||||
// the root directory, not here.
|
||||
//
|
||||
// Without this workaround, `electron-builder` will end up erasing its own dependencies and failing
|
||||
// because of that.
|
||||
// Without this workaround, `electron-builder` will end up erasing its own dependencies and
|
||||
// failing because of that.
|
||||
await fs.mkdir('node_modules', { recursive: true })
|
||||
|
||||
const cliOpts: electronBuilder.CliOptions = {
|
||||
@ -266,7 +285,7 @@ export async function buildPackage(passedArgs: Arguments) {
|
||||
const result = await electronBuilder.build(cliOpts)
|
||||
console.log('Electron Builder is done. Result:', result)
|
||||
// FIXME: https://github.com/enso-org/enso/issues/6082
|
||||
// This is workaround which fixes esbuild freezing after successfully finishing the electronBuilder.build.
|
||||
// It's safe to exit(0) since all processes are finished.
|
||||
// This is a workaround which fixes esbuild hanging after successfully finishing
|
||||
// `electronBuilder.build`. It is safe to `exit(0)` since all processes are finished.
|
||||
process.exit(0)
|
||||
}
|
||||
|
@ -9,13 +9,14 @@ import * as paths from './paths'
|
||||
// === Bundling ===
|
||||
// ================
|
||||
|
||||
/**
|
||||
* Get the bundler options using the environment.
|
||||
/** Get the bundler options using the environment.
|
||||
*
|
||||
* The following environment variables are required:
|
||||
* - `ENSO_BUILD_IDE` - output directory for bundled client files;
|
||||
* - `ENSO_BUILD_PROJECT_MANAGER_IN_BUNDLE_PATH` - path to the project manager executable relative to the PM bundle root;
|
||||
* - `ENSO_BUILD_IDE_BUNDLED_ENGINE_VERSION` - version of the Engine (backend) that is bundled along with this client build.
|
||||
* - `ENSO_BUILD_PROJECT_MANAGER_IN_BUNDLE_PATH` - path to the project manager executable relative
|
||||
* to the PM bundle root;
|
||||
* - `ENSO_BUILD_IDE_BUNDLED_ENGINE_VERSION` - version of the Engine (backend) that is bundled
|
||||
* along with this client build.
|
||||
*
|
||||
* @see bundlerOptions
|
||||
*/
|
||||
|
@ -1,5 +1,9 @@
|
||||
/** @file File associations for client application. */
|
||||
|
||||
// =========================
|
||||
// === File associations ===
|
||||
// =========================
|
||||
|
||||
/** The extension for the source file, without the leading period character. */
|
||||
export const SOURCE_FILE_EXTENSION = 'enso'
|
||||
|
||||
|
@ -30,7 +30,7 @@
|
||||
"yargs": "17.6.2"
|
||||
},
|
||||
"comments": {
|
||||
"electron-builder": "We cannot update it to never version because of NSIS installer issue: https://github.com/enso-org/enso/issues/5169"
|
||||
"electron-builder": "Cannot be updated to a newer version because of a NSIS installer issue: https://github.com/enso-org/enso/issues/5169"
|
||||
},
|
||||
"devDependencies": {
|
||||
"crypto-js": "4.1.1",
|
||||
|
@ -2,7 +2,12 @@
|
||||
|
||||
import * as utils from '../../utils'
|
||||
|
||||
/** Path to the Project Manager bundle within the electron distribution (relative to the electron's resources directory). */
|
||||
// ==========================
|
||||
// === Paths to resources ===
|
||||
// ==========================
|
||||
|
||||
/** Path to the Project Manager bundle within the electron distribution
|
||||
* (relative to electron's resources directory). */
|
||||
export const PROJECT_MANAGER_BUNDLE = 'enso'
|
||||
|
||||
/** Distribution directory for IDE. */
|
||||
|
@ -91,7 +91,7 @@ const logger = contentConfig.logger
|
||||
// === Initialize Authentication Module ===
|
||||
// ========================================
|
||||
|
||||
/** Configures all the functionality that must be set up in the Electron app to support
|
||||
/** Configure all the functionality that must be set up in the Electron app to support
|
||||
* authentication-related flows. Must be called in the Electron app `whenReady` event.
|
||||
*
|
||||
* @param window - A function that returns the main Electron window. This argument is a lambda and
|
||||
@ -104,7 +104,7 @@ export function initModule(window: () => electron.BrowserWindow) {
|
||||
initSaveAccessTokenListener()
|
||||
}
|
||||
|
||||
/** Registers an Inter-Process Communication (IPC) channel between the Electron application and the
|
||||
/** Register an Inter-Process Communication (IPC) channel between the Electron application and the
|
||||
* served website.
|
||||
*
|
||||
* This channel listens for {@link ipc.Channel.openUrlInSystemBrowser} events. When this kind of
|
||||
@ -122,7 +122,7 @@ function initIpc() {
|
||||
})
|
||||
}
|
||||
|
||||
/** Registers a listener that fires a callback for `open-url` events, when the URL is a deep link.
|
||||
/** Register a listener that fires a callback for `open-url` events, when the URL is a deep link.
|
||||
*
|
||||
* This listener is used to open a page in *this* application window, when the user is
|
||||
* redirected to a URL with a protocol supported by this application.
|
||||
@ -135,13 +135,11 @@ function initOpenUrlListener(window: () => electron.BrowserWindow) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the 'open-url' event by parsing the received URL, checking if it is a deep link, and
|
||||
/** Handle the 'open-url' event by parsing the received URL, checking if it is a deep link, and
|
||||
* sending it to the appropriate BrowserWindow via IPC.
|
||||
*
|
||||
* @param url - The URL to handle.
|
||||
* @param window - A function that returns the BrowserWindow to send the parsed URL to.
|
||||
*/
|
||||
* @param window - A function that returns the BrowserWindow to send the parsed URL to. */
|
||||
export function onOpenUrl(url: URL, window: () => electron.BrowserWindow) {
|
||||
logger.log(`Received 'open-url' event for '${url.toString()}'.`)
|
||||
if (url.protocol !== `${common.DEEP_LINK_SCHEME}:`) {
|
||||
@ -152,30 +150,32 @@ export function onOpenUrl(url: URL, window: () => electron.BrowserWindow) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers a listener that fires a callback for `save-access-token` events.
|
||||
/** Register a listener that fires a callback for `save-access-token` events.
|
||||
*
|
||||
* This listener is used to save given access token to credentials file to be later used by enso backend.
|
||||
* This listener is used to save given access token to credentials file to be later used by
|
||||
* the backend.
|
||||
*
|
||||
* Credentials file is placed in users home directory in `.enso` subdirectory in `credentials` file. */
|
||||
* The credentials file is placed in the user's home directory in the `.enso` subdirectory
|
||||
* in the `credentials` file. */
|
||||
function initSaveAccessTokenListener() {
|
||||
electron.ipcMain.on(ipc.Channel.saveAccessToken, (event, accessToken: string) => {
|
||||
/** Enso home directory for credentials file. */
|
||||
const ensoCredentialsDirectoryName = '.enso'
|
||||
/** Enso credentials file. */
|
||||
const ensoCredentialsFileName = 'credentials'
|
||||
/** Home directory for the credentials file. */
|
||||
const credentialsDirectoryName = `.${common.PRODUCT_NAME.toLowerCase()}`
|
||||
/** File name of the credentials file. */
|
||||
const credentialsFileName = 'credentials'
|
||||
/** System agnostic credentials directory home path. */
|
||||
const ensoCredentialsHomePath = path.join(os.homedir(), ensoCredentialsDirectoryName)
|
||||
const credentialsHomePath = path.join(os.homedir(), credentialsDirectoryName)
|
||||
|
||||
fs.mkdir(ensoCredentialsHomePath, { recursive: true }, error => {
|
||||
fs.mkdir(credentialsHomePath, { recursive: true }, error => {
|
||||
if (error) {
|
||||
logger.error(`Couldn't create ${ensoCredentialsDirectoryName} directory.`)
|
||||
logger.error(`Couldn't create ${credentialsDirectoryName} directory.`)
|
||||
} else {
|
||||
fs.writeFile(
|
||||
path.join(ensoCredentialsHomePath, ensoCredentialsFileName),
|
||||
path.join(credentialsHomePath, credentialsFileName),
|
||||
accessToken,
|
||||
innerError => {
|
||||
if (innerError) {
|
||||
logger.error(`Could not write to ${ensoCredentialsFileName} file.`)
|
||||
logger.error(`Could not write to ${credentialsFileName} file.`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -29,17 +29,17 @@ export function pathOrPanic(args: config.Args): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Executes the Project Manager with given arguments. */
|
||||
/** Execute the Project Manager with given arguments. */
|
||||
async function exec(args: config.Args, processArgs: string[]) {
|
||||
const binPath = pathOrPanic(args)
|
||||
return await execFile(binPath, processArgs)
|
||||
}
|
||||
|
||||
/** Spawn Project Manager process.
|
||||
/** Spawn the Project Manager process.
|
||||
*
|
||||
* The standard output and error handles will be redirected to the electron's app output and error
|
||||
* handles. Input is piped to this process, so it will not be closed, until this process
|
||||
* finished. */
|
||||
* The standard output and error handles will be redirected to the output and error handles of the
|
||||
* Electron app. Input is piped to this process, so it will not be closed until this process
|
||||
* finishes. */
|
||||
export function spawn(args: config.Args, processArgs: string[]): childProcess.ChildProcess {
|
||||
return logger.groupMeasured(
|
||||
`Starting the backend process with the following options: ${processArgs.join(', ')}.`,
|
||||
@ -47,8 +47,8 @@ export function spawn(args: config.Args, processArgs: string[]): childProcess.Ch
|
||||
const binPath = pathOrPanic(args)
|
||||
const process = childProcess.spawn(binPath, processArgs, {
|
||||
stdio: [/* stdin */ 'pipe', /* stdout */ 'inherit', /* stderr */ 'inherit'],
|
||||
// The Project Manager should never spawn any windows. On Windows OS this needs to be
|
||||
// manually prevented, as the default is to spawn a console window.
|
||||
// The Project Manager should never spawn any windows. On Windows OS this needs
|
||||
// to be manually prevented, as the default is to spawn a console window.
|
||||
windowsHide: true,
|
||||
})
|
||||
logger.log(`Backend has been spawned (pid = ${String(process.pid)}).`)
|
||||
|
@ -35,6 +35,7 @@ interface ConfigConfig {
|
||||
export class Config {
|
||||
dir: string
|
||||
port: number
|
||||
/** Create a server configuration. */
|
||||
constructor(cfg: ConfigConfig) {
|
||||
this.dir = path.resolve(cfg.dir)
|
||||
this.port = cfg.port
|
||||
@ -45,8 +46,8 @@ export class Config {
|
||||
// === Port Finder ===
|
||||
// ===================
|
||||
|
||||
/** Determines the initial available communication endpoint, starting from the specified port, to
|
||||
* provide file hosting services. */
|
||||
/** Determine the initial available communication endpoint, starting from the specified port,
|
||||
* to provide file hosting services. */
|
||||
async function findPort(port: number): Promise<number> {
|
||||
return await portfinder.getPortPromise({ port, startPort: port })
|
||||
}
|
||||
@ -55,12 +56,13 @@ async function findPort(port: number): Promise<number> {
|
||||
// === Server ===
|
||||
// ==============
|
||||
|
||||
/// A simple server implementation.
|
||||
///
|
||||
/// Initially it was based on `union`, but later we migrated to `create-servers`. Read this topic to
|
||||
/// learn why: https://github.com/http-party/http-server/issues/483
|
||||
/** A simple server implementation.
|
||||
*
|
||||
* Initially it was based on `union`, but later we migrated to `create-servers`.
|
||||
* Read this topic to learn why: https://github.com/http-party/http-server/issues/483 */
|
||||
export class Server {
|
||||
server: unknown
|
||||
/** Create a simple HTTP server. */
|
||||
constructor(public config: Config) {}
|
||||
|
||||
/** Server constructor. */
|
||||
@ -72,6 +74,7 @@ export class Server {
|
||||
return server
|
||||
}
|
||||
|
||||
/** Start the server. */
|
||||
run(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = createServer(
|
||||
@ -92,6 +95,7 @@ export class Server {
|
||||
})
|
||||
}
|
||||
|
||||
/** Respond to an incoming request. */
|
||||
process(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||
const requestUrl = request.url
|
||||
if (requestUrl == null) {
|
||||
|
@ -25,7 +25,7 @@ const DEFAULT_PORT = 8080
|
||||
/** Window size (width and height). */
|
||||
export class WindowSize {
|
||||
static separator = 'x'
|
||||
/** Constructs a new {@link WindowSize}. */
|
||||
/** Create a new {@link WindowSize}. */
|
||||
constructor(public width: number, public height: number) {}
|
||||
|
||||
/** Constructor of the default window size. */
|
||||
@ -33,7 +33,7 @@ export class WindowSize {
|
||||
return new WindowSize(DEFAULT_WIDTH, DEFAULT_HEIGHT)
|
||||
}
|
||||
|
||||
/** Parses the input text in form of `<width>x<height>`. */
|
||||
/** Parse the input text in form of `<width>x<height>`. */
|
||||
static parse(arg: string): Error | WindowSize {
|
||||
const size = arg.split(WindowSize.separator)
|
||||
const widthStr = size[0]
|
||||
@ -47,7 +47,7 @@ export class WindowSize {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns window size in a form of `<width>x<height>`. */
|
||||
/** Return window size in a form of `<width>x<height>`. */
|
||||
pretty(): string {
|
||||
return `${this.width}${WindowSize.separator}${this.height}`
|
||||
}
|
||||
@ -102,7 +102,10 @@ export const CONFIG = contentConfig.OPTIONS.merge(
|
||||
passToWebApplication: false,
|
||||
primary: false,
|
||||
value: '',
|
||||
description: `Instructs Node.js to listen for a debugging client on the given port.`,
|
||||
description:
|
||||
// This empty string is required for the formatter to keep the line
|
||||
// under 100 columns.
|
||||
`` + `Instructs Node.js to listen for a debugging client on the given port.`,
|
||||
}),
|
||||
},
|
||||
groups: {
|
||||
@ -186,7 +189,8 @@ export const CONFIG = contentConfig.OPTIONS.merge(
|
||||
`has the potential to significantly enhance the performance of the ` +
|
||||
`application in our specific use cases. This behavior can be ` +
|
||||
`observed in the following example: ` +
|
||||
`https://groups.google.com/a/chromium.org/g/chromium-dev/c/09NnO6jYT6o.`,
|
||||
`https://groups.google.com/a/chromium.org/g/chromium-dev/c/` +
|
||||
`09NnO6jYT6o.`,
|
||||
}),
|
||||
disableSandbox: new contentConfig.Option({
|
||||
passToWebApplication: false,
|
||||
@ -249,7 +253,10 @@ export const CONFIG = contentConfig.OPTIONS.merge(
|
||||
passToWebApplication: false,
|
||||
primary: false,
|
||||
value: true,
|
||||
description: `Enable native CPU-mappable GPU memory buffer support on Linux.`,
|
||||
description:
|
||||
// This empty string is required for the formatter to keep the line
|
||||
// under 100 columns.
|
||||
`` + `Enable native CPU-mappable GPU memory buffer support on Linux.`,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
@ -428,7 +435,10 @@ export const CONFIG = contentConfig.OPTIONS.merge(
|
||||
passToWebApplication: false,
|
||||
primary: false,
|
||||
value: '',
|
||||
description: `Ignore the connections limit for domains list separated by ','.`,
|
||||
description:
|
||||
// This empty string is required for the formatter to keep the line
|
||||
// under 100 columns.
|
||||
`` + `Ignore the connections limit for domains list separated by ','.`,
|
||||
}),
|
||||
jsFlags: new contentConfig.Option({
|
||||
passToWebApplication: false,
|
||||
@ -457,7 +467,8 @@ export const CONFIG = contentConfig.OPTIONS.merge(
|
||||
primary: false,
|
||||
value: '',
|
||||
description:
|
||||
'Enable net log events to be saved and writes them to the provided path.',
|
||||
`Enable net log events to be saved and writes them to the provided ` +
|
||||
`path.`,
|
||||
}),
|
||||
logLevel: new contentConfig.Option({
|
||||
passToWebApplication: false,
|
||||
@ -482,7 +493,8 @@ export const CONFIG = contentConfig.OPTIONS.merge(
|
||||
value: false,
|
||||
description:
|
||||
`Disable the Chrome sandbox. Forces renderer process and Chrome ` +
|
||||
`helper processes to run un-sandboxed. Should only be used for testing.`,
|
||||
`helper processes to run un-sandboxed. Should only be used ` +
|
||||
`for testing.`,
|
||||
}),
|
||||
proxyBypassList: new contentConfig.Option({
|
||||
passToWebApplication: false,
|
||||
@ -492,7 +504,8 @@ export const CONFIG = contentConfig.OPTIONS.merge(
|
||||
`Instruct Electron to bypass the proxy server for the given ` +
|
||||
`semi-colon-separated list of hosts. This flag has an effect only if ` +
|
||||
`used in tandem with '-chrome.proxy-server'. For example, ` +
|
||||
`'-chrome.proxy-bypass-list "<local>;*.google.com;*foo.com;1.2.3.4:5678"'.`,
|
||||
`'-chrome.proxy-bypass-list "<local>;*.google.com;*foo.com;` +
|
||||
`1.2.3.4:5678"'.`,
|
||||
}),
|
||||
proxyPacUrl: new contentConfig.Option({
|
||||
passToWebApplication: false,
|
||||
@ -511,7 +524,8 @@ export const CONFIG = contentConfig.OPTIONS.merge(
|
||||
`noteworthy that not all proxy servers support HTTPS and WebSocket ` +
|
||||
`requests. The proxy URL does not support username and password ` +
|
||||
`authentication per ` +
|
||||
`[Chrome issue](https://bugs.chromium.org/p/chromium/issues/detail?id=615947).`,
|
||||
`[Chrome issue](https://bugs.chromium.org/p/chromium/issues/detail` +
|
||||
`?id=615947).`,
|
||||
}),
|
||||
remoteDebuggingPort: new contentConfig.Option({
|
||||
passToWebApplication: false,
|
||||
@ -569,8 +583,9 @@ export const CONFIG = contentConfig.OPTIONS.merge(
|
||||
`A list of Blink (Chrome's rendering engine) features separated ` +
|
||||
`by ',' like 'CSSVariables,KeyboardEventKey' to enable. The full ` +
|
||||
`list of supported feature strings can be found in the ` +
|
||||
`[RuntimeEnabledFeatures.json5](https://cs.chromium.org/chromium/src/third_party/blink/renderer/platform/runtime_enabled_features.json5?l=70) ` +
|
||||
`file.`,
|
||||
`[RuntimeEnabledFeatures.json5](https://cs.chromium.org/chromium/src/` +
|
||||
`third_party/blink/renderer/platform/runtime_enabled_features.json5` +
|
||||
`?l=70) file.`,
|
||||
}),
|
||||
|
||||
disableBlinkFeatures: new contentConfig.Option({
|
||||
@ -581,8 +596,9 @@ export const CONFIG = contentConfig.OPTIONS.merge(
|
||||
`A list of Blink (Chrome's rendering engine) features separated ` +
|
||||
`by ',' like 'CSSVariables,KeyboardEventKey' to disable. The full ` +
|
||||
`list of supported feature strings can be found in the ` +
|
||||
`[RuntimeEnabledFeatures.json5](https://cs.chromium.org/chromium/src/third_party/blink/renderer/platform/runtime_enabled_features.json5?l=70) ` +
|
||||
`file.`,
|
||||
`[RuntimeEnabledFeatures.json5](https://cs.chromium.org/chromium/src/` +
|
||||
`third_party/blink/renderer/platform/runtime_enabled_features.json5` +
|
||||
`?l=70) file.`,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
@ -593,5 +609,7 @@ CONFIG.groups.startup.options.platform.value = process.platform
|
||||
|
||||
CONFIG.groups.engine.options.preferredVersion.value = BUNDLED_ENGINE_VERSION
|
||||
|
||||
/** The type of the full configuration object. */
|
||||
export type Args = typeof CONFIG
|
||||
/** A configuration option. */
|
||||
export type Option<T> = contentConfig.Option<T>
|
||||
|
@ -38,11 +38,14 @@ const USAGE =
|
||||
`the application from a web-browser, the creation of a window can be suppressed by ` +
|
||||
`entering either '-window=false' or '-no-window'.`
|
||||
|
||||
/** Contains information for a category of command line options and the options
|
||||
* it is comprised of. */
|
||||
class Section<T> {
|
||||
description = ''
|
||||
entries: (readonly [cmdOption: string, option: config.Option<T>])[] = []
|
||||
}
|
||||
|
||||
/** Configuration options controlling how the help information is displayed. */
|
||||
interface PrintHelpConfig {
|
||||
args: config.Args
|
||||
groupsOrdering: string[]
|
||||
@ -58,8 +61,7 @@ interface PrintHelpConfig {
|
||||
* 3. Every option has a `[type`] annotation and there is no API to disable it.
|
||||
* 4. There is no option to print commands with single dash instead of double-dash.
|
||||
* 5. Help coloring is not supported, and they do not want to support it:
|
||||
* https://github.com/yargs/yargs/issues/251.
|
||||
*/
|
||||
* https://github.com/yargs/yargs/issues/251. */
|
||||
function printHelp(cfg: PrintHelpConfig) {
|
||||
console.log(USAGE)
|
||||
const totalWidth = logger.terminalWidth() ?? DEFAULT_TERMINAL_WIDTH
|
||||
@ -145,7 +147,7 @@ function printHelp(cfg: PrintHelpConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps the text to a specific output width. If a word is longer than the output width, it will be
|
||||
/** Wrap the text to a specific output width. If a word is longer than the output width, it will be
|
||||
* split. */
|
||||
function wordWrap(str: string, width: number): string[] {
|
||||
if (width <= 0) {
|
||||
@ -201,16 +203,19 @@ function wordWrap(str: string, width: number): string[] {
|
||||
// === Chrome Options ===
|
||||
// ======================
|
||||
|
||||
/** Represents a command line option to be passed to the Chrome instance powering Electron. */
|
||||
export class ChromeOption {
|
||||
/** Create a {@link ChromeOption}. */
|
||||
constructor(public name: string, public value?: string) {}
|
||||
|
||||
/** Return the option as it would appear on the command line. */
|
||||
display(): string {
|
||||
const value = this.value == null ? '' : `=${this.value}`
|
||||
return `--${this.name}${value}`
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces `-no-...` with `--no-...`. This is a hotfix for Yargs bug:
|
||||
/** Replace `-no-...` with `--no-...`. This is a hotfix for a Yargs bug:
|
||||
* https://github.com/yargs/yargs-parser/issues/468. */
|
||||
function fixArgvNoPrefix(argv: string[]): string[] {
|
||||
const singleDashPrefix = '-no-'
|
||||
@ -224,6 +229,7 @@ function fixArgvNoPrefix(argv: string[]): string[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** Command line options, split into regular arguments and Chrome options. */
|
||||
interface ArgvAndChromeOptions {
|
||||
argv: string[]
|
||||
chromeOptions: ChromeOption[]
|
||||
@ -265,7 +271,7 @@ function argvAndChromeOptions(processArgs: string[]): ArgvAndChromeOptions {
|
||||
// === Option Parser ===
|
||||
// =====================
|
||||
|
||||
/** Parses command line arguments. */
|
||||
/** Parse command line arguments. */
|
||||
export function parseArgs(clientArgs: string[] = fileAssociations.CLIENT_ARGUMENTS) {
|
||||
const args = config.CONFIG
|
||||
const { argv, chromeOptions } = argvAndChromeOptions(fixArgvNoPrefix(clientArgs))
|
||||
@ -306,6 +312,7 @@ export function parseArgs(clientArgs: string[] = fileAssociations.CLIENT_ARGUMEN
|
||||
|
||||
// === Parsing ===
|
||||
|
||||
/** Command line arguments after being parsed by `yargs`. */
|
||||
interface YargsArgs {
|
||||
// We don't control the naming of this third-party API.
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
@ -35,16 +35,14 @@ export const SOURCE_FILE_SUFFIX = fileAssociations.SOURCE_FILE_SUFFIX
|
||||
// === Arguments Handling ===
|
||||
// ==========================
|
||||
|
||||
/**
|
||||
* Check if the given list of application startup arguments denotes an attempt to open a file.
|
||||
/** Check if the given list of application startup arguments denotes an attempt to open a file.
|
||||
*
|
||||
* For example, this happens when the user double-clicks on a file in the file explorer and the
|
||||
* application is launched with the file path as an argument.
|
||||
*
|
||||
* @param clientArgs - A list of arguments passed to the application, stripped from the initial
|
||||
* executable name and any electron dev mode arguments.
|
||||
* @returns The path to the file to open, or `null` if no file was specified.
|
||||
*/
|
||||
* @returns The path to the file to open, or `null` if no file was specified. */
|
||||
export function argsDenoteFileOpenAttempt(clientArgs: string[]): string | null {
|
||||
const arg = clientArgs[0]
|
||||
let result: string | null = null
|
||||
@ -89,7 +87,7 @@ function getClientArguments(): string[] {
|
||||
// === File Associations ===
|
||||
// =========================
|
||||
|
||||
/* Check if the given path looks like a file that we can open. */
|
||||
/** Check if the given path looks like a file that we can open. */
|
||||
export function isFileOpenable(path: string): boolean {
|
||||
const extension = pathModule.extname(path).toLowerCase()
|
||||
return (
|
||||
@ -98,13 +96,12 @@ export function isFileOpenable(path: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** On macOS when Enso-associated file is opened, the application is first started and then it
|
||||
/** On macOS when an Enso-associated file is opened, the application is first started and then it
|
||||
* receives the `open-file` event. However, if there is already an instance of Enso running,
|
||||
* it receives the `open-file` event (and no new instance is created for us). In this case,
|
||||
* we manually start a new instance of the application and pass the file path to it (using the
|
||||
* Windows-style command).
|
||||
*/
|
||||
export function onFileOpened(event: Event, path: string): string | void {
|
||||
* Windows-style command). */
|
||||
export function onFileOpened(event: Event, path: string): string | null {
|
||||
logger.log(`Received 'open-file' event for path '${path}'.`)
|
||||
if (isFileOpenable(path)) {
|
||||
logger.log(`The file '${path}' is openable.`)
|
||||
@ -117,9 +114,12 @@ export function onFileOpened(event: Event, path: string): string | void {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return handleOpenFile(path)
|
||||
} else {
|
||||
// We need to start another copy of the application, as the first one is already running.
|
||||
// Another copy of the application needs to be started, as the first one is
|
||||
// already running.
|
||||
logger.log(
|
||||
`The application is already initialized. Starting a new instance to open file '${path}'.`
|
||||
"The application is already initialized. Starting a new instance to open file '" +
|
||||
path +
|
||||
"'."
|
||||
)
|
||||
const args = [path]
|
||||
const child = childProcess.spawn(process.execPath, args, {
|
||||
@ -128,17 +128,18 @@ export function onFileOpened(event: Event, path: string): string | void {
|
||||
})
|
||||
// Prevent parent (this) process from waiting for the child to exit.
|
||||
child.unref()
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
logger.log(`The file '${path}' is not openable, ignoring the 'open-file' event.`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Set up the `open-file` event handler that might import a project and invoke the given callback,
|
||||
* if this IDE instance should load the project. See {@link onFileOpened} for more details.
|
||||
*
|
||||
* @param setProjectToOpen - A function that will be called with the ID of the project to open.
|
||||
*/
|
||||
* @param setProjectToOpen - A function that will be called with the ID of the project to open. */
|
||||
export function setOpenFileEventHandler(setProjectToOpen: (id: string) => void) {
|
||||
electron.app.on('open-file', (event, path) => {
|
||||
const projectId = onFileOpened(event, path)
|
||||
@ -150,11 +151,12 @@ export function setOpenFileEventHandler(setProjectToOpen: (id: string) => void)
|
||||
|
||||
/** Handle the case where IDE is invoked with a file to open.
|
||||
*
|
||||
* Imports project if necessary. Returns the ID of the project to open. In case of an error, displays an error message and rethrows the error.
|
||||
* Imports project if necessary. Returns the ID of the project to open. In case of an error,
|
||||
* the error message is displayed and the error is re-thrown.
|
||||
*
|
||||
* @param openedFile - The path to the file to open.
|
||||
* @returns The ID of the project to open.
|
||||
* @throws An `Error`, if the project from the file cannot be opened or imported. */
|
||||
* @throws {Error} if the project from the file cannot be opened or imported. */
|
||||
export function handleOpenFile(openedFile: string): string {
|
||||
try {
|
||||
return project.importProjectFromPath(openedFile)
|
||||
@ -178,8 +180,7 @@ export function handleOpenFile(openedFile: string): string {
|
||||
*
|
||||
* Handles all errors internally.
|
||||
* @param openedFile - The file to open (null if none).
|
||||
* @param args - The parsed application arguments.
|
||||
*/
|
||||
* @param args - The parsed application arguments. */
|
||||
export function handleFileArguments(openedFile: string | null, args: clientConfig.Args): void {
|
||||
if (openedFile != null) {
|
||||
try {
|
||||
|
@ -45,6 +45,7 @@ class App {
|
||||
args: config.Args = config.CONFIG
|
||||
isQuitting = false
|
||||
|
||||
/** Initialize and run the Electron application. */
|
||||
async run() {
|
||||
log.addFileLog()
|
||||
urlAssociations.registerAssociations()
|
||||
@ -81,6 +82,7 @@ class App {
|
||||
}
|
||||
}
|
||||
|
||||
/** Process the command line arguments. */
|
||||
processArguments() {
|
||||
// We parse only "client arguments", so we don't have to worry about the Electron-Dev vs
|
||||
// Electron-Proper distinction.
|
||||
@ -97,25 +99,24 @@ class App {
|
||||
return { ...configParser.parseArgs(argsToParse), fileToOpen, urlToOpen }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the project to be opened on application startup.
|
||||
/** Set the project to be opened on application startup.
|
||||
*
|
||||
* This method should be called before the application is ready, as it only
|
||||
* modifies the startup options. If the application is already initialized,
|
||||
* an error will be logged, and the method will have no effect.
|
||||
*
|
||||
* @param idOfProjectToOpen - The ID of the project to be opened on startup.
|
||||
*/
|
||||
setProjectToOpenOnStartup(idOfProjectToOpen: string) {
|
||||
* @param projectId - The ID of the project to be opened on startup. */
|
||||
setProjectToOpenOnStartup(projectId: string) {
|
||||
// Make sure that we are not initialized yet, as this method should be called before the
|
||||
// application is ready.
|
||||
if (!electron.app.isReady()) {
|
||||
logger.log(`Setting project to open on startup: ${idOfProjectToOpen}.`)
|
||||
this.args.groups.startup.options.project.value = idOfProjectToOpen
|
||||
logger.log(`Setting the project to open on startup to '${projectId}'.`)
|
||||
this.args.groups.startup.options.project.value = projectId
|
||||
} else {
|
||||
logger.error(
|
||||
`Cannot set project to open on startup: ${idOfProjectToOpen},` +
|
||||
` as the application is already initialized.`
|
||||
"Cannot set the project to open on startup to '" +
|
||||
projectId +
|
||||
"', as the application is already initialized."
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -126,9 +127,9 @@ class App {
|
||||
logger.log('Opening file or URL.', { fileToOpen, urlToOpen })
|
||||
try {
|
||||
if (fileToOpen != null) {
|
||||
// This makes the IDE open the relevant project. Also, this prevents us from using this
|
||||
// method after IDE has been fully set up, as the initializing code would have already
|
||||
// read the value of this argument.
|
||||
// This makes the IDE open the relevant project. Also, this prevents us from using
|
||||
// this method after the IDE has been fully set up, as the initializing code
|
||||
// would have already read the value of this argument.
|
||||
const projectId = fileAssociations.handleOpenFile(fileToOpen)
|
||||
this.setProjectToOpenOnStartup(projectId)
|
||||
}
|
||||
@ -158,8 +159,9 @@ class App {
|
||||
chromeOptions.push(chromeOption)
|
||||
}
|
||||
}
|
||||
const add = (option: string, value?: string) =>
|
||||
const add = (option: string, value?: string) => {
|
||||
chromeOptions.push(new configParser.ChromeOption(option, value))
|
||||
}
|
||||
logger.groupMeasured('Setting Chrome options', () => {
|
||||
const perfOpts = this.args.groups.performance.options
|
||||
addIf(perfOpts.disableGpuSandbox, 'disable-gpu-sandbox')
|
||||
@ -359,7 +361,8 @@ class App {
|
||||
}
|
||||
}
|
||||
|
||||
printVersion(): Promise<void> {
|
||||
/** Print the version of the frontend and the backend. */
|
||||
async printVersion(): Promise<void> {
|
||||
const indent = ' '.repeat(utils.INDENT_SIZE)
|
||||
let maxNameLen = 0
|
||||
for (const name in debug.VERSION_INFO) {
|
||||
@ -371,22 +374,20 @@ class App {
|
||||
const spacing = ' '.repeat(maxNameLen - name.length)
|
||||
console.log(`${indent}${label}:${spacing} ${value}`)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
|
||||
console.log('Backend:')
|
||||
return projectManager.version(this.args).then(backend => {
|
||||
if (!backend) {
|
||||
console.log(`${indent}No backend available.`)
|
||||
} else {
|
||||
const lines = backend.split(/\r?\n/).filter(line => line.length > 0)
|
||||
for (const line of lines) {
|
||||
console.log(`${indent}${line}`)
|
||||
}
|
||||
const backend = await projectManager.version(this.args)
|
||||
if (!backend) {
|
||||
console.log(`${indent}No backend available.`)
|
||||
} else {
|
||||
const lines = backend.split(/\r?\n/).filter(line => line.length > 0)
|
||||
for (const line of lines) {
|
||||
console.log(`${indent}${line}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Register keyboard shortcuts. */
|
||||
registerShortcuts() {
|
||||
electron.app.on('web-contents-created', (_webContentsCreatedEvent, webContents) => {
|
||||
webContents.on('before-input-event', (_beforeInputEvent, input) => {
|
||||
|
@ -1,8 +1,10 @@
|
||||
/** @file Logging utilities.
|
||||
*
|
||||
* This module includes a special {@link addFileLog function} that adds a new log consumer that writes to a file.
|
||||
* This module includes a special {@link addFileLog function} that adds a new log consumer that
|
||||
* writes to a file.
|
||||
*
|
||||
* This is the primary entry point, though its building blocks are also exported, like {@link FileConsumer}. */
|
||||
* This is the primary entry point, though its building blocks are also exported,
|
||||
* like {@link FileConsumer}. */
|
||||
|
||||
import * as fsSync from 'node:fs'
|
||||
import * as pathModule from 'node:path'
|
||||
@ -63,6 +65,7 @@ export class FileConsumer extends linkedDist.Consumer {
|
||||
this.logFileHandle = fsSync.openSync(this.logFilePath, 'a')
|
||||
}
|
||||
|
||||
/** Append a message to the log. */
|
||||
override message(level: linkedDist.LogLevel, ...args: unknown[]): void {
|
||||
const timestamp = new Date().toISOString()
|
||||
const message = args
|
||||
@ -82,16 +85,20 @@ export class FileConsumer extends linkedDist.Consumer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a log group. */
|
||||
override startGroup(...args: unknown[]): void {
|
||||
this.message('log', '[GROUP START]', ...args)
|
||||
}
|
||||
|
||||
/** Start a collapsed log group - for `FileConsumer`, this does the same thing
|
||||
* as `startGroup`. */
|
||||
override startGroupCollapsed(...args: unknown[]): void {
|
||||
// We don't have a way to collapse groups in the file logger, so we just use the same
|
||||
// function as startGroup.
|
||||
this.message('log', '[GROUP START]', ...args)
|
||||
}
|
||||
|
||||
/** End a log group. */
|
||||
override groupEnd(...args: unknown[]): void {
|
||||
this.message('log', '[GROUP END]', ...args)
|
||||
}
|
||||
|
@ -4,17 +4,17 @@
|
||||
// === Naming ===
|
||||
// ==============
|
||||
|
||||
/** Capitalizes first letter of the provided string. */
|
||||
/** Capitalize the first letter of the provided string. */
|
||||
export function capitalizeFirstLetter(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
/** Converts the camel case name to kebab case one. For example, converts `myName` to `my-name`. */
|
||||
/** Convert a camel case name to kebab case. For example, converts `myName` to `my-name`. */
|
||||
export function camelToKebabCase(str: string) {
|
||||
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
/** Converts the camel case name to kebab case one. For example, converts `myName` to `My Name`. */
|
||||
/** Convert a camel case name to title case. For example, converts `myName` to `My Name`. */
|
||||
export function camelCaseToTitle(str: string) {
|
||||
return capitalizeFirstLetter(str.replace(/([a-z])([A-Z])/g, '$1 $2'))
|
||||
}
|
||||
|
@ -15,15 +15,12 @@ import * as paths from '../paths'
|
||||
*
|
||||
* This path is like:
|
||||
* - for packaged application `…/resources/app.asar`;
|
||||
* - for development `…` (just the directory with `index.js`).
|
||||
*/
|
||||
* - for development `…` (just the directory with `index.js`). */
|
||||
export const APP_PATH = electron.app.getAppPath()
|
||||
|
||||
/**
|
||||
* Get the path of the directory where the log files of IDE are stored.
|
||||
/** The path of the directory in which the log files of IDE are stored.
|
||||
*
|
||||
* This is based on the Electron `logs` directory, see {@link Electron.App.getPath}.
|
||||
*/
|
||||
* This is based on the Electron `logs` directory, see {@link Electron.App.getPath}. */
|
||||
export const LOGS_DIRECTORY = electron.app.getPath('logs')
|
||||
|
||||
/** The application assets, all files bundled with it. */
|
||||
@ -31,8 +28,7 @@ export const ASSETS_PATH = path.join(APP_PATH, 'assets')
|
||||
|
||||
/** Path to the `resources` folder.
|
||||
*
|
||||
* Contains other app resources, including binaries, such a project manager.
|
||||
*/
|
||||
* Contains other app resources, including binaries, such a project manager. */
|
||||
export const RESOURCES_PATH = electronIsDev ? APP_PATH : path.join(APP_PATH, '..')
|
||||
|
||||
/** Project manager binary path. */
|
||||
|
@ -24,7 +24,7 @@ const AUTHENTICATION_API_KEY = 'authenticationApi'
|
||||
|
||||
/** Shutdown-related commands and events. */
|
||||
electron.contextBridge.exposeInMainWorld('enso_lifecycle', {
|
||||
/** Allows application-exit to be initiated from WASM code.
|
||||
/** Allow application-exit to be initiated from WASM code.
|
||||
* This is used, for example, in a key binding (Ctrl+Alt+Q) that saves a performance profile and
|
||||
* exits. */
|
||||
quit: () => {
|
||||
@ -82,13 +82,14 @@ electron.contextBridge.exposeInMainWorld('enso_console', {
|
||||
* - handle deep links from the system browser or email client to the dashboard.
|
||||
*
|
||||
* Some functions (i.e., the functions to open URLs in the system browser) are not available in
|
||||
* sandboxed processes (i.e., the dashboard). So the {@link electron.contextBridge.exposeInMainWorld} API is
|
||||
* used to expose these functions. The functions are exposed via this "API object", which is added
|
||||
* to the main window.
|
||||
* sandboxed processes (i.e., the dashboard). So the
|
||||
* {@link electron.contextBridge.exposeInMainWorld} API is used to expose these functions.
|
||||
* The functions are exposed via this "API object", which is added to the main window.
|
||||
*
|
||||
* For more details, see: https://www.electronjs.org/docs/latest/api/context-bridge#api-functions. */
|
||||
* For more details, see:
|
||||
* https://www.electronjs.org/docs/latest/api/context-bridge#api-functions. */
|
||||
const AUTHENTICATION_API = {
|
||||
/** Opens a URL in the system browser (rather than in the app).
|
||||
/** Open a URL in the system browser (rather than in the app).
|
||||
*
|
||||
* OAuth URLs must be opened this way because the dashboard application is sandboxed and thus
|
||||
* not privileged to do so unless we explicitly expose this functionality. */
|
||||
@ -105,10 +106,10 @@ const AUTHENTICATION_API = {
|
||||
electron.ipcRenderer.on(ipc.Channel.openDeepLink, (_event, url: string) => {
|
||||
callback(url)
|
||||
}),
|
||||
/** Saves the access token to a credentials file.
|
||||
/** Save the access token to a credentials file.
|
||||
*
|
||||
* Enso backend doesn't have access to Electron localStorage so we need to save access token to a file.
|
||||
* Then the token will be used to sign cloud API requests. */
|
||||
* The backend doesn't have access to Electron's `localStorage` so we need to save access token
|
||||
* to a file. Then the token will be used to sign cloud API requests. */
|
||||
saveAccessToken: (accessToken: string) => {
|
||||
electron.ipcRenderer.send(ipc.Channel.saveAccessToken, accessToken)
|
||||
},
|
||||
|
@ -1,11 +1,12 @@
|
||||
/** @file This module contains functions for importing projects into the Project Manager.
|
||||
*
|
||||
* Eventually this module should be replaced with a new Project Manager API that supports importing projects.
|
||||
* Eventually this module should be replaced with a new Project Manager API that supports
|
||||
* importing projects.
|
||||
* For now, we basically do the following:
|
||||
* - if the project is already in the Project Manager's location, we just open it;
|
||||
* - if the project is in a different location, we copy it to the Project Manager's location and open it.
|
||||
* - if the project is a bundle, we extract it to the Project Manager's location and open it.
|
||||
*/
|
||||
* - if the project is in a different location, we copy it to the Project Manager's location
|
||||
* and open it.
|
||||
* - 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'
|
||||
@ -27,17 +28,18 @@ const logger = config.logger
|
||||
// === Project Import ===
|
||||
// ======================
|
||||
|
||||
/** 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.
|
||||
/** 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.
|
||||
*/
|
||||
* @throws {Error} if the path does not belong to a valid project. */
|
||||
export function importProjectFromPath(openedPath: string): string {
|
||||
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 like `my-project.enso-project`
|
||||
// and stores the project there. Not the most fortunate move, but...
|
||||
// The second part of condition is for the case when someone names a directory
|
||||
// like `my-project.enso-project` and stores the project there.
|
||||
// Not the most fortunate move, but...
|
||||
if (isProjectRoot(openedPath)) {
|
||||
return importDirectory(openedPath)
|
||||
} else {
|
||||
@ -50,7 +52,8 @@ export function importProjectFromPath(openedPath: string): string {
|
||||
// Check if the project root is under the projects directory. If it is, we can open it.
|
||||
// Otherwise, we need to install it first.
|
||||
if (rootPath == null) {
|
||||
const message = `File '${openedPath}' does not belong to the ${common.PRODUCT_NAME} project.`
|
||||
const productName = common.PRODUCT_NAME
|
||||
const message = `File '${openedPath}' does not belong to the ${productName} project.`
|
||||
throw new Error(message)
|
||||
} else {
|
||||
return importDirectory(rootPath)
|
||||
@ -60,22 +63,22 @@ export function importProjectFromPath(openedPath: string): string {
|
||||
|
||||
/** Import the project from a bundle.
|
||||
*
|
||||
* @returns Project ID (from Project Manager's metadata) identifying the imported project.
|
||||
*/
|
||||
* @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}'.`)
|
||||
// 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 })
|
||||
// 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 to generate a new target
|
||||
// directory name;
|
||||
// * contain the project files directly - in this case, the archive filename will be used to generate a new target
|
||||
// directory name.
|
||||
// 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.
|
||||
// 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
|
||||
// to generate a new target directory name;
|
||||
// * contain the project files directly - in this case, the archive filename will be used
|
||||
// to generate a new target directory name.
|
||||
// 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({
|
||||
file: bundlePath,
|
||||
cwd: targetDirectory,
|
||||
@ -85,12 +88,11 @@ export function importBundle(bundlePath: string): string {
|
||||
return updateId(targetDirectory)
|
||||
}
|
||||
|
||||
/** Import the project, so it becomes visible to Project Manager.
|
||||
/** Import the project so it becomes visible to the Project Manager.
|
||||
*
|
||||
* @param rootPath - The path to the project root.
|
||||
* @returns Project ID (from Project Manager's metadata) identifying the imported project.
|
||||
* @throws `Error` if there occurs race-condition when generating a unique project directory name.
|
||||
*/
|
||||
* @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 {
|
||||
if (isProjectInstalled(rootPath)) {
|
||||
// Project is already visible to Project Manager, so we can just return its ID.
|
||||
@ -105,8 +107,8 @@ export function importDirectory(rootPath: string): string {
|
||||
} else {
|
||||
logger.log(`Copying: '${rootPath}' -> '${targetDirectory}'.`)
|
||||
fsSync.cpSync(rootPath, targetDirectory, { recursive: true })
|
||||
// Update the project ID, so we are certain that it is unique. This would be violated, if we imported the same
|
||||
// project multiple times.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@ -118,23 +120,22 @@ export function importDirectory(rootPath: string): string {
|
||||
|
||||
/** The Project Manager's metadata associated with a project.
|
||||
*
|
||||
* The property list is not exhaustive, it only contains the properties that we need.
|
||||
*/
|
||||
* The property list is not exhaustive; it only contains the properties that we need. */
|
||||
interface ProjectMetadata {
|
||||
/** The ID of the project. It is only used in communication with project manager, it has no semantic meaning. */
|
||||
/** The ID of the project. It is only used in communication with project manager;
|
||||
* it has no semantic meaning. */
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard function to check if an object conforms to the ProjectMetadata interface.
|
||||
/** A type guard function to check if an object conforms to the {@link ProjectMetadata} interface.
|
||||
*
|
||||
* This function checks if the input object has the required properties and correct types
|
||||
* to match the ProjectMetadata interface. It can be used at runtime to validate that
|
||||
* to match the {@link ProjectMetadata} interface. It can be used at runtime to validate that
|
||||
* a given object has the expected shape.
|
||||
*
|
||||
* @param value - The object to check against the ProjectMetadata interface.
|
||||
* @returns A boolean value indicating whether the object matches the ProjectMetadata interface.
|
||||
*/
|
||||
* @returns A boolean value indicating whether the object matches
|
||||
* the {@link ProjectMetadata} interface. */
|
||||
function isProjectMetadata(value: unknown): value is ProjectMetadata {
|
||||
return (
|
||||
typeof value === 'object' && value != null && 'id' in value && typeof value.id === 'string'
|
||||
@ -148,7 +149,7 @@ export function getProjectId(projectRoot: string): string {
|
||||
|
||||
/** Retrieve the project's metadata.
|
||||
*
|
||||
* @throws `Error` if the metadata file is missing or ill-formed. */
|
||||
* @throws {Error} if the metadata file is missing or ill-formed. */
|
||||
export function getMetadata(projectRoot: string): ProjectMetadata {
|
||||
const metadataPath = pathModule.join(projectRoot, paths.PROJECT_METADATA_RELATIVE)
|
||||
const jsonText = fss.readFileSync(metadataPath, 'utf8')
|
||||
@ -166,10 +167,10 @@ export function writeMetadata(projectRoot: string, metadata: ProjectMetadata): v
|
||||
fss.writeFileSync(metadataPath, JSON.stringify(metadata, null, utils.INDENT_SIZE))
|
||||
}
|
||||
|
||||
/** Update project's metadata. If the provided updater does not return anything, the metadata file is left intact.
|
||||
/** Update the project's metadata.
|
||||
* If the provided updater does not return anything, the metadata file is left intact.
|
||||
*
|
||||
* The updater function-returned metadata is passed over.
|
||||
*/
|
||||
* Returns the metadata returned from the updater function. */
|
||||
export function updateMetadata(
|
||||
projectRoot: string,
|
||||
updater: (initialMetadata: ProjectMetadata) => ProjectMetadata
|
||||
@ -184,8 +185,8 @@ export function updateMetadata(
|
||||
// === Project Directory ===
|
||||
// =========================
|
||||
|
||||
/* Check if the given path represents the root of an Enso project. This is decided by the presence
|
||||
* of Project Manager's metadata. */
|
||||
/** 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 projectJsonPath = pathModule.join(candidatePath, paths.PROJECT_METADATA_RELATIVE)
|
||||
let isRoot = false
|
||||
@ -198,8 +199,8 @@ export function isProjectRoot(candidatePath: string): boolean {
|
||||
return isRoot
|
||||
}
|
||||
|
||||
/** Check if this bundle is a compressed directory (rather than directly containing the project files). If it is, we
|
||||
* return the name of the directory. Otherwise, we return `null`. */
|
||||
/** Check if this bundle is a compressed directory (rather than directly containing the project
|
||||
* files). If it is, we return the name of the directory. Otherwise, we return `null`. */
|
||||
export function directoryWithinBundle(bundlePath: string): string | null {
|
||||
// We need to look up the root directory among the tarball entries.
|
||||
let commonPrefix: string | null = null
|
||||
@ -212,16 +213,18 @@ export function directoryWithinBundle(bundlePath: string): string | null {
|
||||
commonPrefix = commonPrefix == null ? path : utils.getCommonPrefix(commonPrefix, path)
|
||||
},
|
||||
})
|
||||
// ESLint doesn't understand that `commonPrefix` can be not `null` here due to the `onentry` callback.
|
||||
// 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 ? pathModule.basename(commonPrefix) : null
|
||||
}
|
||||
|
||||
/** Generate a name for project using given base string. Suffixes are added if there's a collision.
|
||||
/** Generate a name for a project using given base string. A suffix is added if there is a
|
||||
* collision.
|
||||
*
|
||||
* For example 'Name' will become 'Name_1' if there's already a directory named 'Name'.
|
||||
* If given a name like 'Name_1' it will become 'Name_2' if there's already a directory named 'Name_1'.
|
||||
* If a path containing multiple components is given, only the last component is used for the name. */
|
||||
* For example `Name` will become `Name_1` if there's already a directory named `Name`.
|
||||
* 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 {
|
||||
// Use only the last path component.
|
||||
name = pathModule.parse(name).name
|
||||
@ -250,8 +253,8 @@ export function generateDirectoryName(name: string): string {
|
||||
// Unreachable.
|
||||
}
|
||||
|
||||
/** Takes a path to a file, presumably located in a project's subtree. Returns the path to the project's root directory
|
||||
* or `null` if the file is not located in a project. */
|
||||
/** Take a path to a file, presumably located in a project's subtree.Returns the path
|
||||
* to the project's root directory or `null` if the file is not located in a project. */
|
||||
export function getProjectRoot(subtreePath: string): string | null {
|
||||
let currentPath = subtreePath
|
||||
while (!isProjectRoot(currentPath)) {
|
||||
@ -272,7 +275,8 @@ export function getProjectsDirectory(): string {
|
||||
|
||||
/** Check if the given project is installed, i.e. can be opened with the Project Manager. */
|
||||
export function isProjectInstalled(projectRoot: string): boolean {
|
||||
// Project can be opened by project manager only if its root directory is directly under the projects directory.
|
||||
// Project can be opened by project manager only if its root directory is directly under
|
||||
// the projects directory.
|
||||
const projectsDirectory = getProjectsDirectory()
|
||||
const projectRootParent = pathModule.dirname(projectRoot)
|
||||
// Should resolve symlinks and relative paths. Normalize before comparison.
|
||||
@ -283,7 +287,7 @@ export function isProjectInstalled(projectRoot: string): boolean {
|
||||
// === Project ID ===
|
||||
// ==================
|
||||
|
||||
/** Generates a unique UUID for a project. */
|
||||
/** Generate a unique UUID for a project. */
|
||||
export function generateId(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
@ -108,7 +108,8 @@ function preventNavigation() {
|
||||
* https://www.electronjs.org/docs/tutorial/security#13-disable-or-limit-creation-of-new-windows. */
|
||||
function disableNewWindowsCreation() {
|
||||
electron.app.on('web-contents-created', (_event, contents) => {
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
contents.setWindowOpenHandler(details => {
|
||||
const { url } = details
|
||||
const parsedUrl = new URL(url)
|
||||
if (TRUSTED_EXTERNAL_HOSTS.includes(parsedUrl.host)) {
|
||||
void electron.shell.openExternal(url)
|
||||
|
@ -18,8 +18,7 @@ const logger = contentConfig.logger
|
||||
* set up the process.
|
||||
*
|
||||
* It is also no-op on macOS, as the OS handles the URL opening by passing the `open-url` event to
|
||||
* the application, thanks to the information baked in our application by the `electron-builder`.
|
||||
*/
|
||||
* the application, thanks to the information baked in our application by `electron-builder`. */
|
||||
export function registerAssociations() {
|
||||
if (!electron.app.isDefaultProtocolClient(common.DEEP_LINK_SCHEME)) {
|
||||
if (electronIsDev) {
|
||||
@ -40,8 +39,7 @@ export function registerAssociations() {
|
||||
// === URL handling ===
|
||||
// ====================
|
||||
|
||||
/**
|
||||
* Check if the given list of application startup arguments denotes an attempt to open a URL.
|
||||
/** Check if the given list of application startup arguments denotes an attempt to open a URL.
|
||||
*
|
||||
* For example, this happens on Windows when the browser redirects user using our
|
||||
* [deep link scheme]{@link common.DEEP_LINK_SCHEME}. On macOS this is not used, as the OS
|
||||
@ -49,8 +47,7 @@ export function registerAssociations() {
|
||||
*
|
||||
* @param clientArgs - A list of arguments passed to the application, stripped from the initial
|
||||
* executable name and any electron dev mode arguments.
|
||||
* @returns The URL to open, or `null` if no file was specified.
|
||||
*/
|
||||
* @returns The URL to open, or `null` if no file was specified. */
|
||||
export function argsDenoteUrlOpenAttempt(clientArgs: string[]): URL | null {
|
||||
const arg = clientArgs[0]
|
||||
let result: URL | null = null
|
||||
@ -74,8 +71,7 @@ export function argsDenoteUrlOpenAttempt(clientArgs: string[]): URL | null {
|
||||
*
|
||||
* This happens on Windows when the browser redirects user using the deep link scheme.
|
||||
*
|
||||
* @param openedUrl - The URL to open.
|
||||
*/
|
||||
* @param openedUrl - The URL to open. */
|
||||
export function handleOpenUrl(openedUrl: URL) {
|
||||
logger.log(`Opening URL '${openedUrl.toString()}'.`)
|
||||
const appLock = electron.app.requestSingleInstanceLock({ openedUrl })
|
||||
@ -108,8 +104,7 @@ export function handleOpenUrl(openedUrl: URL) {
|
||||
* use {@link setAsUrlHandler} and {@link unsetAsUrlHandler} to ensure that the callback
|
||||
* is called.
|
||||
*
|
||||
* @param callback - The callback to call when the application is requested to open a URL.
|
||||
*/
|
||||
* @param callback - The callback to call when the application is requested to open a URL. */
|
||||
export function registerUrlCallback(callback: (url: URL) => void) {
|
||||
// First, register the callback for the `open-url` event. This is used on macOS.
|
||||
electron.app.on('open-url', (event, url) => {
|
||||
@ -158,10 +153,10 @@ export function registerUrlCallback(callback: (url: URL) => void) {
|
||||
* callbacks.
|
||||
*
|
||||
* The mechanism is built on top of the Electron's
|
||||
* [instance lock]{@link https://www.electronjs.org/docs/api/app#apprequestsingleinstancelock} functionality.
|
||||
* [instance lock]{@link https://www.electronjs.org/docs/api/app#apprequestsingleinstancelock}
|
||||
* functionality.
|
||||
*
|
||||
* @throws An error if another instance of the application has already acquired the lock.
|
||||
*/
|
||||
* @throws {Error} An error if another instance of the application has already acquired the lock. */
|
||||
export function setAsUrlHandler() {
|
||||
logger.log('Expecting URL callback, acquiring the lock.')
|
||||
if (!electron.app.requestSingleInstanceLock()) {
|
||||
@ -175,8 +170,8 @@ export function setAsUrlHandler() {
|
||||
/** Stop this application instance from receiving URL callbacks.
|
||||
*
|
||||
* This function releases the instance lock that was acquired by the {@link setAsUrlHandler}
|
||||
* function. This is necessary to ensure that other IDE instances can receive their URL callbacks.
|
||||
*/
|
||||
* function. This is necessary to ensure that other IDE instances can receive their
|
||||
* URL callbacks. */
|
||||
export function unsetAsUrlHandler() {
|
||||
logger.log('URL callback completed, releasing the lock.')
|
||||
electron.app.releaseSingleInstanceLock()
|
||||
|
@ -9,10 +9,18 @@ import * as esbuild from 'esbuild'
|
||||
import * as esbuildConfig from './esbuild-config'
|
||||
import * as paths from './paths'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const GUI_PATH = path.resolve(paths.getGuiDirectory())
|
||||
const IDE_PATH = paths.getIdeDirectory()
|
||||
const PROJECT_MANAGER_BUNDLE = paths.getProjectManagerBundlePath()
|
||||
|
||||
// =================
|
||||
// === Start IDE ===
|
||||
// =================
|
||||
|
||||
const SCRIPT_ARGS = process.argv.slice(2)
|
||||
console.log('Script arguments:', ...SCRIPT_ARGS.map(arg => JSON.stringify(arg)))
|
||||
|
||||
|
@ -17,8 +17,7 @@ const CHECKSUM_TYPE = 'sha256'
|
||||
// === Checksum ===
|
||||
// ================
|
||||
|
||||
/**
|
||||
* The `type` argument can be one of `md5`, `sha1`, or `sha256`.
|
||||
/** The `type` argument can be one of `md5`, `sha1`, or `sha256`.
|
||||
* @param {string} path - Path to the file.
|
||||
* @param {ChecksumType} type - The checksum algorithm to use.
|
||||
* @returns {Promise<string>} A promise that resolves to the checksum. */
|
||||
@ -66,7 +65,8 @@ async function writeFileChecksum(path, type) {
|
||||
/** Generates checksums for all build artifacts.
|
||||
* @param {import('electron-builder').BuildResult} context - Build information. */
|
||||
exports.default = async function (context) {
|
||||
// `context` is BuildResult, see https://www.electron.build/configuration/configuration.html#buildresult
|
||||
// `context` is BuildResult, see
|
||||
// https://www.electron.build/configuration/configuration.html#buildresult
|
||||
for (const file of context.artifactPaths) {
|
||||
console.log(`Generating ${CHECKSUM_TYPE} checksum for ${file}.`)
|
||||
await writeFileChecksum(file, CHECKSUM_TYPE)
|
||||
|
@ -1,13 +1,16 @@
|
||||
/** @file This script signs the content of all archives that we have for macOS. For this to work this needs to run on
|
||||
* macOS with `codesign`, and a JDK installed. `codesign` is needed to sign the files, while the JDK is needed for
|
||||
* correct packing and unpacking of java archives.
|
||||
/** @file This script signs the content of all archives that we have for macOS.
|
||||
* For this to work this needs to run on macOS with `codesign`, and a JDK installed.
|
||||
* `codesign` is needed to sign the files, while the JDK is needed for correct packing
|
||||
* and unpacking of java archives.
|
||||
*
|
||||
* We require this extra step as our dependencies contain files that require us to re-sign jar contents that cannot be
|
||||
* opened as pure zip archives, but require a java toolchain to extract and re-assemble to preserve manifest
|
||||
* information. This functionality is not provided by `electron-osx-sign` out of the box.
|
||||
* We require this extra step as our dependencies contain files that require us
|
||||
* to re-sign jar contents that cannot be opened as pure zip archives,
|
||||
* but require a java toolchain to extract and re-assemble to preserve manifest information.
|
||||
* This functionality is not provided by `electron-osx-sign` out of the box.
|
||||
*
|
||||
* This code is based on https://github.com/electron/electron-osx-sign/pull/231 but our use-case is unlikely to be
|
||||
* supported by electron-osx-sign as it adds a java toolchain as additional dependency.
|
||||
* This code is based on https://github.com/electron/electron-osx-sign/pull/231
|
||||
* but our use-case is unlikely to be supported by `electron-osx-sign`
|
||||
* as it adds a java toolchain as additional dependency.
|
||||
* This script should be removed once the engine is signed. */
|
||||
|
||||
import * as childProcess from 'node:child_process'
|
||||
@ -66,8 +69,8 @@ async function graalSignables(resourcesDir: string): Promise<Signable[]> {
|
||||
`Contents/MacOS/libjli.dylib`,
|
||||
]
|
||||
|
||||
// We use `*` for Graal versioned directory to not have to update this script on every GraalVM update.
|
||||
// Updates might still be needed when the list of binaries to sign changes.
|
||||
// We use `*` for Graal versioned directory to not have to update this script on every GraalVM
|
||||
// update. Updates might still be needed when the list of binaries to sign changes.
|
||||
const graalDir = pathModule.join(resourcesDir, 'enso', 'runtime', '*')
|
||||
const archives = await ArchiveToSign.lookupMany(graalDir, archivePatterns)
|
||||
const binaries = await BinaryToSign.lookupMany(graalDir, binariesPatterns)
|
||||
@ -76,10 +79,10 @@ async function graalSignables(resourcesDir: string): Promise<Signable[]> {
|
||||
|
||||
/** Parts of the Enso Engine distribution that need to be signed by us in an extra step. */
|
||||
async function ensoPackageSignables(resourcesDir: string): Promise<Signable[]> {
|
||||
/// Archives, and their content that need to be signed in an extra step. If a new archive is added
|
||||
/// to the engine dependencies this also needs to be added here. If an archive is not added here, it
|
||||
/// will show up as a failure to notarise the IDE. The offending archive will be named in the error
|
||||
/// message provided by Apple and can then be added here.
|
||||
// Archives, and their content that need to be signed in an extra step. If a new archive is
|
||||
// added to the engine dependencies this also needs to be added here. If an archive is not added
|
||||
// here, it will show up as a failure to notarise the IDE. The offending archive will be named
|
||||
// in the error message provided by Apple and can then be added here.
|
||||
const engineDir = `${resourcesDir}/enso/dist/*`
|
||||
const archivePatterns: ArchivePattern[] = [
|
||||
[
|
||||
@ -108,8 +111,7 @@ async function ensoPackageSignables(resourcesDir: string): Promise<Signable[]> {
|
||||
/** Information we need to sign a given binary. */
|
||||
interface SigningContext {
|
||||
/** A digital identity that is stored in a keychain that is on the calling user's keychain
|
||||
* search list. We rely on this already being set up by the Electron Builder.
|
||||
*/
|
||||
* search list. We rely on this already being set up by the Electron Builder. */
|
||||
identity: string
|
||||
/** Path to the entitlements file. */
|
||||
entitlements: string
|
||||
@ -132,8 +134,7 @@ function run(cmd: string, args: string[], cwd?: string) {
|
||||
|
||||
/** Archive with some binaries that we want to sign.
|
||||
*
|
||||
* Can be either a zip or a jar file.
|
||||
*/
|
||||
* Can be either a zip or a jar file. */
|
||||
class ArchiveToSign implements Signable {
|
||||
/** Looks up for archives to sign using the given path patterns. */
|
||||
static lookupMany = lookupManyHelper(ArchiveToSign.lookup.bind(this))
|
||||
@ -165,7 +166,7 @@ class ArchiveToSign implements Signable {
|
||||
run(`jar`, ['xf', this.path], workingDir)
|
||||
} else {
|
||||
// We cannot use `unzip` here because of the following issue:
|
||||
// https://unix.stackexchange.com/questions/115825/extra-bytes-error-when-unzipping-a-file
|
||||
// https://unix.stackexchange.com/questions/115825/
|
||||
// This started to be an issue with GraalVM 22.3.0 release.
|
||||
run(`7za`, ['X', `-o${workingDir}`, this.path])
|
||||
}
|
||||
@ -250,31 +251,29 @@ class BinaryToSign implements Signable {
|
||||
/** Helper used to concisely define patterns for an archive to sign.
|
||||
*
|
||||
* Consists of pattern of the archive path
|
||||
* and set of patterns for files to sign inside the archive.
|
||||
*/
|
||||
* and set of patterns for files to sign inside the archive. */
|
||||
type ArchivePattern = [glob.Pattern, glob.Pattern[]]
|
||||
|
||||
/** Like `glob` but returns absolute paths by default. */
|
||||
async function globAbs(pattern: glob.Pattern, options?: glob.Options): Promise<string[]> {
|
||||
async function globAbsolute(pattern: glob.Pattern, options?: glob.Options): Promise<string[]> {
|
||||
const paths = await glob(pattern, { absolute: true, ...options })
|
||||
return paths
|
||||
}
|
||||
|
||||
/** Glob patterns relative to a given base directory. Base directory is allowed to be a pattern as
|
||||
* well.
|
||||
*/
|
||||
async function globAbsIn(
|
||||
/** Glob patterns relative to a given base directory. The base directory is allowed to be a pattern
|
||||
* as well. */
|
||||
async function globAbsoluteIn(
|
||||
base: glob.Pattern,
|
||||
pattern: glob.Pattern,
|
||||
options?: glob.Options
|
||||
): Promise<string[]> {
|
||||
return globAbs(pathModule.join(base, pattern), options)
|
||||
return globAbsolute(pathModule.join(base, pattern), options)
|
||||
}
|
||||
|
||||
/** Generate a lookup function for a given Signable type. */
|
||||
function lookupHelper<R extends Signable>(mapper: (path: string) => R) {
|
||||
return async (base: string, pattern: glob.Pattern) => {
|
||||
const paths = await globAbsIn(base, pattern)
|
||||
const paths = await globAbsoluteIn(base, pattern)
|
||||
return paths.map(mapper)
|
||||
}
|
||||
}
|
||||
@ -298,9 +297,7 @@ async function rmRf(path: string) {
|
||||
await fs.rm(path, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new temporary directory. Caller is responsible for cleaning up the directory.
|
||||
*/
|
||||
/** Get a new temporary directory. Caller is responsible for cleaning up the directory. */
|
||||
async function getTmpDir(prefix?: string) {
|
||||
return await fs.mkdtemp(pathModule.join(os.tmpdir(), prefix ?? 'enso-signing-'))
|
||||
}
|
||||
|
@ -1,13 +1,11 @@
|
||||
/**
|
||||
* @file This script is for watching the whole IDE and spawning the electron process.
|
||||
/** @file This script is for watching the whole IDE and spawning the electron process.
|
||||
*
|
||||
* It sets up watchers for the client and content, and spawns the electron process with the IDE.
|
||||
* The spawned electron process can then use its refresh capability to pull the latest changes
|
||||
* from the watchers.
|
||||
*
|
||||
* If the electron is closed, the script will restart it, allowing to test the IDE setup.
|
||||
* To stop, use Ctrl+C.
|
||||
*/
|
||||
* If the electron app is closed, the script will restart it, allowing to test the IDE setup.
|
||||
* To stop, use Ctrl+C. */
|
||||
|
||||
import * as childProcess from 'node:child_process'
|
||||
import * as fs from 'node:fs/promises'
|
||||
@ -21,6 +19,10 @@ import * as contentBundler from '../content/esbuild-config'
|
||||
import * as dashboardBundler from '../dashboard/esbuild-config'
|
||||
import * as paths from './paths'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Set of esbuild watches for the client and content. */
|
||||
interface Watches {
|
||||
client: esbuild.BuildResult
|
||||
@ -28,9 +30,17 @@ interface Watches {
|
||||
content: esbuild.BuildResult
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const IDE_DIR_PATH = paths.getIdeDirectory()
|
||||
const PROJECT_MANAGER_BUNDLE_PATH = paths.getProjectManagerBundlePath()
|
||||
|
||||
// =============
|
||||
// === Watch ===
|
||||
// =============
|
||||
|
||||
console.log('Cleaning IDE dist directory.')
|
||||
await fs.rm(IDE_DIR_PATH, { recursive: true, force: true })
|
||||
await fs.mkdir(IDE_DIR_PATH, { recursive: true })
|
||||
@ -47,7 +57,7 @@ const ALL_BUNDLES_READY = new Promise<Watches>((resolve, reject) => {
|
||||
setup: build => {
|
||||
build.onEnd(result => {
|
||||
if (result.errors.length) {
|
||||
// We cannot carry on if the client failed to build, because electron executable
|
||||
// We cannot carry on if the client failed to build, because electron
|
||||
// would immediately exit with an error.
|
||||
console.error('Client watch bundle failed:', result.errors[0])
|
||||
reject(result.errors[0])
|
||||
@ -114,8 +124,9 @@ const ELECTRON_ARGS = [path.join(IDE_DIR_PATH, 'index.cjs'), '--', ...process.ar
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SIGINT received. Exiting.')
|
||||
// The esbuild process seems to remain alive at this point and will keep our process from ending.
|
||||
// Thus, we exit manually. It seems to terminate the child esbuild process as well.
|
||||
// The `esbuild` process seems to remain alive at this point and will keep our process
|
||||
// from ending. Thus, we exit manually. It seems to terminate the child `esbuild` process
|
||||
// as well.
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
|
@ -5,6 +5,10 @@
|
||||
* here when it is not possible for a sibling package to own that code without introducing a
|
||||
* circular dependency in our packages. */
|
||||
|
||||
// ========================
|
||||
// === Product metadata ===
|
||||
// ========================
|
||||
|
||||
/** URL protocol scheme for deep links to authentication flow pages, without the `:` suffix.
|
||||
*
|
||||
* For example: the deep link URL
|
||||
|
@ -11,7 +11,8 @@ export const Option = linkedDist.config.Option
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Group = linkedDist.config.Group
|
||||
export const logger = linkedDist.log.logger
|
||||
// Declaring type with the same name as a variable.
|
||||
/** A configuration option. */
|
||||
// This type has the same name as a variable.
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export type Option<T> = linkedDist.config.Option<T>
|
||||
|
||||
@ -27,6 +28,7 @@ export const VERSION = {
|
||||
/// Version of the `client` js package.
|
||||
ide: new semver.SemVer(BUILD_INFO.version, { loose: true }),
|
||||
|
||||
/** Returns whether this is a development version. */
|
||||
isDev(): boolean {
|
||||
const clientVersion = VERSION.ide
|
||||
const releaseDev = clientVersion.compareMain(VERSION.dev) === 0
|
||||
|
@ -3,6 +3,10 @@ import * as esbuild from 'esbuild'
|
||||
|
||||
import * as bundler from './esbuild-config'
|
||||
|
||||
// =======================
|
||||
// === Generate bundle ===
|
||||
// =======================
|
||||
|
||||
try {
|
||||
void esbuild.build(bundler.bundleOptions())
|
||||
} catch (error) {
|
||||
|
@ -1,13 +1,11 @@
|
||||
/**
|
||||
* @file Configuration for the esbuild bundler and build/watch commands.
|
||||
/** @file Configuration for the esbuild bundler and build/watch commands.
|
||||
*
|
||||
* The bundler processes each entry point into a single file, each with no external dependencies and
|
||||
* minified. This primarily involves resolving all imports, along with some other transformations
|
||||
* (like TypeScript compilation).
|
||||
*
|
||||
* See the bundlers documentation for more information:
|
||||
* https://esbuild.github.io/getting-started/#bundling-for-node.
|
||||
*/
|
||||
* https://esbuild.github.io/getting-started/#bundling-for-node. */
|
||||
|
||||
import * as childProcess from 'node:child_process'
|
||||
import * as fs from 'node:fs/promises'
|
||||
@ -36,6 +34,7 @@ const THIS_PATH = pathModule.resolve(pathModule.dirname(url.fileURLToPath(import
|
||||
// === Environment variables ===
|
||||
// =============================
|
||||
|
||||
/** Mandatory build options. */
|
||||
export interface Arguments {
|
||||
/** List of files to be copied from WASM artifacts. */
|
||||
wasmArtifacts: string
|
||||
@ -49,9 +48,7 @@ export interface Arguments {
|
||||
devMode: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get arguments from the environment.
|
||||
*/
|
||||
/** Get arguments from the environment. */
|
||||
export function argumentsFromEnv(): Arguments {
|
||||
const wasmArtifacts = utils.requireEnv('ENSO_BUILD_GUI_WASM_ARTIFACTS')
|
||||
const assetsPath = utils.requireEnv('ENSO_BUILD_GUI_ASSETS')
|
||||
@ -64,14 +61,13 @@ export function argumentsFromEnv(): Arguments {
|
||||
// === Git process ===
|
||||
// ===================
|
||||
|
||||
/**
|
||||
* Get output of a git command.
|
||||
/** Get output of a git command.
|
||||
* @param command - Command line following the `git` program.
|
||||
* @returns Output of the command.
|
||||
*/
|
||||
* @returns Output of the command. */
|
||||
function git(command: string): string {
|
||||
// TODO [mwu] Eventually this should be removed, data should be provided by the build script through `BUILD_INFO`.
|
||||
// The bundler configuration should not invoke git, it is not its responsibility.
|
||||
// TODO [mwu] Eventually this should be removed, data should be provided by the build script
|
||||
// through `BUILD_INFO`. The bundler configuration should not invoke git,
|
||||
// it is not its responsibility.
|
||||
return childProcess.execSync(`git ${command}`, { encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
@ -79,9 +75,7 @@ function git(command: string): string {
|
||||
// === Bundling ===
|
||||
// ================
|
||||
|
||||
/**
|
||||
* Generate the builder options.
|
||||
*/
|
||||
/** Generate the builder options. */
|
||||
export function bundlerOptions(args: Arguments) {
|
||||
const { outputPath, ensoglAppPath, wasmArtifacts, assetsPath, devMode } = args
|
||||
const buildOptions = {
|
||||
@ -118,10 +112,13 @@ export function bundlerOptions(args: Arguments) {
|
||||
// All other files are ESM because of `"type": "module"` in `package.json`.
|
||||
name: 'pkg-js-is-cjs',
|
||||
setup: build => {
|
||||
build.onLoad({ filter: /[/\\]pkg.js$/ }, async ({ path }) => ({
|
||||
contents: await fs.readFile(path),
|
||||
loader: 'copy',
|
||||
}))
|
||||
build.onLoad({ filter: /[/\\]pkg.js$/ }, async info => {
|
||||
const { path } = info
|
||||
return {
|
||||
contents: await fs.readFile(path),
|
||||
loader: 'copy',
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
esbuildPluginCopyDirectories(),
|
||||
@ -166,16 +163,15 @@ export function bundlerOptions(args: Arguments) {
|
||||
|
||||
/** The basic, common settings for the bundler, based on the environment variables.
|
||||
*
|
||||
* Note that they should be further customized as per the needs of the specific workflow (e.g. watch vs. build).
|
||||
*/
|
||||
* Note that they should be further customized as per the needs of the specific workflow
|
||||
* (e.g. watch vs. build). */
|
||||
export function bundlerOptionsFromEnv() {
|
||||
return bundlerOptions(argumentsFromEnv())
|
||||
}
|
||||
|
||||
/** ESBuild options for bundling (one-off build) the package.
|
||||
/** esbuild options for bundling the package for a one-off build.
|
||||
*
|
||||
* Relies on the environment variables to be set.
|
||||
*/
|
||||
* Relies on the environment variables to be set. */
|
||||
export function bundleOptions() {
|
||||
return bundlerOptionsFromEnv()
|
||||
}
|
||||
|
@ -43,15 +43,16 @@ if (IS_DEV_MODE) {
|
||||
// === Fetch ===
|
||||
// =============
|
||||
|
||||
function timeout(time: number) {
|
||||
/** Returns an `AbortController` that aborts after the specified number of seconds. */
|
||||
function timeout(timeSeconds: number) {
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => {
|
||||
controller.abort()
|
||||
}, time * SECOND)
|
||||
}, timeSeconds * SECOND)
|
||||
return controller
|
||||
}
|
||||
|
||||
/** A version of `fetch` which timeouts after the provided time. */
|
||||
/** A version of `fetch` which times out after the provided time. */
|
||||
async function fetchTimeout(url: string, timeoutSeconds: number): Promise<unknown> {
|
||||
return fetch(url, { signal: timeout(timeoutSeconds).signal }).then(response => {
|
||||
const statusCodeOK = 200
|
||||
@ -125,17 +126,22 @@ function displayDeprecatedVersionDialog() {
|
||||
// === Main entry point ===
|
||||
// ========================
|
||||
|
||||
/** Nested configuration options with `string` values. */
|
||||
interface StringConfig {
|
||||
[key: string]: StringConfig | string
|
||||
}
|
||||
|
||||
/** Contains the entrypoint into the IDE. */
|
||||
class Main implements AppRunner {
|
||||
app: app.App | null = null
|
||||
|
||||
/** Stop an app instance, if one is running. */
|
||||
stopApp() {
|
||||
this.app?.stop()
|
||||
}
|
||||
|
||||
/** Run an app instance with the specified configuration.
|
||||
* This includes the scene to run and the WebSocket endpoints to the backend. */
|
||||
async runApp(inputConfig?: StringConfig) {
|
||||
this.stopApp()
|
||||
|
||||
@ -179,6 +185,7 @@ class Main implements AppRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/** The entrypoint into the IDE. */
|
||||
main(inputConfig?: StringConfig) {
|
||||
contentConfig.OPTIONS.loadAll([app.urlParams()])
|
||||
const isUsingAuthentication = contentConfig.OPTIONS.options.authentication.value
|
||||
|
46
app/ide-desktop/lib/content/src/newtype.ts
Normal file
46
app/ide-desktop/lib/content/src/newtype.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/** @file Emulates `newtype`s in TypeScript. */
|
||||
|
||||
// ===============
|
||||
// === Newtype ===
|
||||
// ===============
|
||||
|
||||
/** An interface specifying the variant of a newtype. */
|
||||
interface NewtypeVariant<TypeName extends string> {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_$type: TypeName
|
||||
}
|
||||
|
||||
/** Used to create a "branded type",
|
||||
* which contains a property that only exists at compile time.
|
||||
*
|
||||
* `Newtype<string, 'A'>` and `Newtype<string, 'B'>` are not compatible with each other,
|
||||
* however both are regular `string`s at runtime.
|
||||
*
|
||||
* This is useful in parameters that require values from a certain source,
|
||||
* for example IDs for a specific object type.
|
||||
*
|
||||
* It is similar to a `newtype` in other languages.
|
||||
* Note however because TypeScript is structurally typed,
|
||||
* a branded type is assignable to its base type:
|
||||
* `a: string = asNewtype<Newtype<string, 'Name'>>(b)` successfully typechecks. */
|
||||
export type Newtype<T, TypeName extends string> = NewtypeVariant<TypeName> & T
|
||||
|
||||
/** An interface that matches a type if and only if it is not a newtype. */
|
||||
interface NotNewtype {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_$type?: never
|
||||
}
|
||||
|
||||
/** Converts a value that is not a newtype, to a value that is a newtype. */
|
||||
export function asNewtype<T extends Newtype<unknown, string>>(
|
||||
s: NotNewtype & Omit<T, '_$type'>
|
||||
): T {
|
||||
// This cast is unsafe.
|
||||
// `T` has an extra property `_$type` which is used purely for typechecking
|
||||
// and does not exist at runtime.
|
||||
//
|
||||
// The property name is specifically chosen to trigger eslint's `naming-convention` lint,
|
||||
// so it should not be possible to accidentally create a value with such a type.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return s as unknown as T
|
||||
}
|
186
app/ide-desktop/lib/content/src/project_manager.ts
Normal file
186
app/ide-desktop/lib/content/src/project_manager.ts
Normal file
@ -0,0 +1,186 @@
|
||||
/** @file This module defines the Project Manager endpoint. */
|
||||
import * as newtype from './newtype'
|
||||
|
||||
const PROJECT_MANAGER_ENDPOINT = 'ws://127.0.0.1:30535'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Possible actions to take when a component is missing. */
|
||||
export enum MissingComponentAction {
|
||||
fail = 'Fail',
|
||||
install = 'Install',
|
||||
forceInstallBroken = 'ForceInstallBroken',
|
||||
}
|
||||
|
||||
/** The return value of a JSON-RPC call. */
|
||||
interface Result<T> {
|
||||
result: T
|
||||
}
|
||||
|
||||
// This intentionally has the same brand as in the cloud backend API.
|
||||
/** An ID of a project. */
|
||||
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
|
||||
/** A name of a project. */
|
||||
export type ProjectName = newtype.Newtype<string, 'ProjectName'>
|
||||
/** A UTC value containing a date and a time. */
|
||||
export type UTCDateTime = newtype.Newtype<string, 'UTCDateTime'>
|
||||
|
||||
/** Details for a project. */
|
||||
interface ProjectMetadata {
|
||||
name: ProjectName
|
||||
namespace: string
|
||||
id: ProjectId
|
||||
engineVersion: string | null
|
||||
lastOpened: UTCDateTime | null
|
||||
}
|
||||
|
||||
/** A value specifying a socket's hostname and port. */
|
||||
interface IpWithSocket {
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
/** The return value of the "list projects" endpoint. */
|
||||
interface ProjectList {
|
||||
projects: ProjectMetadata[]
|
||||
}
|
||||
|
||||
/** The return value of the "create project" endpoint. */
|
||||
interface CreateProject {
|
||||
projectId: ProjectId
|
||||
}
|
||||
|
||||
/** The return value of the "open project" endpoint. */
|
||||
interface OpenProject {
|
||||
engineVersion: string
|
||||
languageServerJsonAddress: IpWithSocket
|
||||
languageServerBinaryAddress: IpWithSocket
|
||||
projectName: ProjectName
|
||||
projectNamespace: string
|
||||
}
|
||||
|
||||
// ================================
|
||||
// === Parameters for endpoints ===
|
||||
// ================================
|
||||
|
||||
/** Parameters for the "open project" endpoint. */
|
||||
export interface OpenProjectParams {
|
||||
projectId: ProjectId
|
||||
missingComponentAction: MissingComponentAction
|
||||
}
|
||||
|
||||
/** Parameters for the "close project" endpoint. */
|
||||
export interface CloseProjectParams {
|
||||
projectId: ProjectId
|
||||
}
|
||||
|
||||
/** Parameters for the "list projects" endpoint. */
|
||||
export interface ListProjectsParams {
|
||||
numberOfProjects?: number
|
||||
}
|
||||
|
||||
/** Parameters for the "create project" endpoint. */
|
||||
export interface CreateProjectParams {
|
||||
name: ProjectName
|
||||
projectTemplate?: string
|
||||
version?: string
|
||||
missingComponentAction?: MissingComponentAction
|
||||
}
|
||||
|
||||
/** Parameters for the "list samples" endpoint. */
|
||||
export interface RenameProjectParams {
|
||||
projectId: ProjectId
|
||||
name: ProjectName
|
||||
}
|
||||
|
||||
/** Parameters for the "delete project" endpoint. */
|
||||
export interface DeleteProjectParams {
|
||||
projectId: ProjectId
|
||||
}
|
||||
|
||||
/** Parameters for the "list samples" endpoint. */
|
||||
export interface ListSamplesParams {
|
||||
projectId: ProjectId
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === Project Manager ===
|
||||
// =======================
|
||||
|
||||
/** A WebSocket endpoint to the Project Manager. */
|
||||
export class ProjectManager {
|
||||
/** Creates a {@link ProjectManager}. */
|
||||
constructor(protected readonly connectionUrl: string) {}
|
||||
|
||||
/** The returns the singleton instance of the {@link ProjectManager}. */
|
||||
static default() {
|
||||
return new ProjectManager(PROJECT_MANAGER_ENDPOINT)
|
||||
}
|
||||
|
||||
/** Sends a JSON-RPC request to the WebSocket endpoint. */
|
||||
public async sendRequest<T = void>(method: string, params: unknown): Promise<Result<T>> {
|
||||
const req = {
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
method,
|
||||
params,
|
||||
}
|
||||
|
||||
const ws = new WebSocket(this.connectionUrl)
|
||||
return new Promise<Result<T>>((resolve, reject) => {
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify(req))
|
||||
}
|
||||
ws.onmessage = event => {
|
||||
// There is no way to avoid this; `JSON.parse` returns `any`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
resolve(JSON.parse(event.data))
|
||||
}
|
||||
ws.onerror = error => {
|
||||
reject(error)
|
||||
}
|
||||
}).finally(() => {
|
||||
ws.close()
|
||||
})
|
||||
}
|
||||
|
||||
/** * Open an existing project. */
|
||||
public async openProject(params: OpenProjectParams): Promise<Result<OpenProject>> {
|
||||
return this.sendRequest<OpenProject>('project/open', params)
|
||||
}
|
||||
|
||||
/** * Close an open project. */
|
||||
public async closeProject(params: CloseProjectParams): Promise<Result<void>> {
|
||||
return this.sendRequest('project/close', params)
|
||||
}
|
||||
|
||||
/** * Get the projects list, sorted by open time. */
|
||||
public async listProjects(params: ListProjectsParams): Promise<Result<ProjectList>> {
|
||||
return this.sendRequest<ProjectList>('project/list', params)
|
||||
}
|
||||
|
||||
/** * Create a new project. */
|
||||
public async createProject(params: CreateProjectParams): Promise<Result<CreateProject>> {
|
||||
return this.sendRequest<CreateProject>('project/create', {
|
||||
missingComponentAction: MissingComponentAction.install,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/** * Rename a project. */
|
||||
public async renameProject(params: RenameProjectParams): Promise<Result<void>> {
|
||||
return this.sendRequest('project/rename', params)
|
||||
}
|
||||
|
||||
/** * Delete a project. */
|
||||
public async deleteProject(params: DeleteProjectParams): Promise<Result<void>> {
|
||||
return this.sendRequest('project/delete', params)
|
||||
}
|
||||
|
||||
/** * Get the list of sample projects that are available to the user. */
|
||||
public async listSamples(params: ListSamplesParams): Promise<Result<ProjectList>> {
|
||||
return this.sendRequest<ProjectList>('project/listSample', params)
|
||||
}
|
||||
}
|
@ -1,4 +1,8 @@
|
||||
/** @file This file is used to simply run the IDE. It can be not invoked if the IDE needs to be used
|
||||
* as a library. */
|
||||
|
||||
// ===============
|
||||
// === Run IDE ===
|
||||
// ===============
|
||||
|
||||
void window.enso.main()
|
||||
|
@ -2,6 +2,10 @@
|
||||
|
||||
import * as wasmRustGlue from 'wasm_rust_glue'
|
||||
|
||||
// =============================
|
||||
// === Export WASM Rust glue ===
|
||||
// =============================
|
||||
|
||||
// Eslint is not (and should not be) set up to check CommonJS.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
exports.init = wasmRustGlue.default
|
||||
|
@ -4,9 +4,18 @@ import * as portfinder from 'portfinder'
|
||||
|
||||
import * as bundler from './esbuild-config.js'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const PORT = 8080
|
||||
const HTTP_STATUS_OK = 200
|
||||
|
||||
// ===============
|
||||
// === Watcher ===
|
||||
// ===============
|
||||
|
||||
/** Start the esbuild watcher. */
|
||||
async function watch() {
|
||||
const opts = bundler.bundleOptions()
|
||||
const builder = await esbuild.context(opts)
|
||||
@ -14,6 +23,8 @@ async function watch() {
|
||||
await builder.serve({
|
||||
port: await portfinder.getPortPromise({ port: PORT }),
|
||||
servedir: opts.outdir,
|
||||
/** This function is called on every request.
|
||||
* It is used here to show an error if the file to serve was not found. */
|
||||
onRequest(args) {
|
||||
if (args.status !== HTTP_STATUS_OK) {
|
||||
console.error(`HTTP error ${args.status} when serving path '${args.path}'.`)
|
||||
|
@ -22,6 +22,7 @@ const HTTP_STATUS_OK = 200
|
||||
// === Watcher ===
|
||||
// ===============
|
||||
|
||||
/** Starts the esbuild watcher. */
|
||||
async function watch() {
|
||||
const dashboardOpts = dashboardBundler.bundleOptions()
|
||||
const dashboardBuilder = await esbuild.context(dashboardOpts)
|
||||
@ -44,6 +45,8 @@ async function watch() {
|
||||
await builder.serve({
|
||||
port: await portfinder.getPortPromise({ port: PORT }),
|
||||
servedir: opts.outdir,
|
||||
/** This function is called on every request.
|
||||
* It is used here to show an error if the file to serve was not found. */
|
||||
onRequest(args) {
|
||||
if (args.status !== HTTP_STATUS_OK) {
|
||||
console.error(
|
||||
|
@ -17,6 +17,7 @@ export const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta
|
||||
// === Bundler ===
|
||||
// ===============
|
||||
|
||||
/** Clean up old build output and runs the esbuild bundler. */
|
||||
async function bundle() {
|
||||
try {
|
||||
try {
|
||||
|
@ -1,13 +1,11 @@
|
||||
/**
|
||||
* @file Configuration for the esbuild bundler and build/watch commands.
|
||||
/** @file Configuration for the esbuild bundler and build/watch commands.
|
||||
*
|
||||
* The bundler processes each entry point into a single file, each with no external dependencies and
|
||||
* minified. This primarily involves resolving all imports, along with some other transformations
|
||||
* (like TypeScript compilation).
|
||||
*
|
||||
* See the bundlers documentation for more information:
|
||||
* https://esbuild.github.io/getting-started/#bundling-for-node.
|
||||
*/
|
||||
* https://esbuild.github.io/getting-started/#bundling-for-node. */
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import * as url from 'node:url'
|
||||
@ -34,6 +32,7 @@ const TAILWIND_CONFIG_PATH = path.resolve(THIS_PATH, 'tailwind.config.ts')
|
||||
// === Environment variables ===
|
||||
// =============================
|
||||
|
||||
/** Mandatory build options. */
|
||||
export interface Arguments {
|
||||
/** Path where bundled files are output. */
|
||||
outputPath: string
|
||||
@ -41,22 +40,22 @@ export interface Arguments {
|
||||
devMode: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get arguments from the environment.
|
||||
*/
|
||||
/** Get arguments from the environment. */
|
||||
export function argumentsFromEnv(): Arguments {
|
||||
const outputPath = path.resolve(utils.requireEnv('ENSO_BUILD_GUI'), 'assets')
|
||||
return { outputPath, devMode: false }
|
||||
}
|
||||
|
||||
// ======================
|
||||
// === Inline plugins ===
|
||||
// ======================
|
||||
// =======================
|
||||
// === Esbuild plugins ===
|
||||
// =======================
|
||||
|
||||
/** A plugin to process all CSS files with Tailwind CSS. */
|
||||
function esbuildPluginGenerateTailwind(): esbuild.Plugin {
|
||||
return {
|
||||
name: 'enso-generate-tailwind',
|
||||
setup: build => {
|
||||
/** An entry in the cache of already processed CSS files. */
|
||||
interface CacheEntry {
|
||||
contents: string
|
||||
lastModified: number
|
||||
@ -113,8 +112,8 @@ export function bundlerOptions(args: Arguments) {
|
||||
plugins: [
|
||||
esbuildPluginNodeModules.NodeModulesPolyfillPlugin(),
|
||||
esbuildPluginTime(),
|
||||
// This is not strictly needed because the cloud frontend does not use the Project Manager,
|
||||
// however it is very difficult to conditionally exclude a module.
|
||||
// This is not strictly needed because the cloud frontend does not use
|
||||
// the Project Manager, however it is very difficult to conditionally exclude a module.
|
||||
esbuildPluginYaml.yamlPlugin({}),
|
||||
esbuildPluginGenerateTailwind(),
|
||||
],
|
||||
@ -142,7 +141,7 @@ export function bundlerOptions(args: Arguments) {
|
||||
return correctlyTypedBuildOptions
|
||||
}
|
||||
|
||||
/** ESBuild options for bundling (one-off build) the package.
|
||||
/** esbuild options for bundling (one-off build) the package.
|
||||
*
|
||||
* Relies on the environment variables to be set. */
|
||||
export function bundleOptions() {
|
||||
|
@ -55,8 +55,8 @@ const MESSAGES = {
|
||||
},
|
||||
forgotPassword: {
|
||||
userNotFound: 'Username not found. Please register first.',
|
||||
userNotConfirmed:
|
||||
'Cannot reset password for user with an unverified email. Please verify your email first.',
|
||||
userNotConfirmed: `Cannot reset password for user with an unverified email. \
|
||||
Please verify your email first.`,
|
||||
},
|
||||
}
|
||||
|
||||
@ -83,7 +83,7 @@ interface AmplifyError extends Error {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Hints to TypeScript if we can safely cast an `unknown` error to an {@link AmplifyError}. */
|
||||
/** Hint to TypeScript if we can safely cast an `unknown` error to an {@link AmplifyError}. */
|
||||
function isAmplifyError(error: unknown): error is AmplifyError {
|
||||
if (error && typeof error === 'object') {
|
||||
return 'code' in error && 'message' in error && 'name' in error
|
||||
@ -92,7 +92,7 @@ function isAmplifyError(error: unknown): error is AmplifyError {
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts the `unknown` error into an {@link AmplifyError} and returns it, or re-throws it if
|
||||
/** Convert the `unknown` error into an {@link AmplifyError} and returns it, or re-throws it if
|
||||
* conversion is not possible.
|
||||
* @throws If the error is not an amplify error. */
|
||||
function intoAmplifyErrorOrThrow(error: unknown): AmplifyError {
|
||||
@ -113,7 +113,7 @@ interface AuthError {
|
||||
log: string
|
||||
}
|
||||
|
||||
/** Hints to TypeScript if we can safely cast an `unknown` error to an `AuthError`. */
|
||||
/** Hint to TypeScript if we can safely cast an `unknown` error to an `AuthError`. */
|
||||
function isAuthError(error: unknown): error is AuthError {
|
||||
if (error && typeof error === 'object') {
|
||||
return 'name' in error && 'log' in error
|
||||
@ -122,6 +122,17 @@ function isAuthError(error: unknown): error is AuthError {
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
// === CognitoError ===
|
||||
// ====================
|
||||
|
||||
/** Base interface for all errors output from this module.
|
||||
* Every user-facing error MUST extend this interface. */
|
||||
interface CognitoError {
|
||||
kind: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// ===============
|
||||
// === Cognito ===
|
||||
// ===============
|
||||
@ -130,6 +141,7 @@ function isAuthError(error: unknown): error is AuthError {
|
||||
* This way, the methods don't throw all errors, but define exactly which errors they return.
|
||||
* The caller can then handle them via pattern matching on the {@link results.Result} type. */
|
||||
export class Cognito {
|
||||
/** Create a new Cognito wrapper. */
|
||||
constructor(
|
||||
private readonly logger: loggerProvider.Logger,
|
||||
private readonly platform: platformModule.Platform,
|
||||
@ -143,15 +155,14 @@ export class Cognito {
|
||||
amplify.Auth.configure(nestedAmplifyConfig)
|
||||
}
|
||||
|
||||
/** Saves the access token to a file for further reuse. */
|
||||
|
||||
/** Save the access token to a file for further reuse. */
|
||||
saveAccessToken(accessToken: string) {
|
||||
if (this.amplifyConfig.accessTokenSaver) {
|
||||
this.amplifyConfig.accessTokenSaver(accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the current {@link UserSession}, or `None` if the user is not logged in.
|
||||
/** Return the current {@link UserSession}, or `None` if the user is not logged in.
|
||||
*
|
||||
* Will refresh the {@link UserSession} if it has expired. */
|
||||
userSession() {
|
||||
@ -165,7 +176,7 @@ export class Cognito {
|
||||
return signUp(username, password, this.platform)
|
||||
}
|
||||
|
||||
/** Sends the email address verification code.
|
||||
/** Send the email address verification code.
|
||||
*
|
||||
* The user will receive a link in their email. The user must click the link to go to the email
|
||||
* verification page. The email verification page will parse the verification code from the URL.
|
||||
@ -175,7 +186,7 @@ export class Cognito {
|
||||
return confirmSignUp(email, code)
|
||||
}
|
||||
|
||||
/** Signs in via the Google federated identity provider.
|
||||
/** Sign in via the Google federated identity provider.
|
||||
*
|
||||
* This function will open the Google authentication page in the user's browser. The user will
|
||||
* be asked to log in to their Google account, and then to grant access to the application.
|
||||
@ -184,7 +195,7 @@ export class Cognito {
|
||||
return signInWithGoogle(this.customState())
|
||||
}
|
||||
|
||||
/** Signs in via the GitHub federated identity provider.
|
||||
/** Sign in via the GitHub federated identity provider.
|
||||
*
|
||||
* This function will open the GitHub authentication page in the user's browser. The user will
|
||||
* be asked to log in to their GitHub account, and then to grant access to the application.
|
||||
@ -193,19 +204,19 @@ export class Cognito {
|
||||
return signInWithGitHub()
|
||||
}
|
||||
|
||||
/** Signs in with the given username and password.
|
||||
/** Sign in with the given username and password.
|
||||
*
|
||||
* Does not rely on external identity providers (e.g., Google or GitHub). */
|
||||
signInWithPassword(username: string, password: string) {
|
||||
return signInWithPassword(username, password)
|
||||
}
|
||||
|
||||
/** Signs out the current user. */
|
||||
/** Sign out the current user. */
|
||||
signOut() {
|
||||
return signOut(this.logger)
|
||||
}
|
||||
|
||||
/** Sends a password reset email.
|
||||
/** Send a password reset email.
|
||||
*
|
||||
* The user will be able to reset their password by following the link in the email, which takes
|
||||
* them to the "reset password" page of the application. The verification code will be filled in
|
||||
@ -214,7 +225,7 @@ export class Cognito {
|
||||
return forgotPassword(email)
|
||||
}
|
||||
|
||||
/** Submits a new password for the given email address.
|
||||
/** Submit a new password for the given email address.
|
||||
*
|
||||
* The user will have received a verification code in an email, which they will have entered on
|
||||
* the "reset password" page of the application. This function will submit the new password
|
||||
@ -274,12 +285,13 @@ export interface UserSession {
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
/** Return the current `CognitoUserSession`, if one exists. */
|
||||
async function userSession() {
|
||||
const amplifySession = await getAmplifyCurrentSession()
|
||||
return amplifySession.map(parseUserSession).toOption()
|
||||
}
|
||||
|
||||
/** Returns the current `CognitoUserSession` if the user is logged in, or `CurrentSessionErrorKind`
|
||||
/** Return the current `CognitoUserSession` if the user is logged in, or `CurrentSessionErrorKind`
|
||||
* otherwise.
|
||||
*
|
||||
* Will refresh the session if it has expired. */
|
||||
@ -288,7 +300,7 @@ async function getAmplifyCurrentSession() {
|
||||
return currentSession.mapErr(intoCurrentSessionErrorKind)
|
||||
}
|
||||
|
||||
/** Parses a `CognitoUserSession` into a `UserSession`.
|
||||
/** Parse a `CognitoUserSession` into a {@link UserSession}.
|
||||
* @throws If the `email` field of the payload is not a string. */
|
||||
function parseUserSession(session: cognito.CognitoUserSession): UserSession {
|
||||
const payload: Record<string, unknown> = session.getIdToken().payload
|
||||
@ -307,8 +319,12 @@ const CURRENT_SESSION_NO_CURRENT_USER_ERROR = {
|
||||
kind: 'NoCurrentUser',
|
||||
} as const
|
||||
|
||||
/** Internal IDs of errors that may occur when getting the current session. */
|
||||
type CurrentSessionErrorKind = (typeof CURRENT_SESSION_NO_CURRENT_USER_ERROR)['kind']
|
||||
|
||||
/** Convert an {@link AmplifyError} into a {@link CurrentSessionErrorKind} if it is a known error,
|
||||
* else re-throws the error.
|
||||
* @throws {Error} If the error is not recognized. */
|
||||
function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind {
|
||||
if (error === CURRENT_SESSION_NO_CURRENT_USER_ERROR.internalMessage) {
|
||||
return CURRENT_SESSION_NO_CURRENT_USER_ERROR.kind
|
||||
@ -321,13 +337,17 @@ function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind {
|
||||
// === SignUp ===
|
||||
// ==============
|
||||
|
||||
function signUp(username: string, password: string, platform: platformModule.Platform) {
|
||||
return results.Result.wrapAsync(async () => {
|
||||
/** A wrapper around the Amplify "sign up" endpoint that converts known errors
|
||||
* to {@link SignUpError}s. */
|
||||
async function signUp(username: string, password: string, platform: platformModule.Platform) {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
const params = intoSignUpParams(username, password, platform)
|
||||
await amplify.Auth.signUp(params)
|
||||
}).then(result => result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignUpErrorOrThrow))
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignUpErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Format a username and password as an {@link amplify.SignUpParams}. */
|
||||
function intoSignUpParams(
|
||||
username: string,
|
||||
password: string,
|
||||
@ -368,16 +388,21 @@ const SIGN_UP_INVALID_PASSWORD_ERROR = {
|
||||
kind: 'InvalidPassword',
|
||||
} as const
|
||||
|
||||
/** Internal IDs of errors that may occur when signing up. */
|
||||
type SignUpErrorKind =
|
||||
| (typeof SIGN_UP_INVALID_PARAMETER_ERROR)['kind']
|
||||
| (typeof SIGN_UP_INVALID_PASSWORD_ERROR)['kind']
|
||||
| (typeof SIGN_UP_USERNAME_EXISTS_ERROR)['kind']
|
||||
|
||||
export interface SignUpError {
|
||||
/** An error that may occur when signing up. */
|
||||
export interface SignUpError extends CognitoError {
|
||||
kind: SignUpErrorKind
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Convert an {@link AmplifyError} into a {@link SignUpError} if it is a known error,
|
||||
* else re-throws the error.
|
||||
* @throws {Error} If the error is not recognized. */
|
||||
function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError {
|
||||
if (error.code === SIGN_UP_USERNAME_EXISTS_ERROR.internalCode) {
|
||||
return {
|
||||
@ -403,6 +428,8 @@ function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError {
|
||||
// === ConfirmSignUp ===
|
||||
// =====================
|
||||
|
||||
/** A wrapper around the Amplify "confirm sign up" endpoint that converts known errors
|
||||
* to {@link ConfirmSignUpError}s. */
|
||||
async function confirmSignUp(email: string, code: string) {
|
||||
return results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.confirmSignUp(email, code)
|
||||
@ -415,13 +442,18 @@ const CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR = {
|
||||
kind: 'UserAlreadyConfirmed',
|
||||
} as const
|
||||
|
||||
/** Internal IDs of errors that may occur when confirming registration. */
|
||||
type ConfirmSignUpErrorKind = (typeof CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR)['kind']
|
||||
|
||||
export interface ConfirmSignUpError {
|
||||
/** An error that may occur when confirming registration. */
|
||||
export interface ConfirmSignUpError extends CognitoError {
|
||||
kind: ConfirmSignUpErrorKind
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Convert an {@link AmplifyError} into a {@link ConfirmSignUpError} if it is a known error,
|
||||
* else re-throws the error.
|
||||
* @throws {Error} If the error is not recognized. */
|
||||
function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError {
|
||||
if (
|
||||
error.code === CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.internalCode &&
|
||||
@ -443,6 +475,7 @@ function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError
|
||||
// === SignInWithGoogle ===
|
||||
// ========================
|
||||
|
||||
/** A wrapper around the Amplify "sign in with Google" endpoint. */
|
||||
async function signInWithGoogle(customState: string | null) {
|
||||
const provider = amplify.CognitoHostedUIIdentityProvider.Google
|
||||
const options = {
|
||||
@ -456,6 +489,7 @@ async function signInWithGoogle(customState: string | null) {
|
||||
// === SignInWithGoogle ===
|
||||
// ========================
|
||||
|
||||
/** A wrapper around the Amplify confirm "sign in with GitHub" endpoint. */
|
||||
async function signInWithGitHub() {
|
||||
await amplify.Auth.federatedSignIn({
|
||||
customProvider: GITHUB_PROVIDER,
|
||||
@ -466,21 +500,27 @@ async function signInWithGitHub() {
|
||||
// === SignInWithPassword ===
|
||||
// ==========================
|
||||
|
||||
/** A wrapper around the Amplify "sign in with password" endpoint that converts known errors
|
||||
* to {@link SignInWithPasswordError}s. */
|
||||
async function signInWithPassword(username: string, password: string) {
|
||||
return results.Result.wrapAsync(async () => {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.signIn(username, password)
|
||||
}).then(result =>
|
||||
result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow)
|
||||
)
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Internal IDs of errors that may occur when signing in with a password. */
|
||||
type SignInWithPasswordErrorKind = 'NotAuthorized' | 'UserNotConfirmed' | 'UserNotFound'
|
||||
|
||||
export interface SignInWithPasswordError {
|
||||
/** An error that may occur when signing in with a password. */
|
||||
export interface SignInWithPasswordError extends CognitoError {
|
||||
kind: SignInWithPasswordErrorKind
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Convert an {@link AmplifyError} into a {@link SignInWithPasswordError} if it is a known error,
|
||||
* else re-throws the error.
|
||||
* @throws {Error} If the error is not recognized. */
|
||||
function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInWithPasswordError {
|
||||
switch (error.code) {
|
||||
case 'UserNotFoundException':
|
||||
@ -509,27 +549,41 @@ function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInWithPass
|
||||
const FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR = {
|
||||
internalCode: 'InvalidParameterException',
|
||||
kind: 'UserNotConfirmed',
|
||||
message:
|
||||
'Cannot reset password for the user as there is no registered/verified email or phone_number',
|
||||
message: `Cannot reset password for the user as there is no registered/verified email or \
|
||||
phone_number`,
|
||||
} as const
|
||||
|
||||
const FORGOT_PASSWORD_USER_NOT_FOUND_ERROR = {
|
||||
internalCode: 'UserNotFoundException',
|
||||
kind: 'UserNotFound',
|
||||
} as const
|
||||
|
||||
/** A wrapper around the Amplify "forgot password" endpoint that converts known errors
|
||||
* to {@link ForgotPasswordError}s. */
|
||||
async function forgotPassword(email: string) {
|
||||
return results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.forgotPassword(email)
|
||||
}).then(result => result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoForgotPasswordErrorOrThrow))
|
||||
}
|
||||
|
||||
type ForgotPasswordErrorKind = 'UserNotConfirmed' | 'UserNotFound'
|
||||
/** Internal IDs of errors that may occur when requesting a password reset. */
|
||||
type ForgotPasswordErrorKind =
|
||||
| (typeof FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR)['kind']
|
||||
| (typeof FORGOT_PASSWORD_USER_NOT_FOUND_ERROR)['kind']
|
||||
|
||||
export interface ForgotPasswordError {
|
||||
/** An error that may occur when requesting a password reset. */
|
||||
export interface ForgotPasswordError extends CognitoError {
|
||||
kind: ForgotPasswordErrorKind
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Convert an {@link AmplifyError} into a {@link ForgotPasswordError} if it is a known error,
|
||||
* else re-throws the error.
|
||||
* @throws {Error} If the error is not recognized. */
|
||||
function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordError {
|
||||
if (error.code === 'UserNotFoundException') {
|
||||
if (error.code === FORGOT_PASSWORD_USER_NOT_FOUND_ERROR.internalCode) {
|
||||
return {
|
||||
kind: 'UserNotFound',
|
||||
kind: FORGOT_PASSWORD_USER_NOT_FOUND_ERROR.kind,
|
||||
message: MESSAGES.forgotPassword.userNotFound,
|
||||
}
|
||||
} else if (
|
||||
@ -549,19 +603,27 @@ function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordErro
|
||||
// === ForgotPasswordSubmit ===
|
||||
// ============================
|
||||
|
||||
/** A wrapper around the Amplify "forgot password submit" endpoint that converts known errors
|
||||
* to {@link ForgotPasswordSubmitError}s. */
|
||||
async function forgotPasswordSubmit(email: string, code: string, password: string) {
|
||||
return results.Result.wrapAsync(async () => {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.forgotPasswordSubmit(email, code, password)
|
||||
}).then(result => result.mapErr(intoForgotPasswordSubmitErrorOrThrow))
|
||||
})
|
||||
return result.mapErr(intoForgotPasswordSubmitErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Internal IDs of errors that may occur when resetting a password. */
|
||||
type ForgotPasswordSubmitErrorKind = 'AmplifyError' | 'AuthError'
|
||||
|
||||
export interface ForgotPasswordSubmitError {
|
||||
/** An error that may occur when resetting a password. */
|
||||
export interface ForgotPasswordSubmitError extends CognitoError {
|
||||
kind: ForgotPasswordSubmitErrorKind
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Convert an {@link AmplifyError} into a {@link ForgotPasswordSubmitError}
|
||||
* if it is a known error, else re-throws the error.
|
||||
* @throws {Error} If the error is not recognized. */
|
||||
function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSubmitError {
|
||||
if (isAuthError(error)) {
|
||||
return {
|
||||
@ -582,6 +644,7 @@ function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSub
|
||||
// === SignOut ===
|
||||
// ===============
|
||||
|
||||
/** A wrapper around the Amplify "sign out" endpoint. */
|
||||
async function signOut(logger: loggerProvider.Logger) {
|
||||
// FIXME [NP]: https://github.com/enso-org/cloud-v2/issues/341
|
||||
// For some reason, the redirect back to the IDE from the browser doesn't work correctly so this
|
||||
@ -605,24 +668,30 @@ async function signOut(logger: loggerProvider.Logger) {
|
||||
// === ChangePassword ===
|
||||
// ======================
|
||||
|
||||
/** A wrapper around the Amplify "current authenticated user" endpoint that converts known errors
|
||||
* to {@link AmplifyError}s. */
|
||||
async function currentAuthenticatedUser() {
|
||||
const result = await results.Result.wrapAsync(
|
||||
/** The interface provided by Amplify declares that the return type is `Promise<CognitoUser | any>`,
|
||||
* but TypeScript automatically converts it to `Promise<any>`. Therefore, it is necessary to use
|
||||
* `as` to narrow down the type to `Promise<CognitoUser>`. */
|
||||
/** The interface provided by Amplify declares that the return type is
|
||||
* `Promise<CognitoUser | any>`, but TypeScript automatically converts it to `Promise<any>`.
|
||||
* Therefore, it is necessary to use `as` to narrow down the type to
|
||||
* `Promise<CognitoUser>`. */
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
() => amplify.Auth.currentAuthenticatedUser() as Promise<amplify.CognitoUser>
|
||||
)
|
||||
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||
}
|
||||
|
||||
/** A wrapper around the Amplify "change password submit" endpoint that converts known errors
|
||||
* to {@link AmplifyError}s. */
|
||||
async function changePassword(oldPassword: string, newPassword: string) {
|
||||
const cognitoUserResult = await currentAuthenticatedUser()
|
||||
if (cognitoUserResult.ok) {
|
||||
const cognitoUser = cognitoUserResult.unwrap()
|
||||
return results.Result.wrapAsync(async () => {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.changePassword(cognitoUser, oldPassword, newPassword)
|
||||
}).then(result => result.mapErr(intoAmplifyErrorOrThrow))
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||
} else {
|
||||
return results.Err(cognitoUserResult.val)
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ const REGISTRATION_QUERY_PARAMS = {
|
||||
// === Confirm Registration ===
|
||||
// ============================
|
||||
|
||||
/** An empty component redirecting users based on the backend response to user registration. */
|
||||
function ConfirmRegistration() {
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { confirmSignUp } = auth.useAuth()
|
||||
@ -50,6 +51,7 @@ function ConfirmRegistration() {
|
||||
return <></>
|
||||
}
|
||||
|
||||
/** Return an object containing the query parameters, with keys renamed to `camelCase`. */
|
||||
function parseUrlSearchParams(search: string) {
|
||||
const query = new URLSearchParams(search)
|
||||
const verificationCode = query.get(REGISTRATION_QUERY_PARAMS.verificationCode)
|
||||
|
@ -6,10 +6,12 @@ import * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons'
|
||||
// === FontAwesomeIcon ===
|
||||
// =======================
|
||||
|
||||
/** Props for a {@link FontAwesomeIcon}. */
|
||||
export interface FontAwesomeIconProps {
|
||||
icon: fontawesomeIcons.IconDefinition
|
||||
}
|
||||
|
||||
/** A fixed-size container for a {@link fontawesome.FontAwesomeIcon FontAwesomeIcon}. */
|
||||
function FontAwesomeIcon(props: FontAwesomeIconProps) {
|
||||
return (
|
||||
<span
|
||||
|
@ -14,6 +14,7 @@ import SvgIcon from './svgIcon'
|
||||
// === ForgotPassword ===
|
||||
// ======================
|
||||
|
||||
/** A form for users to request for their password to be reset. */
|
||||
function ForgotPassword() {
|
||||
const { forgotPassword } = auth.useAuth()
|
||||
|
||||
|
@ -5,11 +5,13 @@ import * as react from 'react'
|
||||
// === Input ===
|
||||
// =============
|
||||
|
||||
/** Props for an {@link Input}. */
|
||||
export interface InputProps extends react.InputHTMLAttributes<HTMLInputElement> {
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
}
|
||||
|
||||
/** A component for authentication from inputs, with preset styles. */
|
||||
function Input(props: InputProps) {
|
||||
const { setValue, ...passThrough } = props
|
||||
return (
|
||||
@ -18,10 +20,7 @@ function Input(props: InputProps) {
|
||||
onChange={event => {
|
||||
setValue(event.target.value)
|
||||
}}
|
||||
className={
|
||||
'text-sm sm:text-base placeholder-gray-500 pl-10 pr-4 rounded-lg border border-gray-400 ' +
|
||||
'w-full py-2 focus:outline-none focus:border-blue-400'
|
||||
}
|
||||
className="text-sm sm:text-base placeholder-gray-500 pl-10 pr-4 rounded-lg border border-gray-400 w-full py-2 focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ const LOGIN_QUERY_PARAMS = {
|
||||
// === Login ===
|
||||
// =============
|
||||
|
||||
/** A form for users to log in. */
|
||||
function Login() {
|
||||
const { search } = router.useLocation()
|
||||
const { signInWithGoogle, signInWithGitHub, signInWithPassword } = auth.useAuth()
|
||||
@ -164,6 +165,7 @@ function Login() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Return an object containing the query parameters, with keys renamed to `camelCase`. */
|
||||
function parseUrlSearchParams(search: string) {
|
||||
const query = new URLSearchParams(search)
|
||||
const email = query.get(LOGIN_QUERY_PARAMS.email)
|
||||
|
@ -13,6 +13,7 @@ import SvgIcon from './svgIcon'
|
||||
// === Registration ===
|
||||
// ====================
|
||||
|
||||
/** A form for users to register an account. */
|
||||
function Registration() {
|
||||
const { signUp } = auth.useAuth()
|
||||
const [email, setEmail] = react.useState('')
|
||||
|
@ -23,6 +23,7 @@ const RESET_PASSWORD_QUERY_PARAMS = {
|
||||
// === ResetPassword ===
|
||||
// =====================
|
||||
|
||||
/** A form for users to reset their password. */
|
||||
function ResetPassword() {
|
||||
const { resetPassword } = auth.useAuth()
|
||||
const { search } = router.useLocation()
|
||||
@ -173,6 +174,7 @@ function ResetPassword() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Return an object containing the query parameters, with keys renamed to `camelCase`. */
|
||||
function parseUrlSearchParams(search: string) {
|
||||
const query = new URLSearchParams(search)
|
||||
const verificationCode = query.get(RESET_PASSWORD_QUERY_PARAMS.verificationCode)
|
||||
|
@ -13,6 +13,7 @@ import SvgIcon from './svgIcon'
|
||||
// === SetUsername ===
|
||||
// ===================
|
||||
|
||||
/** A form for users to set their username upon registration. */
|
||||
function SetUsername() {
|
||||
const { setUsername: authSetUsername } = auth.useAuth()
|
||||
const { email } = auth.usePartialUserSession()
|
||||
|
@ -1,13 +1,15 @@
|
||||
/** @file Styled wrapper around {@link Svg} icons. */
|
||||
/** @file Styled wrapper around SVG images. */
|
||||
|
||||
// ===============
|
||||
// === SvgIcon ===
|
||||
// ===============
|
||||
|
||||
interface SvgIconProps {
|
||||
/** Props for a {@link SvgIcon}. */
|
||||
export interface SvgIconProps {
|
||||
svg: JSX.Element
|
||||
}
|
||||
|
||||
/** A fixed-size container for a SVG image. */
|
||||
function SvgIcon(props: SvgIconProps) {
|
||||
return (
|
||||
<div
|
||||
|
@ -30,11 +30,6 @@ export const OAUTH_RESPONSE_TYPE = newtype.asNewtype<OAuthResponseType>('code')
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** The types in this section use "branded types". These are types that intersect with an interface
|
||||
* with a private `$brand` property. Branding a type makes the resulting type unique from its base
|
||||
* type. This makes it impossible to erroneously provide regular `string`s to functions that expect
|
||||
* an `AwsRegion`, etc. */
|
||||
|
||||
/** The AWS region in which our Cognito pool is located. This is always set to `eu-west-1` because
|
||||
* that is the only region in which our Cognito pools are currently available in. */
|
||||
type AwsRegion = newtype.Newtype<string, 'AwsRegion'>
|
||||
@ -65,13 +60,13 @@ type OAuthResponseType = newtype.Newtype<string, 'OAuthResponseType'>
|
||||
* Cognito pool and during the creation of the OAuth client. See the `enso-org/cloud-v2` repo for
|
||||
* details. */
|
||||
export type OAuthRedirect = newtype.Newtype<string, 'OAuthRedirect'>
|
||||
/** Callback used to open URLs for the OAuth flow. This is only used in the desktop app (i.e., not in
|
||||
/** Callback used to open URLs for the OAuth flow. This is only used in the desktop app (i.e. not in
|
||||
* the cloud). This is because in the cloud we just keep the user in their browser, but in the app
|
||||
* we want to open OAuth URLs in the system browser. This is because the user can't be expected to
|
||||
* trust their credentials to an Electron app. */
|
||||
export type OAuthUrlOpener = (url: string, redirectUrl: string) => void
|
||||
/** A function used to save access token to a Enso credentials file. The token is used by the engine to issue
|
||||
* http request to cloud API. */
|
||||
/** A function used to save the access token to a credentials file. The token is used by the engine
|
||||
* to issue HTTP requests to the cloud API. */
|
||||
export type AccessTokenSaver = (accessToken: string) => void
|
||||
/** Function used to register a callback. The callback will get called when a deep link is received
|
||||
* by the app. This is only used in the desktop app (i.e., not in the cloud). This is used when the
|
||||
@ -105,10 +100,12 @@ export interface AmplifyConfig {
|
||||
// === NestedAmplifyConfig ===
|
||||
// ===========================
|
||||
|
||||
/** Configuration options for a {@link OauthAmplifyConfig}. */
|
||||
interface OauthAmplifyConfigOptions {
|
||||
urlOpener?: OAuthUrlOpener
|
||||
}
|
||||
|
||||
/** OAuth configuration for a {@link NestedAmplifyConfig}. */
|
||||
interface OauthAmplifyConfig {
|
||||
options: OauthAmplifyConfigOptions
|
||||
domain: OAuthDomain
|
||||
@ -126,7 +123,7 @@ export interface NestedAmplifyConfig {
|
||||
oauth: OauthAmplifyConfig
|
||||
}
|
||||
|
||||
/** Converts the flattened `AmplifyConfig` struct to a form recognizable to the AWS Amplify library.
|
||||
/** Convert the flattened `AmplifyConfig` struct to a form recognizable to the AWS Amplify library.
|
||||
*
|
||||
* We use a flattened form of the config for easier object manipulation, but the AWS Amplify library
|
||||
* expects a nested form. */
|
||||
|
@ -31,7 +31,7 @@ export enum AuthEvent {
|
||||
signOut = 'signOut',
|
||||
}
|
||||
|
||||
/** Returns `true` if the given `string` is an {@link AuthEvent}. */
|
||||
/** Return `true` if the given `string` is an {@link AuthEvent}. */
|
||||
function isAuthEvent(value: string): value is AuthEvent {
|
||||
return Object.values<string>(AuthEvent).includes(value)
|
||||
}
|
||||
@ -45,7 +45,7 @@ function isAuthEvent(value: string): value is AuthEvent {
|
||||
* @see {@link Api["listen"]} */
|
||||
export type ListenerCallback = (event: AuthEvent, data?: unknown) => void
|
||||
|
||||
/** Unsubscribes the {@link ListenerCallback} from authentication state changes.
|
||||
/** Unsubscribe the {@link ListenerCallback} from authentication state changes.
|
||||
*
|
||||
* @see {@link Api["listen"]} */
|
||||
type UnsubscribeFunction = () => void
|
||||
@ -56,11 +56,11 @@ type UnsubscribeFunction = () => void
|
||||
* to avoid memory leaks or duplicate event handlers. */
|
||||
export type ListenFunction = (listener: ListenerCallback) => UnsubscribeFunction
|
||||
|
||||
/** Listen to authentication state changes. */
|
||||
export function registerAuthEventListener(listener: ListenerCallback) {
|
||||
const callback: amplify.HubCallback = data => {
|
||||
return amplify.Hub.listen(AUTHENTICATION_HUB, data => {
|
||||
if (isAuthEvent(data.payload.event)) {
|
||||
listener(data.payload.event, data.payload.data)
|
||||
}
|
||||
}
|
||||
return amplify.Hub.listen(AUTHENTICATION_HUB, callback)
|
||||
})
|
||||
}
|
||||
|
@ -41,6 +41,8 @@ const MESSAGES = {
|
||||
|
||||
// === UserSession ===
|
||||
|
||||
/** A user session for a user that may be either fully registered,
|
||||
* or in the process of registering. */
|
||||
export type UserSession = FullUserSession | PartialUserSession
|
||||
|
||||
/** Object containing the currently signed-in user's session data. */
|
||||
@ -135,6 +137,7 @@ const AuthContext = react.createContext<AuthContextType>({} as AuthContextType)
|
||||
// === AuthProvider ===
|
||||
// ====================
|
||||
|
||||
/** Props for an {@link AuthProvider}. */
|
||||
export interface AuthProviderProps {
|
||||
authService: authServiceModule.AuthService
|
||||
/** Callback to execute once the user has authenticated successfully. */
|
||||
@ -142,6 +145,7 @@ export interface AuthProviderProps {
|
||||
children: react.ReactNode
|
||||
}
|
||||
|
||||
/** A React provider for the Cognito API. */
|
||||
export function AuthProvider(props: AuthProviderProps) {
|
||||
const { authService, children } = props
|
||||
const { cognito } = authService
|
||||
@ -208,6 +212,8 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
})
|
||||
}, [session])
|
||||
|
||||
/** Wrap a function returning a {@link Promise} to displays a loading toast notification
|
||||
* until the returned {@link Promise} finishes loading. */
|
||||
const withLoadingToast =
|
||||
<T extends unknown[], R>(action: (...args: T) => Promise<R>) =>
|
||||
async (...args: T) => {
|
||||
@ -221,45 +227,45 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
return result
|
||||
}
|
||||
|
||||
const signUp = (username: string, password: string) =>
|
||||
cognito.signUp(username, password).then(result => {
|
||||
if (result.ok) {
|
||||
toast.success(MESSAGES.signUpSuccess)
|
||||
} else {
|
||||
toast.error(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
})
|
||||
const signUp = async (username: string, password: string) => {
|
||||
const result = await cognito.signUp(username, password)
|
||||
if (result.ok) {
|
||||
toast.success(MESSAGES.signUpSuccess)
|
||||
} else {
|
||||
toast.error(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
}
|
||||
|
||||
const confirmSignUp = async (email: string, code: string) =>
|
||||
cognito.confirmSignUp(email, code).then(result => {
|
||||
if (result.err) {
|
||||
switch (result.val.kind) {
|
||||
case 'UserAlreadyConfirmed':
|
||||
break
|
||||
default:
|
||||
throw new errorModule.UnreachableCaseError(result.val.kind)
|
||||
}
|
||||
const confirmSignUp = async (email: string, code: string) => {
|
||||
const result = await cognito.confirmSignUp(email, code)
|
||||
if (result.err) {
|
||||
switch (result.val.kind) {
|
||||
case 'UserAlreadyConfirmed':
|
||||
break
|
||||
default:
|
||||
throw new errorModule.UnreachableCaseError(result.val.kind)
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(MESSAGES.confirmSignUpSuccess)
|
||||
navigate(app.LOGIN_PATH)
|
||||
return result.ok
|
||||
}
|
||||
|
||||
const signInWithPassword = async (email: string, password: string) => {
|
||||
const result = await cognito.signInWithPassword(email, password)
|
||||
if (result.ok) {
|
||||
toast.success(MESSAGES.signInWithPasswordSuccess)
|
||||
} else {
|
||||
if (result.val.kind === 'UserNotFound') {
|
||||
navigate(app.REGISTRATION_PATH)
|
||||
}
|
||||
|
||||
toast.success(MESSAGES.confirmSignUpSuccess)
|
||||
navigate(app.LOGIN_PATH)
|
||||
return result.ok
|
||||
})
|
||||
|
||||
const signInWithPassword = async (email: string, password: string) =>
|
||||
cognito.signInWithPassword(email, password).then(result => {
|
||||
if (result.ok) {
|
||||
toast.success(MESSAGES.signInWithPasswordSuccess)
|
||||
} else {
|
||||
if (result.val.kind === 'UserNotFound') {
|
||||
navigate(app.REGISTRATION_PATH)
|
||||
}
|
||||
|
||||
toast.error(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
})
|
||||
toast.error(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
}
|
||||
|
||||
const setUsername = async (
|
||||
backend: backendProvider.AnyBackendAPI,
|
||||
@ -285,41 +291,43 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const forgotPassword = async (email: string) =>
|
||||
cognito.forgotPassword(email).then(result => {
|
||||
if (result.ok) {
|
||||
toast.success(MESSAGES.forgotPasswordSuccess)
|
||||
navigate(app.RESET_PASSWORD_PATH)
|
||||
} else {
|
||||
toast.error(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
})
|
||||
const forgotPassword = async (email: string) => {
|
||||
const result = await cognito.forgotPassword(email)
|
||||
if (result.ok) {
|
||||
toast.success(MESSAGES.forgotPasswordSuccess)
|
||||
navigate(app.RESET_PASSWORD_PATH)
|
||||
} else {
|
||||
toast.error(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
}
|
||||
|
||||
const resetPassword = async (email: string, code: string, password: string) =>
|
||||
cognito.forgotPasswordSubmit(email, code, password).then(result => {
|
||||
if (result.ok) {
|
||||
toast.success(MESSAGES.resetPasswordSuccess)
|
||||
navigate(app.LOGIN_PATH)
|
||||
} else {
|
||||
toast.error(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
})
|
||||
const changePassword = async (oldPassword: string, newPassword: string) =>
|
||||
cognito.changePassword(oldPassword, newPassword).then(result => {
|
||||
if (result.ok) {
|
||||
toast.success(MESSAGES.changePasswordSuccess)
|
||||
} else {
|
||||
toast.error(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
})
|
||||
const signOut = () =>
|
||||
cognito.signOut().then(() => {
|
||||
toast.success(MESSAGES.signOutSuccess)
|
||||
return true
|
||||
})
|
||||
const resetPassword = async (email: string, code: string, password: string) => {
|
||||
const result = await cognito.forgotPasswordSubmit(email, code, password)
|
||||
if (result.ok) {
|
||||
toast.success(MESSAGES.resetPasswordSuccess)
|
||||
navigate(app.LOGIN_PATH)
|
||||
} else {
|
||||
toast.error(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
}
|
||||
|
||||
const changePassword = async (oldPassword: string, newPassword: string) => {
|
||||
const result = await cognito.changePassword(oldPassword, newPassword)
|
||||
if (result.ok) {
|
||||
toast.success(MESSAGES.changePasswordSuccess)
|
||||
} else {
|
||||
toast.error(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
await cognito.signOut()
|
||||
toast.success(MESSAGES.signOutSuccess)
|
||||
return true
|
||||
}
|
||||
|
||||
const value = {
|
||||
signUp: withLoadingToast(signUp),
|
||||
@ -360,7 +368,7 @@ interface UserFacingError {
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Returns `true` if the value is a {@link UserFacingError}. */
|
||||
/** Return `true` if the value is a {@link UserFacingError}. */
|
||||
function isUserFacingError(value: unknown): value is UserFacingError {
|
||||
return typeof value === 'object' && value != null && 'message' in value
|
||||
}
|
||||
@ -381,6 +389,7 @@ export function useAuth() {
|
||||
// === ProtectedLayout ===
|
||||
// =======================
|
||||
|
||||
/** A React Router layout route containing routes only accessible by users that are logged in. */
|
||||
export function ProtectedLayout() {
|
||||
const { session } = useAuth()
|
||||
|
||||
@ -397,6 +406,8 @@ export function ProtectedLayout() {
|
||||
// === SemiProtectedLayout ===
|
||||
// ===========================
|
||||
|
||||
/** A React Router layout route containing routes only accessible by users that are
|
||||
* in the process of registering. */
|
||||
export function SemiProtectedLayout() {
|
||||
const { session } = useAuth()
|
||||
|
||||
@ -411,6 +422,8 @@ export function SemiProtectedLayout() {
|
||||
// === GuestLayout ===
|
||||
// ===================
|
||||
|
||||
/** A React Router layout route containing routes only accessible by users that are
|
||||
* not logged in. */
|
||||
export function GuestLayout() {
|
||||
const { session } = useAuth()
|
||||
|
||||
@ -427,6 +440,8 @@ export function GuestLayout() {
|
||||
// === usePartialUserSession ===
|
||||
// =============================
|
||||
|
||||
/** A React context hook returning the user session
|
||||
* for a user that has not yet completed registration. */
|
||||
export function usePartialUserSession() {
|
||||
return router.useOutletContext<PartialUserSession>()
|
||||
}
|
||||
@ -435,6 +450,7 @@ export function usePartialUserSession() {
|
||||
// === useFullUserSession ===
|
||||
// ==========================
|
||||
|
||||
/** A React context hook returning the user session for a user that has completed registration. */
|
||||
export function useFullUserSession() {
|
||||
return router.useOutletContext<FullUserSession>()
|
||||
}
|
||||
|
@ -13,11 +13,12 @@ import * as listen from '../listen'
|
||||
// === SessionContext ===
|
||||
// ======================
|
||||
|
||||
/** State contained in a {@link SessionContext}. */
|
||||
interface SessionContextType {
|
||||
session: results.Option<cognito.UserSession>
|
||||
}
|
||||
|
||||
/** See {@link AuthContext} for safety details. */
|
||||
/** See `AuthContext` for safety details. */
|
||||
const SessionContext = react.createContext<SessionContextType>(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
{} as SessionContextType
|
||||
@ -27,7 +28,8 @@ const SessionContext = react.createContext<SessionContextType>(
|
||||
// === SessionProvider ===
|
||||
// =======================
|
||||
|
||||
interface SessionProviderProps {
|
||||
/** Props for a {@link SessionProvider}. */
|
||||
export interface SessionProviderProps {
|
||||
/** URL that the content of the app is served at, by Electron.
|
||||
*
|
||||
* This **must** be the actual page that the content is served at, otherwise the OAuth flow will
|
||||
@ -45,6 +47,7 @@ interface SessionProviderProps {
|
||||
children: react.ReactNode
|
||||
}
|
||||
|
||||
/** A React provider for the session of the authenticated user. */
|
||||
export function SessionProvider(props: SessionProviderProps) {
|
||||
const { mainPageUrl, children, userSession, registerAuthEventListener } = props
|
||||
|
||||
@ -74,6 +77,8 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
* For example, if a user clicks the signout button, this will clear the user's session, which
|
||||
* means we want the login screen to render (which is a child of this provider). */
|
||||
react.useEffect(() => {
|
||||
/** Handle Cognito authentication events
|
||||
* @throws {error.UnreachableCaseError} Never. */
|
||||
const listener: listen.ListenerCallback = event => {
|
||||
switch (event) {
|
||||
case listen.AuthEvent.signIn:
|
||||
@ -118,6 +123,7 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
// === useSession ===
|
||||
// ==================
|
||||
|
||||
/** React context hook returning the session of the authenticated user. */
|
||||
export function useSession() {
|
||||
return react.useContext(SessionContext)
|
||||
}
|
||||
|
@ -109,7 +109,7 @@ export interface AuthService {
|
||||
registerAuthEventListener: listen.ListenFunction
|
||||
}
|
||||
|
||||
/** Creates an instance of the authentication service.
|
||||
/** Create an instance of the authentication service.
|
||||
*
|
||||
* # Warning
|
||||
*
|
||||
@ -125,6 +125,7 @@ export function initAuthService(authConfig: AuthConfig): AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the appropriate Amplify configuration for the current platform. */
|
||||
function loadAmplifyConfig(
|
||||
logger: loggerProvider.Logger,
|
||||
platform: platformModule.Platform,
|
||||
@ -146,7 +147,7 @@ function loadAmplifyConfig(
|
||||
urlOpener = openUrlWithExternalBrowser
|
||||
|
||||
/** When running on destop we want to have option to save access token to a file,
|
||||
* so it can be later reuse when issuing requests to Cloud API. */
|
||||
* so it can be later reused when issuing requests to the Cloud API. */
|
||||
accessTokenSaver = saveAccessToken
|
||||
|
||||
/** To handle redirects back to the application from the system browser, we also need to
|
||||
@ -163,10 +164,12 @@ function loadAmplifyConfig(
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a URL with the user's default browser. */
|
||||
function openUrlWithExternalBrowser(url: string) {
|
||||
window.authenticationApi.openUrlInSystemBrowser(url)
|
||||
}
|
||||
|
||||
/** Save the access token to a file. */
|
||||
function saveAccessToken(accessToken: string) {
|
||||
window.authenticationApi.saveAccessToken(accessToken)
|
||||
}
|
||||
|
@ -270,6 +270,7 @@ export interface SpinnerProps {
|
||||
className: string
|
||||
}
|
||||
|
||||
/** A spinning arc that animates using the `dasharray-<percentage>` custom Tailwind classes. */
|
||||
export function Spinner(props: SpinnerProps) {
|
||||
const { size, className } = props
|
||||
return (
|
||||
@ -297,6 +298,7 @@ export function Spinner(props: SpinnerProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Props for a {@link StopIcon}. */
|
||||
export interface StopIconProps {
|
||||
className?: string
|
||||
}
|
||||
@ -347,8 +349,8 @@ export function StopIcon(props: StopIconProps) {
|
||||
// === Svg ===
|
||||
// ===========
|
||||
|
||||
/** Props for the `Svg` component. */
|
||||
interface Props {
|
||||
/** Props for a {@link Svg}. */
|
||||
export interface SvgProps {
|
||||
path: string
|
||||
}
|
||||
|
||||
@ -356,7 +358,7 @@ interface Props {
|
||||
*
|
||||
* @param props - Extra props for the SVG path. The `props.data` field in particular contains the
|
||||
* SVG path data. */
|
||||
function Svg(props: Props) {
|
||||
function Svg(props: SvgProps) {
|
||||
return (
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
|
@ -1,4 +1,5 @@
|
||||
/** @file Type definitions common between all backends. */
|
||||
import * as dateTime from './dateTime'
|
||||
import * as newtype from '../newtype'
|
||||
import * as platform from '../platform'
|
||||
|
||||
@ -36,13 +37,12 @@ export type EmailAddress = newtype.Newtype<string, 'EmailAddress'>
|
||||
/** An AWS S3 file path. */
|
||||
export type S3FilePath = newtype.Newtype<string, 'S3FilePath'>
|
||||
|
||||
/** An AWS machine configuration. */
|
||||
export type Ami = newtype.Newtype<string, 'Ami'>
|
||||
|
||||
/** An AWS user ID. */
|
||||
export type Subject = newtype.Newtype<string, 'Subject'>
|
||||
|
||||
/** An RFC 3339 DateTime string. */
|
||||
export type Rfc3339DateTime = newtype.Newtype<string, 'Rfc3339DateTime'>
|
||||
|
||||
/** A user/organization in the application. These are the primary owners of a project. */
|
||||
export interface UserOrOrganization {
|
||||
id: UserOrOrganizationId
|
||||
@ -143,6 +143,7 @@ export interface SecretInfo {
|
||||
id: SecretId
|
||||
}
|
||||
|
||||
/** The type of asset a specific tag can be applied to. */
|
||||
export enum TagObjectType {
|
||||
file = 'File',
|
||||
project = 'Project',
|
||||
@ -195,7 +196,7 @@ export interface VersionNumber {
|
||||
export interface Version {
|
||||
number: VersionNumber
|
||||
ami: Ami | null
|
||||
created: Rfc3339DateTime
|
||||
created: dateTime.Rfc3339DateTime
|
||||
// This does not follow our naming convention because it's defined this way in the backend,
|
||||
// so we need to match it.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@ -212,6 +213,7 @@ export interface ResourceUsage {
|
||||
storage: number
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying a user. */
|
||||
export interface User {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
pk: Subject
|
||||
@ -221,6 +223,7 @@ export interface User {
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
|
||||
/** Backend representation of user permission types. */
|
||||
export enum PermissionAction {
|
||||
own = 'Own',
|
||||
execute = 'Execute',
|
||||
@ -228,6 +231,7 @@ export enum PermissionAction {
|
||||
read = 'Read',
|
||||
}
|
||||
|
||||
/** User permissions for a specific user. */
|
||||
export interface UserPermission {
|
||||
user: User
|
||||
permission: PermissionAction
|
||||
@ -238,11 +242,12 @@ export interface UserPermission {
|
||||
export interface BaseAsset {
|
||||
id: AssetId
|
||||
title: string
|
||||
modifiedAt: Rfc3339DateTime | null
|
||||
modifiedAt: dateTime.Rfc3339DateTime | null
|
||||
parentId: AssetId
|
||||
permissions: UserPermission[] | null
|
||||
}
|
||||
|
||||
/** All possible types of directory entries. */
|
||||
export enum AssetType {
|
||||
project = 'project',
|
||||
file = 'file',
|
||||
@ -250,6 +255,7 @@ export enum AssetType {
|
||||
directory = 'directory',
|
||||
}
|
||||
|
||||
/** The corresponding ID newtype for each {@link AssetType}. */
|
||||
export interface IdType {
|
||||
[AssetType.project]: ProjectId
|
||||
[AssetType.file]: FileId
|
||||
@ -290,10 +296,8 @@ export interface CreateProjectRequestBody {
|
||||
parentDirectoryId: DirectoryId | null
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request body for the "project update" endpoint.
|
||||
* Only updates of the `projectName` or `ami` are allowed.
|
||||
*/
|
||||
/** HTTP request body for the "project update" endpoint.
|
||||
* Only updates of the `projectName` or `ami` are allowed. */
|
||||
export interface ProjectUpdateRequestBody {
|
||||
projectName: string | null
|
||||
ami: Ami | null
|
||||
@ -320,6 +324,7 @@ export interface CreateTagRequestBody {
|
||||
objectId: string
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "list directory" endpoint. */
|
||||
export interface ListDirectoryRequestParams {
|
||||
parentId?: string
|
||||
}
|
||||
@ -346,6 +351,7 @@ export interface ListVersionsRequestParams {
|
||||
// === Type guards ===
|
||||
// ===================
|
||||
|
||||
/** A type guard that returns whether an {@link Asset} is a specific type of asset. */
|
||||
export function assetIsType<Type extends AssetType>(type: Type) {
|
||||
return (asset: Asset): asset is Asset<Type> => asset.type === type
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import Modal from './modal'
|
||||
// === ResetPasswordModal ===
|
||||
// ==========================
|
||||
|
||||
/** A modal for changing the user's password. */
|
||||
function ChangePasswordModal() {
|
||||
const { changePassword } = auth.useAuth()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
@ -49,9 +50,9 @@ function ChangePasswordModal() {
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<form
|
||||
onSubmit={event => {
|
||||
onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
void onSubmit()
|
||||
await onSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col mb-6">
|
||||
|
@ -10,6 +10,7 @@ import Modal from './modal'
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link ConfirmDeleteModal}. */
|
||||
export interface ConfirmDeleteModalProps {
|
||||
assetType: string
|
||||
name: string
|
||||
@ -17,6 +18,7 @@ export interface ConfirmDeleteModalProps {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/** A modal for confirming the deletion of an asset. */
|
||||
function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
|
||||
const { assetType, name, doDelete, onSuccess } = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
@ -6,6 +6,7 @@ import * as react from 'react'
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link ContextMenu}. */
|
||||
export interface ContextMenuProps {
|
||||
// `left: number` and `top: number` may be more correct,
|
||||
// however passing an event eliminates the chance
|
||||
@ -13,6 +14,7 @@ export interface ContextMenuProps {
|
||||
event: react.MouseEvent
|
||||
}
|
||||
|
||||
/** A context menu that opens at the current mouse position. */
|
||||
function ContextMenu(props: react.PropsWithChildren<ContextMenuProps>) {
|
||||
const { children, event } = props
|
||||
return (
|
||||
|
@ -1,13 +1,18 @@
|
||||
/** @file An entry in a context menu. */
|
||||
|
||||
import * as react from 'react'
|
||||
|
||||
// ========================
|
||||
// === ContextMenuEntry ===
|
||||
// ========================
|
||||
|
||||
/** Props for a {@link ContextMenuEntry}. */
|
||||
export interface ContextMenuEntryProps {
|
||||
disabled?: boolean
|
||||
onClick: (event: react.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
// This component MUST NOT use `useState` because it is not rendered directly.
|
||||
/** An item in a `ContextMenu`. */
|
||||
function ContextMenuEntry(props: react.PropsWithChildren<ContextMenuEntryProps>) {
|
||||
const { children, disabled, onClick } = props
|
||||
return (
|
||||
|
@ -9,6 +9,10 @@ import * as svg from '../../components/svg'
|
||||
|
||||
import Modal from './modal'
|
||||
|
||||
// ==================
|
||||
// === CreateForm ===
|
||||
// ==================
|
||||
|
||||
/** The props that should also be in the wrapper component. */
|
||||
export interface CreateFormPassthroughProps {
|
||||
left: number
|
||||
@ -21,13 +25,14 @@ export interface CreateFormProps extends CreateFormPassthroughProps, react.Props
|
||||
onSubmit: (event: react.FormEvent) => Promise<void>
|
||||
}
|
||||
|
||||
/** A form to create an element. */
|
||||
function CreateForm(props: CreateFormProps) {
|
||||
const { title, left, top, children, onSubmit: wrapperOnSubmit } = props
|
||||
const { title, left, top, children, onSubmit: innerOnSubmit } = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
async function onSubmit(event: react.FormEvent) {
|
||||
const onSubmit = async (event: react.FormEvent) => {
|
||||
event.preventDefault()
|
||||
await wrapperOnSubmit(event)
|
||||
await innerOnSubmit(event)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -46,6 +46,7 @@ export enum Tab {
|
||||
ide = 'ide',
|
||||
}
|
||||
|
||||
/** Determines which columns are visible. */
|
||||
enum ColumnDisplayMode {
|
||||
/** Show only columns which are ready for release. */
|
||||
release = 'release',
|
||||
@ -198,7 +199,7 @@ const COLUMNS_FOR: Record<ColumnDisplayMode, Column[]> = {
|
||||
// === Helper functions ===
|
||||
// ========================
|
||||
|
||||
/** Returns the id of the root directory for a user or organization. */
|
||||
/** Return the id of the root directory for a user or organization. */
|
||||
function rootDirectoryId(userOrOrganizationId: backendModule.UserOrOrganizationId) {
|
||||
return newtype.asNewtype<backendModule.DirectoryId>(
|
||||
userOrOrganizationId.replace(/^organization-/, `${backendModule.AssetType.directory}-`)
|
||||
@ -217,6 +218,7 @@ function columnsFor(displayMode: ColumnDisplayMode, backendPlatform: platformMod
|
||||
// === Dashboard ===
|
||||
// =================
|
||||
|
||||
/** Props for {@link Dashboard}s that are common to all platforms. */
|
||||
export interface DashboardProps {
|
||||
platform: platformModule.Platform
|
||||
appRunner: AppRunner | null
|
||||
@ -225,6 +227,7 @@ export interface DashboardProps {
|
||||
// TODO[sb]: Implement rename when clicking name of a selected row.
|
||||
// There is currently no way to tell whether a row is selected from a column.
|
||||
|
||||
/** The component that contains the entire UI. */
|
||||
function Dashboard(props: DashboardProps) {
|
||||
const { platform, appRunner } = props
|
||||
|
||||
@ -279,7 +282,7 @@ function Dashboard(props: DashboardProps) {
|
||||
const parentDirectory = directoryStack[directoryStack.length - 2]
|
||||
|
||||
react.useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
// On macOS, we need to check for combination of `alt` + `d` which is `∂` (`del`).
|
||||
(event.key === 'd' || event.key === '∂') &&
|
||||
@ -302,30 +305,30 @@ function Dashboard(props: DashboardProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
function setProjectAssets(
|
||||
const setProjectAssets = (
|
||||
newProjectAssets: backendModule.Asset<backendModule.AssetType.project>[]
|
||||
) {
|
||||
) => {
|
||||
setProjectAssetsRaw(newProjectAssets)
|
||||
setVisibleProjectAssets(newProjectAssets.filter(asset => asset.title.includes(query)))
|
||||
}
|
||||
function setDirectoryAssets(
|
||||
const setDirectoryAssets = (
|
||||
newDirectoryAssets: backendModule.Asset<backendModule.AssetType.directory>[]
|
||||
) {
|
||||
) => {
|
||||
setDirectoryAssetsRaw(newDirectoryAssets)
|
||||
setVisibleDirectoryAssets(newDirectoryAssets.filter(asset => asset.title.includes(query)))
|
||||
}
|
||||
function setSecretAssets(
|
||||
const setSecretAssets = (
|
||||
newSecretAssets: backendModule.Asset<backendModule.AssetType.secret>[]
|
||||
) {
|
||||
) => {
|
||||
setSecretAssetsRaw(newSecretAssets)
|
||||
setVisibleSecretAssets(newSecretAssets.filter(asset => asset.title.includes(query)))
|
||||
}
|
||||
function setFileAssets(newFileAssets: backendModule.Asset<backendModule.AssetType.file>[]) {
|
||||
const setFileAssets = (newFileAssets: backendModule.Asset<backendModule.AssetType.file>[]) => {
|
||||
setFileAssetsRaw(newFileAssets)
|
||||
setVisibleFileAssets(newFileAssets.filter(asset => asset.title.includes(query)))
|
||||
}
|
||||
|
||||
function exitDirectory() {
|
||||
const exitDirectory = () => {
|
||||
setDirectoryId(parentDirectory?.id ?? rootDirectoryId(organization.id))
|
||||
setDirectoryStack(
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
@ -333,9 +336,9 @@ function Dashboard(props: DashboardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function enterDirectory(
|
||||
const enterDirectory = (
|
||||
directoryAsset: backendModule.Asset<backendModule.AssetType.directory>
|
||||
) {
|
||||
) => {
|
||||
setDirectoryId(directoryAsset.id)
|
||||
setDirectoryStack([...directoryStack, directoryAsset])
|
||||
}
|
||||
@ -480,6 +483,8 @@ function Dashboard(props: DashboardProps) {
|
||||
}
|
||||
|
||||
/** React components for every column except for the name column. */
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const columnRenderer: Record<
|
||||
Exclude<Column, Column.name>,
|
||||
(asset: backendModule.Asset) => react.ReactNode
|
||||
@ -502,7 +507,7 @@ function Dashboard(props: DashboardProps) {
|
||||
[Column.labels]: () => {
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
|
||||
function onContextMenu(event: react.MouseEvent) {
|
||||
const onContextMenu = (event: react.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(() => (
|
||||
@ -526,7 +531,7 @@ function Dashboard(props: DashboardProps) {
|
||||
[Column.ide]: () => <></>,
|
||||
}
|
||||
|
||||
function renderer<Type extends backendModule.AssetType>(column: Column, assetType: Type) {
|
||||
const renderer = <Type extends backendModule.AssetType>(column: Column, assetType: Type) => {
|
||||
return column === Column.name
|
||||
? // This is type-safe only if we pass enum literals as `assetType`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@ -535,8 +540,8 @@ function Dashboard(props: DashboardProps) {
|
||||
}
|
||||
|
||||
/** Heading element for every column. */
|
||||
function ColumnHeading(column: Column, assetType: backendModule.AssetType) {
|
||||
return column === Column.name ? (
|
||||
const ColumnHeading = (column: Column, assetType: backendModule.AssetType) =>
|
||||
column === Column.name ? (
|
||||
assetType === backendModule.AssetType.project ? (
|
||||
<>{ASSET_TYPE_NAME[assetType]}</>
|
||||
) : (
|
||||
@ -570,7 +575,6 @@ function Dashboard(props: DashboardProps) {
|
||||
) : (
|
||||
<>{COLUMN_NAME[column]}</>
|
||||
)
|
||||
}
|
||||
|
||||
// The purpose of this effect is to enable search action.
|
||||
react.useEffect(() => {
|
||||
@ -580,7 +584,7 @@ function Dashboard(props: DashboardProps) {
|
||||
setVisibleFileAssets(fileAssets.filter(asset => asset.title.includes(query)))
|
||||
}, [query])
|
||||
|
||||
function setAssets(assets: backendModule.Asset[]) {
|
||||
const setAssets = (assets: backendModule.Asset[]) => {
|
||||
const newProjectAssets = assets.filter(
|
||||
backendModule.assetIsType(backendModule.AssetType.project)
|
||||
)
|
||||
@ -611,7 +615,7 @@ function Dashboard(props: DashboardProps) {
|
||||
)
|
||||
|
||||
react.useEffect(() => {
|
||||
function onBlur() {
|
||||
const onBlur = () => {
|
||||
setIsFileBeingDragged(false)
|
||||
}
|
||||
|
||||
@ -622,7 +626,7 @@ function Dashboard(props: DashboardProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
function handleEscapeKey(event: react.KeyboardEvent<HTMLDivElement>) {
|
||||
const handleEscapeKey = (event: react.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!event.ctrlKey &&
|
||||
@ -637,13 +641,13 @@ function Dashboard(props: DashboardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function openDropZone(event: react.DragEvent<HTMLDivElement>) {
|
||||
const openDropZone = (event: react.DragEvent<HTMLDivElement>) => {
|
||||
if (event.dataTransfer.types.includes('Files')) {
|
||||
setIsFileBeingDragged(true)
|
||||
}
|
||||
}
|
||||
|
||||
function getNewProjectName(templateName?: string | null): string {
|
||||
const getNewProjectName = (templateName?: string | null): string => {
|
||||
const prefix = `${templateName ?? 'New_Project'}_`
|
||||
const projectNameTemplate = new RegExp(`^${prefix}(?<projectIndex>\\d+)$`)
|
||||
let highestProjectIndex = 0
|
||||
@ -656,7 +660,7 @@ function Dashboard(props: DashboardProps) {
|
||||
return `${prefix}${highestProjectIndex + 1}`
|
||||
}
|
||||
|
||||
async function handleCreateProject(templateId?: string | null) {
|
||||
const handleCreateProject = async (templateId?: string | null) => {
|
||||
const projectName = getNewProjectName(templateId)
|
||||
const body: backendModule.CreateProjectRequestBody = {
|
||||
projectName,
|
||||
@ -863,11 +867,11 @@ function Dashboard(props: DashboardProps) {
|
||||
onContextMenu={(projectAsset, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
function doOpenForEditing() {
|
||||
const doOpenForEditing = () => {
|
||||
// FIXME[sb]: Switch to IDE tab
|
||||
// once merged with `show-and-open-workspace` branch.
|
||||
}
|
||||
function doOpenAsFolder() {
|
||||
const doOpenAsFolder = () => {
|
||||
// FIXME[sb]: Uncomment once backend support
|
||||
// is in place.
|
||||
// The following code does not typecheck
|
||||
@ -876,7 +880,7 @@ function Dashboard(props: DashboardProps) {
|
||||
}
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
function doRename() {
|
||||
const doRename = () => {
|
||||
setModal(() => (
|
||||
<RenameModal
|
||||
name={projectAsset.title}
|
||||
@ -902,7 +906,7 @@ function Dashboard(props: DashboardProps) {
|
||||
}
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
function doDelete() {
|
||||
const doDelete = () => {
|
||||
setModal(() => (
|
||||
<ConfirmDeleteModal
|
||||
name={projectAsset.title}
|
||||
@ -1004,7 +1008,7 @@ function Dashboard(props: DashboardProps) {
|
||||
event.stopPropagation()
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
function doDelete() {
|
||||
const doDelete = () => {
|
||||
setModal(() => (
|
||||
<ConfirmDeleteModal
|
||||
name={secret.title}
|
||||
@ -1055,15 +1059,15 @@ function Dashboard(props: DashboardProps) {
|
||||
onContextMenu={(file, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
function doCopy() {
|
||||
const doCopy = () => {
|
||||
/** TODO: Wait for backend endpoint. */
|
||||
}
|
||||
function doCut() {
|
||||
const doCut = () => {
|
||||
/** TODO: Wait for backend endpoint. */
|
||||
}
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
function doDelete() {
|
||||
const doDelete = () => {
|
||||
setModal(() => (
|
||||
<ConfirmDeleteModal
|
||||
name={file.title}
|
||||
@ -1075,7 +1079,7 @@ function Dashboard(props: DashboardProps) {
|
||||
/>
|
||||
))
|
||||
}
|
||||
function doDownload() {
|
||||
const doDownload = () => {
|
||||
/** TODO: Wait for backend endpoint. */
|
||||
}
|
||||
setModal(() => (
|
||||
|
@ -9,11 +9,17 @@ import * as modalProvider from '../../providers/modal'
|
||||
import * as platform from '../../platform'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
|
||||
// ===========================
|
||||
// === DirectoryCreateForm ===
|
||||
// ===========================
|
||||
|
||||
/** Props for a {@link DirectoryCreateForm}. */
|
||||
export interface DirectoryCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/** A form to create a directory. */
|
||||
function DirectoryCreateForm(props: DirectoryCreateFormProps) {
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
@ -9,11 +9,17 @@ import * as modalProvider from '../../providers/modal'
|
||||
import * as platform from '../../platform'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
|
||||
// ======================
|
||||
// === FileCreateForm ===
|
||||
// ======================
|
||||
|
||||
/** Props for a {@link FileCreateForm}. */
|
||||
export interface FileCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/** A form to create a file. */
|
||||
function FileCreateForm(props: FileCreateFormProps) {
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
@ -19,13 +19,14 @@ const JS_EXTENSION: Record<platformModule.Platform, string> = {
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
interface Props {
|
||||
/** Props for an {@link Ide}. */
|
||||
export interface IdeProps {
|
||||
project: backendModule.Project
|
||||
appRunner: AppRunner | null
|
||||
}
|
||||
|
||||
/** Container that launches the IDE. */
|
||||
function Ide(props: Props) {
|
||||
/** The ontainer that launches the IDE. */
|
||||
function Ide(props: IdeProps) {
|
||||
const { project, appRunner } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
||||
|
@ -36,6 +36,7 @@ const STATUS_ICON: Record<Status, JSX.Element | null> = {
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link Label}. */
|
||||
export interface LabelProps {
|
||||
status?: Status
|
||||
onContextMenu?: react.MouseEventHandler<HTMLDivElement>
|
||||
|
@ -7,11 +7,16 @@ import * as modalProvider from '../../providers/modal'
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link Modal}. */
|
||||
export interface ModalProps extends react.PropsWithChildren {
|
||||
centered?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** A fullscreen modal with content at the center.
|
||||
* The background is fully opaque by default;
|
||||
* background transparency can be enabled with Tailwind's `bg-opacity` classes,
|
||||
* like `className="bg-opacity-50"` */
|
||||
function Modal(props: ModalProps) {
|
||||
const { children, centered, className } = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
@ -12,18 +12,22 @@ export enum Permission {
|
||||
regular = 'regular',
|
||||
}
|
||||
|
||||
/** Base interface for all permissions. */
|
||||
interface BasePermissions {
|
||||
type: Permission
|
||||
}
|
||||
|
||||
/** Owner permissions over an asset. */
|
||||
interface OwnerPermissions extends BasePermissions {
|
||||
type: Permission.owner
|
||||
}
|
||||
|
||||
/** Admin permissions over an asset. */
|
||||
interface AdminPermissions extends BasePermissions {
|
||||
type: Permission.admin
|
||||
}
|
||||
|
||||
/** Regular permissions over an asset. */
|
||||
interface RegularPermissions extends BasePermissions {
|
||||
type: Permission.regular
|
||||
read: boolean
|
||||
@ -39,6 +43,7 @@ export type Permissions = AdminPermissions | OwnerPermissions | RegularPermissio
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link PermissionDisplay}. */
|
||||
export interface PermissionDisplayProps {
|
||||
permissions: Permissions
|
||||
}
|
||||
|
@ -37,6 +37,7 @@ const SPINNER_CSS_CLASSES: Record<SpinnerState, string> = {
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link ProjectActionButton}. */
|
||||
export interface ProjectActionButtonProps {
|
||||
project: backendModule.Asset<backendModule.AssetType.project>
|
||||
appRunner: AppRunner | null
|
||||
@ -130,7 +131,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
|
||||
}
|
||||
}, [isCheckingResources])
|
||||
|
||||
function closeProject() {
|
||||
const closeProject = () => {
|
||||
setState(backendModule.ProjectState.closed)
|
||||
appRunner?.stopApp()
|
||||
void backend.closeProject(project.id)
|
||||
@ -138,7 +139,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
|
||||
onClose()
|
||||
}
|
||||
|
||||
async function openProject() {
|
||||
const openProject = async () => {
|
||||
setState(backendModule.ProjectState.openInProgress)
|
||||
setSpinnerState(SpinnerState.initial)
|
||||
// The `setTimeout` is required so that the completion percentage goes from
|
||||
|
@ -9,12 +9,17 @@ import * as modalProvider from '../../providers/modal'
|
||||
import * as platform from '../../platform'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
|
||||
// =========================
|
||||
// === ProjectCreateForm ===
|
||||
// =========================
|
||||
|
||||
/** Props for a {@link ProjectCreateForm}. */
|
||||
export interface ProjectCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
// FIXME[sb]: Extract shared shape to a common component.
|
||||
/** A form to create a project. */
|
||||
function ProjectCreateForm(props: ProjectCreateFormProps) {
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
@ -7,6 +7,11 @@ import * as svg from '../../components/svg'
|
||||
|
||||
import Modal from './modal'
|
||||
|
||||
// ===================
|
||||
// === RenameModal ===
|
||||
// ===================
|
||||
|
||||
/** Props for a {@link RenameModal}. */
|
||||
export interface RenameModalProps {
|
||||
assetType: string
|
||||
name: string
|
||||
@ -16,6 +21,7 @@ export interface RenameModalProps {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/** A modal for renaming an asset. */
|
||||
function RenameModal(props: RenameModalProps) {
|
||||
const { assetType, name, namePattern, title, doRename, onSuccess } = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
@ -29,7 +29,8 @@ export interface Column<T> {
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
interface Props<T> {
|
||||
/** Props for a {@link Rows}. */
|
||||
export interface RowsProps<T> {
|
||||
items: T[]
|
||||
getKey: (item: T) => string
|
||||
isLoading: boolean
|
||||
@ -40,18 +41,18 @@ interface Props<T> {
|
||||
}
|
||||
|
||||
/** Table that projects an object into each column. */
|
||||
function Rows<T>(props: Props<T>) {
|
||||
function Rows<T>(props: RowsProps<T>) {
|
||||
const { columns, items, isLoading, getKey, placeholder, onClick, onContextMenu } = props
|
||||
const [spinnerClasses, setSpinnerClasses] = react.useState(SPINNER_INITIAL_CLASSES)
|
||||
|
||||
const headerRow = (
|
||||
<tr>
|
||||
{columns.map(({ heading }, index) => (
|
||||
{columns.map(column => (
|
||||
<th
|
||||
key={index}
|
||||
key={column.id}
|
||||
className="text-vs px-4 align-middle py-1 border-0 border-r whitespace-nowrap font-semibold text-left"
|
||||
>
|
||||
{heading}
|
||||
{column.heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@ -93,9 +94,9 @@ function Rows<T>(props: Props<T>) {
|
||||
}}
|
||||
className="h-10 transition duration-300 ease-in-out hover:bg-gray-100 focus:bg-gray-200"
|
||||
>
|
||||
{columns.map(({ id, render }) => (
|
||||
<td key={id} className="px-4 border-0 border-r">
|
||||
{render(item, index)}
|
||||
{columns.map(column => (
|
||||
<td key={column.id} className="px-4 border-0 border-r">
|
||||
{column.render(item, index)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
@ -9,11 +9,17 @@ import * as modalProvider from '../../providers/modal'
|
||||
import * as platform from '../../platform'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
|
||||
// ========================
|
||||
// === SecretCreateForm ===
|
||||
// ========================
|
||||
|
||||
/** Props for a {@link SecretCreateForm}. */
|
||||
export interface SecretCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/** A form to create a secret. */
|
||||
function SecretCreateForm(props: SecretCreateFormProps) {
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
@ -57,13 +57,14 @@ const TEMPLATES: Template[] = [
|
||||
// === TemplatesRender ===
|
||||
// =======================
|
||||
|
||||
/** Render all templates, and a button to create an empty project. */
|
||||
interface TemplatesRenderProps {
|
||||
/** Props for a {@link TemplatesRender}. */
|
||||
export interface TemplatesRenderProps {
|
||||
// Later this data may be requested and therefore needs to be passed dynamically.
|
||||
templates: Template[]
|
||||
onTemplateClick: (name: string | null) => void
|
||||
}
|
||||
|
||||
/** Render all templates, and a button to create an empty project. */
|
||||
function TemplatesRender(props: TemplatesRenderProps) {
|
||||
const { templates, onTemplateClick } = props
|
||||
|
||||
@ -118,11 +119,12 @@ function TemplatesRender(props: TemplatesRenderProps) {
|
||||
// === Templates ===
|
||||
// =================
|
||||
|
||||
/** The `TemplatesRender`'s container. */
|
||||
interface TemplatesProps {
|
||||
/** Props for a {@link Templates}. */
|
||||
export interface TemplatesProps {
|
||||
onTemplateClick: (name?: string | null) => void
|
||||
}
|
||||
|
||||
/** A container for a {@link TemplatesRender} which passes it a list of templates. */
|
||||
function Templates(props: TemplatesProps) {
|
||||
const { onTemplateClick } = props
|
||||
|
||||
|
@ -14,7 +14,8 @@ import UserMenu from './userMenu'
|
||||
// === TopBar ===
|
||||
// ==============
|
||||
|
||||
interface TopBarProps {
|
||||
/** Props for a {@link TopBar}. */
|
||||
export interface TopBarProps {
|
||||
platform: platformModule.Platform
|
||||
projectName: string | null
|
||||
tab: dashboard.Tab
|
||||
@ -24,10 +25,8 @@ interface TopBarProps {
|
||||
setQuery: (value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link TopBarProps.setQuery} param is used to communicate with the parent component,
|
||||
* because `searchVal` may change parent component's project list.
|
||||
*/
|
||||
/** The {@link TopBarProps.setQuery} parameter is used to communicate with the parent component,
|
||||
* because `searchVal` may change parent component's project list. */
|
||||
function TopBar(props: TopBarProps) {
|
||||
const { platform, projectName, tab, toggleTab, setBackendPlatform, query, setQuery } = props
|
||||
const [userMenuVisible, setUserMenuVisible] = react.useState(false)
|
||||
|
@ -11,11 +11,17 @@ import * as svg from '../../components/svg'
|
||||
|
||||
import Modal from './modal'
|
||||
|
||||
// =======================
|
||||
// === UploadFileModal ===
|
||||
// =======================
|
||||
|
||||
/** Props for an {@link UploadFileModal}. */
|
||||
export interface UploadFileModalProps {
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/** A modal for uploading a file. */
|
||||
function UploadFileModal(props: UploadFileModalProps) {
|
||||
const { directoryId, onSuccess } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
@ -12,10 +12,11 @@ import ChangePasswordModal from './changePasswordModal'
|
||||
|
||||
/** This is the UI component for a `UserMenu` list item.
|
||||
* The main interaction logic is in the `onClick` injected by `UserMenu`. */
|
||||
interface UserMenuItemProps {
|
||||
export interface UserMenuItemProps {
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
/** User menu item. */
|
||||
function UserMenuItem(props: react.PropsWithChildren<UserMenuItemProps>) {
|
||||
const { children, onClick } = props
|
||||
|
||||
|
@ -1,7 +1,14 @@
|
||||
/** @file Utilities for manipulating and displaying dates and times */
|
||||
import * as backend from './backend'
|
||||
import * as newtype from '../newtype'
|
||||
|
||||
// ================
|
||||
// === DateTime ===
|
||||
// ================
|
||||
|
||||
/** A string with date and time, following the RFC3339 specification. */
|
||||
export type Rfc3339DateTime = newtype.Newtype<string, 'Rfc3339DateTime'>
|
||||
|
||||
/** Formats date time into the preferred format: `YYYY-MM-DD, hh:mm`. */
|
||||
export function formatDateTime(date: Date) {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth().toString().padStart(2, '0')
|
||||
@ -11,6 +18,7 @@ export function formatDateTime(date: Date) {
|
||||
return `${year}-${month}-${dayOfMonth}, ${hour}:${minute}`
|
||||
}
|
||||
|
||||
/** Formats a {@link Date} as a {@link Rfc3339DateTime} */
|
||||
export function toRfc3339(date: Date) {
|
||||
return newtype.asNewtype<backend.Rfc3339DateTime>(date.toISOString())
|
||||
return newtype.asNewtype<Rfc3339DateTime>(date.toISOString())
|
||||
}
|
||||
|
@ -4,7 +4,6 @@
|
||||
* The functions are asynchronous and return a {@link Promise} that resolves to the response from
|
||||
* the API. */
|
||||
import * as backend from './backend'
|
||||
import * as dateTime from './dateTime'
|
||||
import * as newtype from '../newtype'
|
||||
import * as platformModule from '../platform'
|
||||
import * as projectManager from './projectManager'
|
||||
@ -13,6 +12,7 @@ import * as projectManager from './projectManager'
|
||||
// === Helper functions ===
|
||||
// ========================
|
||||
|
||||
/** Convert a {@link projectManager.IpWithSocket} to a {@link backend.Address}. */
|
||||
function ipWithSocketToAddress(ipWithSocket: projectManager.IpWithSocket) {
|
||||
return newtype.asNewtype<backend.Address>(`ws://${ipWithSocket.host}:${ipWithSocket.port}`)
|
||||
}
|
||||
@ -35,6 +35,9 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
readonly platform = platformModule.Platform.desktop
|
||||
private readonly projectManager = projectManager.ProjectManager.default()
|
||||
|
||||
/** Return a list of assets in a directory.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async listDirectory(): Promise<backend.Asset[]> {
|
||||
const result = await this.projectManager.listProjects({})
|
||||
return result.projects.map(project => ({
|
||||
@ -47,6 +50,9 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
}))
|
||||
}
|
||||
|
||||
/** Return a list of projects belonging to the current user.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async listProjects(): Promise<backend.ListedProject[]> {
|
||||
const result = await this.projectManager.listProjects({})
|
||||
return result.projects.map(project => ({
|
||||
@ -62,6 +68,9 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
}))
|
||||
}
|
||||
|
||||
/** Create a project.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> {
|
||||
const project = await this.projectManager.createProject({
|
||||
name: newtype.asNewtype<projectManager.ProjectName>(body.projectName),
|
||||
@ -79,6 +88,9 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the project identified by the given project ID.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async closeProject(projectId: backend.ProjectId): Promise<void> {
|
||||
if (LocalBackend.currentlyOpeningProjectId === projectId) {
|
||||
LocalBackend.currentlyOpeningProjectId = null
|
||||
@ -90,6 +102,9 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the project identified by the given project ID.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async getProjectDetails(projectId: backend.ProjectId): Promise<backend.Project> {
|
||||
if (projectId !== LocalBackend.currentlyOpenProject?.id) {
|
||||
const result = await this.projectManager.listProjects({})
|
||||
@ -147,6 +162,9 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepare a project for execution.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async openProject(projectId: backend.ProjectId): Promise<void> {
|
||||
LocalBackend.currentlyOpeningProjectId = projectId
|
||||
const project = await this.projectManager.openProject({
|
||||
@ -156,6 +174,9 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
LocalBackend.currentlyOpenProject = { id: projectId, project }
|
||||
}
|
||||
|
||||
/** Change the name of a project.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async projectUpdate(
|
||||
projectId: backend.ProjectId,
|
||||
body: backend.ProjectUpdateRequestBody
|
||||
@ -195,6 +216,9 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a project.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async deleteProject(projectId: backend.ProjectId): Promise<void> {
|
||||
if (LocalBackend.currentlyOpeningProjectId === projectId) {
|
||||
LocalBackend.currentlyOpeningProjectId = null
|
||||
|
@ -2,6 +2,7 @@
|
||||
*
|
||||
* It should always be in sync with the Rust interface at
|
||||
* `app/gui/controller/engine-protocol/src/project_manager.rs`. */
|
||||
import * as dateTime from './dateTime'
|
||||
import * as newtype from '../newtype'
|
||||
|
||||
import GLOBAL_CONFIG from '../../../../../../../gui/config.yaml' assert { type: 'yaml' }
|
||||
@ -19,40 +20,49 @@ const STOP_TRYING_AFTER_MS = 10000
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Possible actions to take when a component is missing. */
|
||||
export enum MissingComponentAction {
|
||||
fail = 'Fail',
|
||||
install = 'Install',
|
||||
forceInstallBroken = 'ForceInstallBroken',
|
||||
}
|
||||
|
||||
/** Metadata for a JSON-RPC error. */
|
||||
interface JSONRPCError {
|
||||
code: number
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
/** Fields common to all return values of any JSON-RPC call. */
|
||||
interface JSONRPCBaseResponse {
|
||||
jsonrpc: '2.0'
|
||||
id: number
|
||||
}
|
||||
|
||||
/** The return value of a successful JSON-RPC call. */
|
||||
interface JSONRPCSuccessResponse<T> extends JSONRPCBaseResponse {
|
||||
result: T
|
||||
}
|
||||
|
||||
/** The return value of a failed JSON-RPC call. */
|
||||
interface JSONRPCErrorResponse extends JSONRPCBaseResponse {
|
||||
error: JSONRPCError
|
||||
}
|
||||
|
||||
/** The return value of a JSON-RPC call. */
|
||||
type JSONRPCResponse<T> = JSONRPCErrorResponse | JSONRPCSuccessResponse<T>
|
||||
|
||||
// This intentionally has the same brand as in the cloud backend API.
|
||||
/** An ID of a project. */
|
||||
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
|
||||
/** A name of a project. */
|
||||
export type ProjectName = newtype.Newtype<string, 'ProjectName'>
|
||||
/** The newtype's `TypeName` is intentionally different from the name of this type alias,
|
||||
* to match the backend's newtype. */
|
||||
export type UTCDateTime = newtype.Newtype<string, 'Rfc3339DateTime'>
|
||||
export type UTCDateTime = dateTime.Rfc3339DateTime
|
||||
|
||||
/** Details for a project. */
|
||||
export interface ProjectMetadata {
|
||||
name: ProjectName
|
||||
namespace: string
|
||||
@ -61,19 +71,23 @@ export interface ProjectMetadata {
|
||||
lastOpened: UTCDateTime | null
|
||||
}
|
||||
|
||||
/** A value specifying the hostname and port of a socket. */
|
||||
export interface IpWithSocket {
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
/** The return value of the "list projects" endpoint. */
|
||||
export interface ProjectList {
|
||||
projects: ProjectMetadata[]
|
||||
}
|
||||
|
||||
/** The return value of the "create project" endpoint. */
|
||||
export interface CreateProject {
|
||||
projectId: ProjectId
|
||||
}
|
||||
|
||||
/** The return value of the "open project" endpoint. */
|
||||
export interface OpenProject {
|
||||
engineVersion: string
|
||||
languageServerJsonAddress: IpWithSocket
|
||||
@ -86,19 +100,23 @@ export interface OpenProject {
|
||||
// === Parameters for endpoints ===
|
||||
// ================================
|
||||
|
||||
/** Parameters for the "open project" endpoint. */
|
||||
export interface OpenProjectParams {
|
||||
projectId: ProjectId
|
||||
missingComponentAction: MissingComponentAction
|
||||
}
|
||||
|
||||
/** Parameters for the "close project" endpoint. */
|
||||
export interface CloseProjectParams {
|
||||
projectId: ProjectId
|
||||
}
|
||||
|
||||
/** Parameters for the "list projects" endpoint. */
|
||||
export interface ListProjectsParams {
|
||||
numberOfProjects?: number
|
||||
}
|
||||
|
||||
/** Parameters for the "create project" endpoint. */
|
||||
export interface CreateProjectParams {
|
||||
name: ProjectName
|
||||
projectTemplate?: string
|
||||
@ -106,15 +124,18 @@ export interface CreateProjectParams {
|
||||
missingComponentAction?: MissingComponentAction
|
||||
}
|
||||
|
||||
/** Parameters for the "list samples" endpoint. */
|
||||
export interface RenameProjectParams {
|
||||
projectId: ProjectId
|
||||
name: ProjectName
|
||||
}
|
||||
|
||||
/** Parameters for the "delete project" endpoint. */
|
||||
export interface DeleteProjectParams {
|
||||
projectId: ProjectId
|
||||
}
|
||||
|
||||
/** Parameters for the "list samples" endpoint. */
|
||||
export interface ListSamplesParams {
|
||||
projectId: ProjectId
|
||||
}
|
||||
@ -223,6 +244,7 @@ export class ProjectManager {
|
||||
return this.sendRequest<ProjectList>('project/listSample', params)
|
||||
}
|
||||
|
||||
/** Remove all handlers for a specified request ID. */
|
||||
private cleanup(id: number) {
|
||||
this.resolvers.delete(id)
|
||||
this.rejecters.delete(id)
|
||||
|
@ -1,7 +1,8 @@
|
||||
/** @file Module containing the API client for the Cloud backend API.
|
||||
*
|
||||
* Each exported function in the {@link RemoteBackend} in this module corresponds to an API endpoint. The
|
||||
* functions are asynchronous and return a `Promise` that resolves to the response from the API. */
|
||||
* Each exported function in the {@link RemoteBackend} in this module corresponds to
|
||||
* an API endpoint. The functions are asynchronous and return a {@link Promise} that resolves to
|
||||
* the response from the API. */
|
||||
import * as backend from './backend'
|
||||
import * as config from '../config'
|
||||
import * as http from '../http'
|
||||
@ -145,7 +146,7 @@ interface ListVersionsResponseBody {
|
||||
export class RemoteBackend implements backend.Backend {
|
||||
readonly platform = platformModule.Platform.cloud
|
||||
|
||||
/** Creates a new instance of the {@link RemoteBackend} API client.
|
||||
/** Create a new instance of the {@link RemoteBackend} API client.
|
||||
*
|
||||
* @throws An error if the `Authorization` header is not set on the given `client`. */
|
||||
constructor(
|
||||
@ -154,27 +155,29 @@ export class RemoteBackend implements backend.Backend {
|
||||
) {
|
||||
// All of our API endpoints are authenticated, so we expect the `Authorization` header to be
|
||||
// set.
|
||||
if (!this.client.defaultHeaders?.has('Authorization')) {
|
||||
if (!this.client.defaultHeaders.has('Authorization')) {
|
||||
return this.throw('Authorization header not set.')
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Log an error message and throws an {@link Error} with the specified message.
|
||||
* @throws {Error} Always. */
|
||||
throw(message: string): never {
|
||||
this.logger.error(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
/** Sets the username of the current user, on the Cloud backend API. */
|
||||
/** Set the username of the current user. */
|
||||
async createUser(body: backend.CreateUserRequestBody): Promise<backend.UserOrOrganization> {
|
||||
const response = await this.post<backend.UserOrOrganization>(CREATE_USER_PATH, body)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/** Returns organization info for the current user, from the Cloud backend API.
|
||||
/** Return organization info for the current user.
|
||||
*
|
||||
* @returns `null` if status code 401 or 404 was received. */
|
||||
* @returns `null` if any status code other than 200 OK was received. */
|
||||
async usersMe(): Promise<backend.UserOrOrganization | null> {
|
||||
const response = await this.get<backend.UserOrOrganization>(USERS_ME_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -184,10 +187,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a list of assets in a directory, from the Cloud backend API.
|
||||
/** Return a list of assets in a directory.
|
||||
*
|
||||
* @throws An error if status code 401 or 404 was received.
|
||||
*/
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async listDirectory(query: backend.ListDirectoryRequestParams): Promise<backend.Asset[]> {
|
||||
const response = await this.get<ListDirectoryResponseBody>(
|
||||
LIST_DIRECTORY_PATH +
|
||||
@ -212,9 +214,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a directory, on the Cloud backend API.
|
||||
/** Create a directory.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async createDirectory(body: backend.CreateDirectoryRequestBody): Promise<backend.Directory> {
|
||||
const response = await this.post<backend.Directory>(CREATE_DIRECTORY_PATH, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -224,10 +226,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a list of projects belonging to the current user, from the Cloud backend API.
|
||||
/** Return a list of projects belonging to the current user.
|
||||
*
|
||||
* @throws An error if status code 401 or 404 was received.
|
||||
*/
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async listProjects(): Promise<backend.ListedProject[]> {
|
||||
const response = await this.get<ListProjectsResponseBody>(LIST_PROJECTS_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -247,9 +248,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a project for the current user, on the Cloud backend API.
|
||||
/** Create a project.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> {
|
||||
const response = await this.post<backend.CreatedProject>(CREATE_PROJECT_PATH, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -259,9 +260,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes the project identified by the given project ID, on the Cloud backend API.
|
||||
/** Close a project.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async closeProject(projectId: backend.ProjectId): Promise<void> {
|
||||
const response = await this.post(closeProjectPath(projectId), {})
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -271,9 +272,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns project details for the specified project ID, from the Cloud backend API.
|
||||
/** Return details for a project.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async getProjectDetails(projectId: backend.ProjectId): Promise<backend.Project> {
|
||||
const response = await this.get<backend.ProjectRaw>(getProjectDetailsPath(projectId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -294,9 +295,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets project to an open state, on the Cloud backend API.
|
||||
/** Prepare a project for execution.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async openProject(
|
||||
projectId: backend.ProjectId,
|
||||
body: backend.OpenProjectRequestBody = DEFAULT_OPEN_PROJECT_BODY
|
||||
@ -309,6 +310,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the name or AMI of a project.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async projectUpdate(
|
||||
projectId: backend.ProjectId,
|
||||
body: backend.ProjectUpdateRequestBody
|
||||
@ -321,9 +325,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes project, on the Cloud backend API.
|
||||
/** Delete a project.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async deleteProject(projectId: backend.ProjectId): Promise<void> {
|
||||
const response = await this.delete(deleteProjectPath(projectId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -333,9 +337,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns project memory, processor and storage usage, from the Cloud backend API.
|
||||
/** Return the resource usage of a project.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async checkResources(projectId: backend.ProjectId): Promise<backend.ResourceUsage> {
|
||||
const response = await this.get<backend.ResourceUsage>(checkResourcesPath(projectId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -345,9 +349,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a list of files accessible by the current user, from the Cloud backend API.
|
||||
/** Return a list of files accessible by the current user.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async listFiles(): Promise<backend.File[]> {
|
||||
const response = await this.get<ListFilesResponseBody>(LIST_FILES_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -357,9 +361,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploads a file, to the Cloud backend API.
|
||||
/** Upload a file.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async uploadFile(
|
||||
params: backend.UploadFileRequestParams,
|
||||
body: Blob
|
||||
@ -391,9 +395,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a file, on the Cloud backend API.
|
||||
/** Delete a file.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async deleteFile(fileId: backend.FileId): Promise<void> {
|
||||
const response = await this.delete(deleteFilePath(fileId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -403,9 +407,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a secret environment variable, on the Cloud backend API.
|
||||
/** Create a secret environment variable.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async createSecret(body: backend.CreateSecretRequestBody): Promise<backend.SecretAndInfo> {
|
||||
const response = await this.post<backend.SecretAndInfo>(CREATE_SECRET_PATH, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -415,9 +419,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a secret environment variable, from the Cloud backend API.
|
||||
/** Return a secret environment variable.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async getSecret(secretId: backend.SecretId): Promise<backend.Secret> {
|
||||
const response = await this.get<backend.Secret>(getSecretPath(secretId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -427,9 +431,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the secret environment variables accessible by the user, from the Cloud backend API.
|
||||
/** Return the secret environment variables accessible by the user.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async listSecrets(): Promise<backend.SecretInfo[]> {
|
||||
const response = await this.get<ListSecretsResponseBody>(LIST_SECRETS_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -439,9 +443,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a secret environment variable, on the Cloud backend API.
|
||||
/** Delete a secret environment variable.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async deleteSecret(secretId: backend.SecretId): Promise<void> {
|
||||
const response = await this.delete(deleteSecretPath(secretId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -451,9 +455,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a file tag or project tag, on the Cloud backend API.
|
||||
/** Create a file tag or project tag.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async createTag(body: backend.CreateTagRequestBody): Promise<backend.TagInfo> {
|
||||
const response = await this.post<backend.TagInfo>(CREATE_TAG_PATH, {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
@ -470,9 +474,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns file tags or project tags accessible by the user, from the Cloud backend API.
|
||||
/** Return file tags or project tags accessible by the user.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async listTags(params: backend.ListTagsRequestParams): Promise<backend.Tag[]> {
|
||||
const response = await this.get<ListTagsResponseBody>(
|
||||
LIST_TAGS_PATH +
|
||||
@ -489,9 +493,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a secret environment variable, on the Cloud backend API.
|
||||
/** Delete a secret environment variable.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async deleteTag(tagId: backend.TagId): Promise<void> {
|
||||
const response = await this.delete(deleteTagPath(tagId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -501,9 +505,9 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns list of backend or IDE versions, from the Cloud backend API.
|
||||
/** Return list of backend or IDE versions.
|
||||
*
|
||||
* @throws An error if a 401 or 404 status code was received. */
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async listVersions(
|
||||
params: backend.ListVersionsRequestParams
|
||||
): Promise<[backend.Version, ...backend.Version[]]> {
|
||||
@ -523,27 +527,27 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends an HTTP GET request to the given path. */
|
||||
/** Send an HTTP GET request to the given path. */
|
||||
private get<T = void>(path: string) {
|
||||
return this.client.get<T>(`${config.ACTIVE_CONFIG.apiUrl}/${path}`)
|
||||
}
|
||||
|
||||
/** Sends a JSON HTTP POST request to the given path. */
|
||||
/** Send a JSON HTTP POST request to the given path. */
|
||||
private post<T = void>(path: string, payload: object) {
|
||||
return this.client.post<T>(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload)
|
||||
}
|
||||
|
||||
/** Sends a binary HTTP POST request to the given path. */
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/** Sends a JSON HTTP PUT request to the given path. */
|
||||
/** Send a JSON HTTP PUT request to the given path. */
|
||||
private put<T = void>(path: string, payload: object) {
|
||||
return this.client.put<T>(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload)
|
||||
}
|
||||
|
||||
/** Sends an HTTP DELETE request to the given path. */
|
||||
/** Send an HTTP DELETE request to the given path. */
|
||||
private delete<T = void>(path: string) {
|
||||
return this.client.delete<T>(`${config.ACTIVE_CONFIG.apiUrl}/${path}`)
|
||||
}
|
||||
|
@ -4,8 +4,11 @@
|
||||
// === Type assertions (unsafe) ===
|
||||
// ================================
|
||||
|
||||
/** Used to enforce a parameter must be `any`. This is useful to verify that the value comes
|
||||
* from an API that returns `any`. */
|
||||
type MustBeAny<T> = never extends T ? (T & 1 extends 0 ? T : never) : never
|
||||
|
||||
/** Assumes an unknown value is an {@link Error}. */
|
||||
export function unsafeAsError<T>(error: MustBeAny<T>) {
|
||||
// This is UNSAFE - errors can be any value.
|
||||
// Usually they *do* extend `Error`,
|
||||
@ -14,6 +17,7 @@ export function unsafeAsError<T>(error: MustBeAny<T>) {
|
||||
return error as Error
|
||||
}
|
||||
|
||||
/** Extracts the `message` property of a value, by first assuming it is an {@link Error}. */
|
||||
export function unsafeIntoErrorMessage<T>(error: MustBeAny<T>) {
|
||||
return unsafeAsError(error).message
|
||||
}
|
||||
@ -29,6 +33,8 @@ export function unsafeIntoErrorMessage<T>(error: MustBeAny<T>) {
|
||||
* least find out at runtime if we've missed a case, or forgotten to update the code when we add a
|
||||
* new case. */
|
||||
export class UnreachableCaseError extends Error {
|
||||
/** Creates an `UnreachableCaseError`.
|
||||
* The parameter should be `never` since it is unreachable assuming all logic is sound. */
|
||||
constructor(value: never) {
|
||||
super(`Unreachable case: ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
@ -1,17 +1,25 @@
|
||||
/** @file Utility functions for extracting and manipulating file information. */
|
||||
|
||||
import * as svg from './components/svg'
|
||||
|
||||
/** Returns the file extension of a file name. */
|
||||
// ================================
|
||||
// === Extract file information ===
|
||||
// ================================
|
||||
|
||||
/** Extract the file extension from a file name. */
|
||||
export function fileExtension(fileName: string) {
|
||||
return fileName.match(/\.(.+?)$/)?.[1] ?? ''
|
||||
}
|
||||
|
||||
/** Returns the appropriate icon for a specific file extension. */
|
||||
/** Return the appropriate icon for a specific file extension. */
|
||||
export function fileIcon(_extension: string) {
|
||||
return svg.FILE_ICON
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// === Manipulate file information ===
|
||||
// ===================================
|
||||
|
||||
/** Convert a size in bytes to a human readable size, e.g. in mebibytes. */
|
||||
export function toReadableSize(size: number) {
|
||||
/* eslint-disable @typescript-eslint/no-magic-numbers */
|
||||
if (size < 2 ** 10) {
|
||||
|
@ -35,25 +35,26 @@ function blobToBase64(blob: 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. */
|
||||
constructor(
|
||||
/** A map of default headers that are included in every HTTP request sent by this client.
|
||||
*
|
||||
* This is useful for setting headers that are required for every request, like authentication
|
||||
* tokens. */
|
||||
public defaultHeaders?: Headers
|
||||
* This is useful for setting headers that are required for every request, like
|
||||
* authentication tokens. */
|
||||
public defaultHeaders: Headers
|
||||
) {}
|
||||
|
||||
/** Sends an HTTP GET request to the specified URL. */
|
||||
/** Send an HTTP GET request to the specified URL. */
|
||||
get<T = void>(url: string) {
|
||||
return this.request<T>(HttpMethod.get, url)
|
||||
}
|
||||
|
||||
/** Sends a JSON HTTP POST request to the specified URL. */
|
||||
/** Send a JSON HTTP POST request to the specified URL. */
|
||||
post<T = void>(url: string, payload: object) {
|
||||
return this.request<T>(HttpMethod.post, url, JSON.stringify(payload), 'application/json')
|
||||
}
|
||||
|
||||
/** Sends a base64-encoded binary HTTP POST request to the specified URL. */
|
||||
/** 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,
|
||||
@ -63,32 +64,34 @@ export class Client {
|
||||
)
|
||||
}
|
||||
|
||||
/** Sends a JSON HTTP PUT request to the specified URL. */
|
||||
/** Send a JSON HTTP PUT request to the specified URL. */
|
||||
put<T = void>(url: string, payload: object) {
|
||||
return this.request<T>(HttpMethod.put, url, JSON.stringify(payload), 'application/json')
|
||||
}
|
||||
|
||||
/** Sends an HTTP DELETE request to the specified URL. */
|
||||
/** Send an HTTP DELETE request to the specified URL. */
|
||||
delete<T = void>(url: string) {
|
||||
return this.request<T>(HttpMethod.delete, url)
|
||||
}
|
||||
|
||||
/** Executes an HTTP request to the specified URL, with the given HTTP method. */
|
||||
/** Execute an HTTP request to the specified URL, with the given HTTP method. */
|
||||
private request<T = void>(
|
||||
method: HttpMethod,
|
||||
url: string,
|
||||
payload?: string,
|
||||
mimetype?: string
|
||||
) {
|
||||
const defaultHeaders = this.defaultHeaders ?? []
|
||||
const headers = new Headers(defaultHeaders)
|
||||
const headers = new Headers(this.defaultHeaders)
|
||||
if (payload) {
|
||||
const contentType = mimetype ?? 'application/json'
|
||||
headers.set('Content-Type', contentType)
|
||||
}
|
||||
|
||||
/** A {@link Response} with a properly typed return type for `response.json()`. */
|
||||
interface ResponseWithTypedJson<U> extends Response {
|
||||
json: () => Promise<U>
|
||||
}
|
||||
|
||||
// This is an UNSAFE type assertion, however this is a HTTP client
|
||||
// and should only be used to query APIs with known response types.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -33,9 +33,10 @@ const IDE_ELEMENT_ID = 'root'
|
||||
* Running this function finds a `div` element with the ID `dashboard`, and renders the
|
||||
* authentication/dashboard UI using React. It also handles routing and other interactions (e.g.,
|
||||
* for redirecting the user to/from the login page). */
|
||||
export // This export declaration must be broken up to satisfy the `require-jsdoc` rule.
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export function run(props: app.AppProps) {
|
||||
function run(props: app.AppProps) {
|
||||
const { logger } = props
|
||||
logger.log('Starting authentication/dashboard UI.')
|
||||
/** The root element that the authentication/dashboard app will be rendered into. */
|
||||
@ -52,6 +53,7 @@ export function run(props: app.AppProps) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Global configuration for the {@link App} component. */
|
||||
export type AppProps = app.AppProps
|
||||
// This export should be `PascalCase` because it is a re-export.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -1,5 +1,10 @@
|
||||
/** @file TypeScript's closest equivalent of `newtype`s. */
|
||||
/** @file Emulates `newtype`s in TypeScript. */
|
||||
|
||||
// ===============
|
||||
// === Newtype ===
|
||||
// ===============
|
||||
|
||||
/** An interface specifying the variant of a newtype. */
|
||||
interface NewtypeVariant<TypeName extends string> {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_$type: TypeName
|
||||
@ -20,11 +25,13 @@ interface NewtypeVariant<TypeName extends string> {
|
||||
* `a: string = asNewtype<Newtype<string, 'Name'>>(b)` successfully typechecks. */
|
||||
export type Newtype<T, TypeName extends string> = NewtypeVariant<TypeName> & T
|
||||
|
||||
/** An interface that matches a type if and only if it is not a newtype. */
|
||||
interface NotNewtype {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_$type?: never
|
||||
}
|
||||
|
||||
/** Converts a value that is not a newtype, to a value that is a newtype. */
|
||||
export function asNewtype<T extends Newtype<unknown, string>>(
|
||||
s: NotNewtype & Omit<T, '_$type'>
|
||||
): T {
|
||||
|
@ -16,6 +16,7 @@ export type AnyBackendAPI = localBackend.LocalBackend | remoteBackend.RemoteBack
|
||||
// === BackendContext ===
|
||||
// ======================
|
||||
|
||||
/** State contained in a `BackendContext`. */
|
||||
export interface BackendContextType {
|
||||
backend: AnyBackendAPI
|
||||
setBackend: (backend: AnyBackendAPI) => void
|
||||
@ -25,6 +26,7 @@ export interface BackendContextType {
|
||||
// as `backend` will always be accessed using `useBackend`.
|
||||
const BackendContext = react.createContext<BackendContextType>(null)
|
||||
|
||||
/** Props for a {@link BackendProvider}. */
|
||||
export interface BackendProviderProps extends React.PropsWithChildren<object> {
|
||||
initialBackend: AnyBackendAPI
|
||||
}
|
||||
|
@ -11,9 +11,9 @@ import * as react from 'react'
|
||||
* In the browser, this is the `Console` interface. In Electron, this is the `Logger` interface
|
||||
* provided by the EnsoGL packager. */
|
||||
export interface Logger {
|
||||
/** Logs a message to the console. */
|
||||
/** Log a message to the console. */
|
||||
log: (message: unknown, ...optionalParams: unknown[]) => void
|
||||
/** Logs an error message to the console. */
|
||||
/** Log an error message to the console. */
|
||||
error: (message: unknown, ...optionalParams: unknown[]) => void
|
||||
}
|
||||
|
||||
@ -29,11 +29,13 @@ const LoggerContext = react.createContext<Logger>({} as Logger)
|
||||
// === LoggerProvider ===
|
||||
// ======================
|
||||
|
||||
interface LoggerProviderProps {
|
||||
/** Props for a {@link LoggerProvider}. */
|
||||
export interface LoggerProviderProps {
|
||||
children: react.ReactNode
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
/** A React provider containing the diagnostic logger. */
|
||||
export function LoggerProvider(props: LoggerProviderProps) {
|
||||
const { children, logger } = props
|
||||
return <LoggerContext.Provider value={logger}>{children}</LoggerContext.Provider>
|
||||
@ -43,6 +45,7 @@ export function LoggerProvider(props: LoggerProviderProps) {
|
||||
// === useLogger ===
|
||||
// =================
|
||||
|
||||
/** A React context hook exposing the diagnostic logger. */
|
||||
export function useLogger() {
|
||||
return react.useContext(LoggerContext)
|
||||
}
|
||||
|
@ -1,9 +1,15 @@
|
||||
/** @file */
|
||||
import * as react from 'react'
|
||||
|
||||
// =============
|
||||
// === Modal ===
|
||||
// =============
|
||||
|
||||
/** The type of a modal. */
|
||||
export type Modal = () => JSX.Element
|
||||
|
||||
export interface ModalContextType {
|
||||
/** State contained in a `ModalContext`. */
|
||||
interface ModalContextType {
|
||||
modal: Modal | null
|
||||
setModal: (modal: Modal | null) => void
|
||||
}
|
||||
@ -16,26 +22,30 @@ const ModalContext = react.createContext<ModalContextType>({
|
||||
},
|
||||
})
|
||||
|
||||
// React components should always have a sibling `Props` interface
|
||||
// if they accept props.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
/** Props for a {@link ModalProvider}. */
|
||||
export interface ModalProviderProps extends React.PropsWithChildren<object> {}
|
||||
|
||||
/** A React provider containing the currently active modal. */
|
||||
export function ModalProvider(props: ModalProviderProps) {
|
||||
const { children } = props
|
||||
const [modal, setModal] = react.useState<Modal | null>(null)
|
||||
return <ModalContext.Provider value={{ modal, setModal }}>{children}</ModalContext.Provider>
|
||||
}
|
||||
|
||||
/** A React context hook exposing the currently active modal, if one is currently visible. */
|
||||
export function useModal() {
|
||||
const { modal } = react.useContext(ModalContext)
|
||||
return { modal }
|
||||
}
|
||||
|
||||
/** A React context hook exposing functions to set and unset the currently active modal. */
|
||||
export function useSetModal() {
|
||||
const { setModal } = react.useContext(ModalContext)
|
||||
function unsetModal() {
|
||||
setModal(null)
|
||||
const { setModal: setModalRaw } = react.useContext(ModalContext)
|
||||
const setModal = (modal: Modal) => {
|
||||
setModalRaw(modal)
|
||||
}
|
||||
const unsetModal = () => {
|
||||
setModalRaw(null)
|
||||
}
|
||||
return { setModal, unsetModal }
|
||||
}
|
||||
|
@ -1,11 +1,15 @@
|
||||
/** @file Helper function to upload multiple files,
|
||||
* with progress being reported by a continually updating toast notification. */
|
||||
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as backend from './dashboard/backend'
|
||||
import * as remoteBackend from './dashboard/remoteBackend'
|
||||
|
||||
// ===========================
|
||||
// === uploadMultipleFiles ===
|
||||
// ===========================
|
||||
|
||||
/** Uploads multiple files to the backend, showing a continuously updated toast notification. */
|
||||
export async function uploadMultipleFiles(
|
||||
backendService: remoteBackend.RemoteBackend,
|
||||
directoryId: backend.DirectoryId,
|
||||
|
@ -33,11 +33,13 @@ if (IS_DEV_MODE) {
|
||||
authentication.run({
|
||||
logger: console,
|
||||
// This file is only included when building for the cloud,
|
||||
// so it is safe to set `platform` to `cloud`.
|
||||
// so the `platform` is always `Platform.cloud`.
|
||||
platform: platform.Platform.cloud,
|
||||
showDashboard: true,
|
||||
// The `onAuthenticated` parameter is required but we don't need it, so we pass an empty function.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onAuthenticated() {},
|
||||
/** The `onAuthenticated` option is mandatory but is not needed here,
|
||||
* so this function is empty. */
|
||||
onAuthenticated() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
},
|
||||
appRunner: null,
|
||||
})
|
||||
|
@ -13,6 +13,10 @@ import * as common from 'enso-common'
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare const self: ServiceWorkerGlobalScope
|
||||
|
||||
// ===============================
|
||||
// === Intercept HTTP requests ===
|
||||
// ===============================
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url)
|
||||
if (url.hostname === 'localhost' && url.pathname !== '/esbuild') {
|
||||
|
@ -45,7 +45,9 @@ export const theme = {
|
||||
'140': '35rem',
|
||||
},
|
||||
boxShadow: {
|
||||
soft: '0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, 0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, 0 18px 80px 0 #0000001c',
|
||||
soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \
|
||||
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \
|
||||
0 18px 80px 0 #0000001c`,
|
||||
},
|
||||
animation: {
|
||||
'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite',
|
||||
|
@ -34,12 +34,15 @@ OPTS.loader = { '.html': 'copy' }
|
||||
// === Watcher ===
|
||||
// ===============
|
||||
|
||||
/** Start the esbuild watcher. */
|
||||
async function watch() {
|
||||
const builder = await esbuild.context(OPTS)
|
||||
await builder.watch()
|
||||
await builder.serve({
|
||||
port: PORT,
|
||||
servedir: OPTS.outdir,
|
||||
/** This function is called on every request.
|
||||
* It is used here to show an error if the file to serve was not found. */
|
||||
onRequest(args) {
|
||||
if (args.status !== HTTP_STATUS_OK) {
|
||||
console.error(
|
||||
|
@ -1,8 +1,17 @@
|
||||
/** @file Typings for this plugin. */
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Configuration options for `esbuild-plugin-copy-directories`. */
|
||||
export interface Options {
|
||||
directoryFilter?: RegExp
|
||||
log?: ((message: string) => void) | null
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// === esbuildPluginCopyDirectories ===
|
||||
// ====================================
|
||||
|
||||
export default function esbuildPluginCopyDirectories(options?: Options): esbuild.Plugin
|
||||
|
@ -13,6 +13,10 @@ const NAME = 'copy-directories'
|
||||
/** The esbuild namespace that the directories that will be copied are given. */
|
||||
const NAMESPACE = NAME
|
||||
|
||||
// ========================
|
||||
// === Helper functions ===
|
||||
// ========================
|
||||
|
||||
// This function is required. If narrowing is used instead,
|
||||
// TypeScript thinks `outputDir` may be `undefined` in functions.
|
||||
/** @param {string} message - The message with which to throw the `Error`.
|
||||
@ -22,6 +26,10 @@ function error(message) {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// === esbuildPluginCopyDirectories ===
|
||||
// ====================================
|
||||
|
||||
/** An esbuild plugin to copy and watch directories.
|
||||
* @param {import('./index').Options} [options] - options.
|
||||
* @returns {import('esbuild').Plugin} The esbuild plugin. */
|
||||
@ -37,12 +45,12 @@ export default function esbuildPluginCopyDirectories(options) {
|
||||
const outputDir =
|
||||
build.initialOptions.outdir ?? error('Output directory must be given.')
|
||||
/** @param {string} root - Path to the directory to watch. */
|
||||
function continuouslySync(root) {
|
||||
const continuouslySync = root => {
|
||||
// It's theoretically possible to use a single `chokidar` instance,
|
||||
// however the root directory path is needed for calculating the destination path.
|
||||
const watcher = chokidar.watch(root, { cwd: root })
|
||||
/** @param {string} path - Path to the file to be copied. */
|
||||
function copy(path) {
|
||||
const copy = path => {
|
||||
void (async () => {
|
||||
const source = pathModule.resolve(root, path)
|
||||
const destination = pathModule.join(outputDir, path)
|
||||
@ -67,7 +75,8 @@ export default function esbuildPluginCopyDirectories(options) {
|
||||
})
|
||||
unwatchers.add(() => void watcher.close())
|
||||
}
|
||||
build.onResolve({ filter: directoryFilter }, async ({ path, kind }) => {
|
||||
build.onResolve({ filter: directoryFilter }, async info => {
|
||||
const { path, kind } = info
|
||||
if (kind === 'entry-point') {
|
||||
if (!watchingPath[path]) {
|
||||
watchingPath[path] = true
|
||||
|
@ -1,7 +1,5 @@
|
||||
/** @file
|
||||
* This file generates the product logo as SVG and then converts it to set of PNGs, MacOS ICNS, and
|
||||
* Windows ICO formats.
|
||||
*/
|
||||
/** @file This file generates the product logo as SVG and then converts it to set of PNGs,
|
||||
* MacOS ICNS, and Windows ICO formats. */
|
||||
|
||||
import * as childProcess from 'node:child_process'
|
||||
import * as fs from 'node:fs/promises'
|
||||
@ -31,7 +29,9 @@ const MACOS_DPI = 144
|
||||
// === Logo ===
|
||||
// ============
|
||||
|
||||
/** A class representing the logo, of the specified size. */
|
||||
class Logo {
|
||||
/** Creates a {@link Logo}. */
|
||||
constructor(size = DEFAULT_SIZE, compatibleMode = true) {
|
||||
this.xsize = size
|
||||
this.size = DEFAULT_SIZE
|
||||
@ -56,11 +56,12 @@ class Logo {
|
||||
this.defs = ''
|
||||
}
|
||||
|
||||
/** Outputs the logo as an SVG image. */
|
||||
generate() {
|
||||
return `
|
||||
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="${
|
||||
this.xsize
|
||||
}" width="${this.xsize}" viewBox="0 0 ${this.xsize} ${this.xsize}">
|
||||
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg" \
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" height="${this.xsize}" width="${this.xsize}" \
|
||||
viewBox="0 0 ${this.xsize} ${this.xsize}">
|
||||
<defs>
|
||||
<circle id="innerCircle" cx="32" cy="32" r="${this.innerRadius}"/>
|
||||
<circle id="leftAtom" cx="${
|
||||
@ -125,15 +126,14 @@ class Logo {
|
||||
`
|
||||
}
|
||||
|
||||
/** Return a reference to the element containing the complete logo. */
|
||||
main() {
|
||||
return `<g transform="scale(${this.scale})"> <use ${this.ref}="#final"/> </g>`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate icons.
|
||||
* @param {string} outputDir - The directory in which the icons will be placed.
|
||||
*/
|
||||
/** Generate icons.
|
||||
* @param {string} outputDir - The directory in which the icons will be placed. */
|
||||
async function genIcons(outputDir) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
let sizes = [16, 32, 64, 128, 256, 512, 1024]
|
||||
@ -215,7 +215,8 @@ async function main() {
|
||||
if (!outputDir) {
|
||||
const script = process.env.npm_package_name ?? url.fileURLToPath(import.meta.url)
|
||||
throw Error(
|
||||
`Script '${script}' invocation needs to be given an output path either through command line argument or 'ENSO_BUILD_ICONS' environment variable.`
|
||||
`The script '${script}' needs to be given an output path either through a \
|
||||
command line argument or the 'ENSO_BUILD_ICONS' environment variable.`
|
||||
)
|
||||
} else {
|
||||
await genIcons(outputDir)
|
||||
|
14
app/ide-desktop/lib/types/globals.d.ts
vendored
14
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -4,14 +4,21 @@
|
||||
*
|
||||
* This file MUST `export {}` for the globals to be visible to other files. */
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Nested configuration options with `string` values. */
|
||||
interface StringConfig {
|
||||
[key: string]: StringConfig | string
|
||||
}
|
||||
|
||||
/** The public interface exposed to `window` by the IDE. */
|
||||
interface Enso {
|
||||
main: (inputConfig?: StringConfig) => Promise<void>
|
||||
}
|
||||
|
||||
/** Build information injected by the build script. */
|
||||
interface BuildInfo {
|
||||
commit: string
|
||||
version: string
|
||||
@ -42,13 +49,20 @@ interface AuthenticationApi {
|
||||
saveAccessToken: (access_token: string) => void
|
||||
}
|
||||
|
||||
// =====================================
|
||||
// === Global namespace augmentation ===
|
||||
// =====================================
|
||||
|
||||
// JSDocs here are intentionally empty as these interfaces originate from elsewhere.
|
||||
declare global {
|
||||
/** */
|
||||
interface Window {
|
||||
enso: Enso
|
||||
authenticationApi: AuthenticationApi
|
||||
}
|
||||
|
||||
namespace NodeJS {
|
||||
/** */
|
||||
interface ProcessEnv {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
APPLEID: string
|
||||
|
14
app/ide-desktop/lib/types/modules.d.ts
vendored
14
app/ide-desktop/lib/types/modules.d.ts
vendored
@ -2,7 +2,12 @@
|
||||
*
|
||||
* This file MUST NOT `export {}` for the modules to be visible to other files. */
|
||||
|
||||
// ===========================
|
||||
// === Module declarations ===
|
||||
// ===========================
|
||||
|
||||
declare module '*/gui/config.yaml' {
|
||||
/** Content of the GUI config file. */
|
||||
interface Config {
|
||||
windowAppScopeName: string
|
||||
windowAppScopeConfigName: string
|
||||
@ -18,15 +23,18 @@ declare module '*/gui/config.yaml' {
|
||||
}
|
||||
|
||||
declare module '@eslint/js' {
|
||||
/** A set of configurations. */
|
||||
interface Config {
|
||||
rules: Record<string, string>
|
||||
rules: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Preset configurations defined by ESLint. */
|
||||
interface EslintConfigs {
|
||||
all: Config
|
||||
recommended: Config
|
||||
}
|
||||
|
||||
/** The default export of the module. */
|
||||
interface Default {
|
||||
configs: EslintConfigs
|
||||
}
|
||||
@ -56,13 +64,17 @@ declare module 'tailwindcss/nesting/index.js' {
|
||||
declare module 'create-servers' {
|
||||
import * as http from 'node:http'
|
||||
|
||||
/** Configuration options for `create-servers`. */
|
||||
interface CreateServersOptions {
|
||||
http: number
|
||||
handler: http.RequestListener<http.IncomingMessage, http.ServerResponse>
|
||||
}
|
||||
|
||||
/** An error passed to a callback when a HTTP request fails. */
|
||||
interface HttpError {
|
||||
http: string
|
||||
}
|
||||
|
||||
export default function (
|
||||
option: CreateServersOptions,
|
||||
// This is a third-party module which we have no control over.
|
||||
|
1
app/ide-desktop/lib/types/types.d.ts
vendored
1
app/ide-desktop/lib/types/types.d.ts
vendored
@ -4,6 +4,7 @@
|
||||
// === Globally accessible interfaces ===
|
||||
// ======================================
|
||||
|
||||
/** A configuration in which values may be strings or nested configurations. */
|
||||
interface StringConfig {
|
||||
[key: string]: StringConfig | string
|
||||
}
|
||||
|
@ -14,13 +14,11 @@ export const INDENT_SIZE = 4
|
||||
// === Environment ===
|
||||
// ===================
|
||||
|
||||
/**
|
||||
* Get the environment variable value.
|
||||
/** Get the environment variable value.
|
||||
*
|
||||
* @param name - The name of the environment variable.
|
||||
* @returns The value of the environment variable.
|
||||
* @throws {Error} If the environment variable is not set.
|
||||
*/
|
||||
* @throws {Error} If the environment variable is not set. */
|
||||
export function requireEnv(name: string) {
|
||||
return (
|
||||
process.env[name] ??
|
||||
@ -30,24 +28,20 @@ export function requireEnv(name: string) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the path from environment variable and resolve it.
|
||||
/** Read the path from environment variable and resolve it.
|
||||
*
|
||||
* @param name - The name of the environment variable.
|
||||
* @returns The resolved path.
|
||||
* @throws {Error} If the environment variable is not set.
|
||||
*/
|
||||
* @throws {Error} If the environment variable is not set. */
|
||||
export function requireEnvResolvedPath(name: string) {
|
||||
return path.resolve(requireEnv(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the path from environment variable and resolve it. Verify that it exists.
|
||||
/** Read the path from environment variable and resolve it. Verify that it exists.
|
||||
*
|
||||
* @param name - The name of the environment variable.
|
||||
* @returns The resolved path.
|
||||
* @throws {Error} If the environment variable is not set or path does not exist.
|
||||
*/
|
||||
* @throws {Error} If the environment variable is not set or path does not exist. */
|
||||
export function requireEnvPathExist(name: string) {
|
||||
const value = requireEnv(name)
|
||||
if (fs.existsSync(value)) {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user