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:
somebody1234 2023-05-20 05:55:29 +10:00 committed by GitHub
parent 4f71673718
commit 658395e011
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 1613 additions and 1392 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)}).`)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@
// === Globally accessible interfaces ===
// ======================================
/** A configuration in which values may be strings or nested configurations. */
interface StringConfig {
[key: string]: StringConfig | string
}

View File

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