This commit is contained in:
Gregory Travis 2024-07-16 14:47:47 -04:00
commit 553bf6b4cc
58 changed files with 645 additions and 617 deletions

View File

@ -3,8 +3,10 @@
#### Enso Language & Runtime #### Enso Language & Runtime
- [Enforce conversion method return type][10468] - [Enforce conversion method return type][10468]
- [Renaming launcher executable to ensoup][10535]
[10468]: https://github.com/enso-org/enso/pull/10468 [10468]: https://github.com/enso-org/enso/pull/10468
[10535]: https://github.com/enso-org/enso/pull/10535
#### Enso IDE #### Enso IDE
@ -12,7 +14,7 @@
- [Numeric Widget does not accept non-numeric input][10457]. This is to prevent - [Numeric Widget does not accept non-numeric input][10457]. This is to prevent
node being completely altered by accidental code put to the widget. node being completely altered by accidental code put to the widget.
- [Redesigned "record control" panel][10509]. Now it contains more intuitive - [Redesigned "record control" panel][10509]. Now it contains more intuitive
"refresh" and "run workflow" buttons. "refresh" and "write all" buttons.
- [Warning messages do not obscure visualization buttons][10546]. - [Warning messages do not obscure visualization buttons][10546].
[10433]: https://github.com/enso-org/enso/pull/10443 [10433]: https://github.com/enso-org/enso/pull/10443

View File

@ -92,7 +92,7 @@ function readableBinding(binding: keyof (typeof graphBindings)['bindings']) {
icon="record" icon="record"
class="slot7 record" class="slot7 record"
data-testid="toggleRecord" data-testid="toggleRecord"
title="Record" title="Write Always"
:modelValue="props.isRecordingOverridden" :modelValue="props.isRecordingOverridden"
@update:modelValue="emit('update:isRecordingOverridden', $event)" @update:modelValue="emit('update:isRecordingOverridden', $event)"
/> />

View File

@ -18,7 +18,7 @@ const project = useProjectStore()
</div> </div>
<div class="control right-end"> <div class="control right-end">
<SvgButton <SvgButton
title="Run Workflow" title="Write All"
class="iconButton" class="iconButton"
name="workflow_play" name="workflow_play"
draggable="false" draggable="false"

View File

@ -39,10 +39,9 @@
* credentials. * credentials.
* *
* To redirect the user from the IDE to an external source: * To redirect the user from the IDE to an external source:
* 1. Call the {@link initIpc} function to register a listener for * 1. Register a listener for {@link ipc.Channel.openUrlInSystemBrowser} IPC events.
* {@link ipc.Channel.openUrlInSystemBrowser} IPC events. * 2. Emit an {@link ipc.Channel.openUrlInSystemBrowser} event. The listener registered in step
* 2. Emit an {@link ipc.Channel.openUrlInSystemBrowser} event. The listener registered in the * 1 will use the {@link opener} library to open the event's {@link URL}
* {@link initIpc} function will use the {@link opener} library to open the event's {@link URL}
* argument in the system web browser, in a cross-platform way. * argument in the system web browser, in a cross-platform way.
* *
* ## Redirect To IDE * ## Redirect To IDE
@ -57,7 +56,7 @@
* *
* To prepare the application to handle deep links: * To prepare the application to handle deep links:
* - Register a custom URL protocol scheme with the OS (c.f., `electron-builder-config.ts`). * - Register a custom URL protocol scheme with the OS (c.f., `electron-builder-config.ts`).
* - Define a listener for Electron `OPEN_URL_EVENT`s (c.f., {@link initOpenUrlListener}). * - Define a listener for Electron `OPEN_URL_EVENT`s.
* - Define a listener for {@link ipc.Channel.openDeepLink} events (c.f., `preload.ts`). * - Define a listener for {@link ipc.Channel.openDeepLink} events (c.f., `preload.ts`).
* *
* Then when the user clicks on a deep link from an external source to the IDE: * Then when the user clicks on a deep link from an external source to the IDE:
@ -71,7 +70,6 @@
* Then it parses the {@link URL} from the event's {@link URL} argument. Then it uses the * Then it parses the {@link URL} from the event's {@link URL} argument. Then it uses the
* {@link URL} to redirect the user to the dashboard, to the page specified in the {@link URL}'s * {@link URL} to redirect the user to the dashboard, to the page specified in the {@link URL}'s
* `pathname`. */ * `pathname`. */
import * as fs from 'node:fs' import * as fs from 'node:fs'
import * as os from 'node:os' import * as os from 'node:os'
import * as path from 'node:path' import * as path from 'node:path'
@ -97,65 +95,27 @@ const logger = contentConfig.logger
* not a variable because the main window is not available when this function is called. This module * not a variable because the main window is not available when this function is called. This module
* does not use the `window` until after it is initialized, so while the lambda may return `null` in * does not use the `window` until after it is initialized, so while the lambda may return `null` in
* theory, it never will in practice. */ * theory, it never will in practice. */
export function initModule(window: () => electron.BrowserWindow) { export function initAuthentication(window: () => electron.BrowserWindow) {
initIpc() // Listen for events to open a URL externally in a browser the user trusts. This is used for
initOpenUrlListener(window) // OAuth authentication, both for trustworthiness and for convenience (the ability to use the
initSaveAccessTokenListener() // browser's saved passwords).
}
/** 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
* event is fired, this listener will assume that the first and only argument of the event is a URL.
* This listener will then attempt to open the URL in a cross-platform way. The intent is to open
* the URL in the system browser.
*
* This functionality is necessary because we don't want to run the OAuth flow in the app. Users
* don't trust Electron apps to handle their credentials. */
function initIpc() {
electron.ipcMain.on(ipc.Channel.openUrlInSystemBrowser, (_event, url: string) => { electron.ipcMain.on(ipc.Channel.openUrlInSystemBrowser, (_event, url: string) => {
logger.log(`Opening URL in system browser: '${url}'.`) logger.log(`Opening URL '${url}' in the default browser.`)
urlAssociations.setAsUrlHandler()
opener(url) opener(url)
}) })
}
/** Register a listener that fires a callback for `open-url` events, when the URL is a deep link. // Listen for events to handle deep links.
*
* 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.
*
* All URLs that aren't deep links (i.e., URLs that don't use the {@link common.DEEP_LINK_SCHEME}
* protocol) will be ignored by this handler. Non-deep link URLs will be handled by Electron. */
function initOpenUrlListener(window: () => electron.BrowserWindow) {
urlAssociations.registerUrlCallback(url => { urlAssociations.registerUrlCallback(url => {
onOpenUrl(url, window) logger.log(`Received 'open-url' event for '${url.toString()}'.`)
if (url.protocol !== `${common.DEEP_LINK_SCHEME}:`) {
logger.error(`'${url.toString()}' is not a deep link, ignoring.`)
} else {
logger.log(`'${url.toString()}' is a deep link, sending to renderer.`)
window().webContents.send(ipc.Channel.openDeepLink, url.toString())
}
}) })
}
/** Handle the 'open-url' event by parsing the received URL, checking if it is a deep link, and // Listen for events to save the given user credentials to `~/.enso/credentials`.
* 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. */
export function onOpenUrl(url: URL, window: () => electron.BrowserWindow) {
logger.log(`Received 'open-url' event for '${url.toString()}'.`)
if (url.protocol !== `${common.DEEP_LINK_SCHEME}:`) {
logger.error(`'${url.toString()}' is not a deep link, ignoring.`)
} else {
logger.log(`'${url.toString()}' is a deep link, sending to renderer.`)
window().webContents.send(ipc.Channel.openDeepLink, url.toString())
}
}
/** 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
* the backend.
*
* The credentials file is placed in the user's home directory in the `.enso` subdirectory
* in the `credentials` file. */
function initSaveAccessTokenListener() {
electron.ipcMain.on( electron.ipcMain.on(
ipc.Channel.saveAccessToken, ipc.Channel.saveAccessToken,
(event, accessTokenPayload: SaveAccessTokenPayload | null) => { (event, accessTokenPayload: SaveAccessTokenPayload | null) => {

View File

@ -40,7 +40,7 @@ export interface ExternalFunctions {
project: stream.Readable, project: stream.Readable,
directory: string | null, directory: string | null,
name: string | null name: string | null
) => Promise<string> ) => Promise<projectManagement.ProjectInfo>
readonly runProjectManagerCommand: ( readonly runProjectManagerCommand: (
cliArguments: string[], cliArguments: string[],
body?: NodeJS.ReadableStream body?: NodeJS.ReadableStream
@ -269,14 +269,14 @@ export class Server {
const name = url.searchParams.get('name') const name = url.searchParams.get('name')
void this.config.externalFunctions void this.config.externalFunctions
.uploadProjectBundle(request, directory, name) .uploadProjectBundle(request, directory, name)
.then(id => { .then(project => {
response response
.writeHead(HTTP_STATUS_OK, [ .writeHead(HTTP_STATUS_OK, [
['Content-Length', String(id.length)], ['Content-Length', String(project.id.length)],
['Content-Type', 'text/plain'], ['Content-Type', 'text/plain'],
...common.COOP_COEP_CORP_HEADERS, ...common.COOP_COEP_CORP_HEADERS,
]) ])
.end(id) .end(project.id)
}) })
.catch(() => { .catch(() => {
response response

View File

@ -220,7 +220,7 @@ export class ChromeOption {
/** Replace `-no-...` with `--no-...`. This is a hotfix for a Yargs bug: /** Replace `-no-...` with `--no-...`. This is a hotfix for a Yargs bug:
* https://github.com/yargs/yargs-parser/issues/468. */ * https://github.com/yargs/yargs-parser/issues/468. */
function fixArgvNoPrefix(argv: string[]): string[] { function fixArgvNoPrefix(argv: readonly string[]): readonly string[] {
const singleDashPrefix = '-no-' const singleDashPrefix = '-no-'
const doubleDashPrefix = '--no-' const doubleDashPrefix = '--no-'
return argv.map(arg => { return argv.map(arg => {
@ -234,13 +234,13 @@ function fixArgvNoPrefix(argv: string[]): string[] {
/** Command line options, split into regular arguments and Chrome options. */ /** Command line options, split into regular arguments and Chrome options. */
interface ArgvAndChromeOptions { interface ArgvAndChromeOptions {
readonly argv: string[] readonly argv: readonly string[]
readonly chromeOptions: ChromeOption[] readonly chromeOptions: ChromeOption[]
} }
/** Parse the given list of arguments into two distinct sets: regular arguments and those specific /** Parse the given list of arguments into two distinct sets: regular arguments and those specific
* to Chrome. */ * to Chrome. */
function argvAndChromeOptions(processArgs: string[]): ArgvAndChromeOptions { function argvAndChromeOptions(processArgs: readonly string[]): ArgvAndChromeOptions {
const chromeOptionRegex = /--?chrome.([^=]*)(?:=(.*))?/ const chromeOptionRegex = /--?chrome.([^=]*)(?:=(.*))?/
const argv = [] const argv = []
const chromeOptions: ChromeOption[] = [] const chromeOptions: ChromeOption[] = []
@ -275,7 +275,7 @@ function argvAndChromeOptions(processArgs: string[]): ArgvAndChromeOptions {
// ===================== // =====================
/** Parse command line arguments. */ /** Parse command line arguments. */
export function parseArgs(clientArgs: string[] = fileAssociations.CLIENT_ARGUMENTS) { export function parseArgs(clientArgs: readonly string[] = fileAssociations.CLIENT_ARGUMENTS) {
const args = config.CONFIG const args = config.CONFIG
const { argv, chromeOptions } = argvAndChromeOptions(fixArgvNoPrefix(clientArgs)) const { argv, chromeOptions } = argvAndChromeOptions(fixArgvNoPrefix(clientArgs))
const yargsOptions = args.optionsRecursive().reduce((opts: Record<string, Options>, option) => { const yargsOptions = args.optionsRecursive().reduce((opts: Record<string, Options>, option) => {

View File

@ -4,8 +4,6 @@
* It includes utilities for determining if a file can be opened, managing the file opening * It includes utilities for determining if a file can be opened, managing the file opening
* process, and launching new instances of the IDE when necessary. The module also exports * process, and launching new instances of the IDE when necessary. The module also exports
* constants related to file associations and project handling. */ * constants related to file associations and project handling. */
import * as childProcess from 'node:child_process'
import * as fsSync from 'node:fs' import * as fsSync from 'node:fs'
import * as pathModule from 'node:path' import * as pathModule from 'node:path'
import process from 'node:process' import process from 'node:process'
@ -49,7 +47,7 @@ export const SOURCE_FILE_SUFFIX = fileAssociations.SOURCE_FILE_SUFFIX
* @param clientArgs - A list of arguments passed to the application, stripped from the initial * @param clientArgs - A list of arguments passed to the application, stripped from the initial
* executable name and any electron dev mode arguments. * 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 { export function argsDenoteFileOpenAttempt(clientArgs: readonly string[]): string | null {
const arg = clientArgs[0] const arg = clientArgs[0]
let result: string | null = null let result: string | null = null
// If the application is invoked with exactly one argument and this argument is a file, we // If the application is invoked with exactly one argument and this argument is a file, we
@ -70,21 +68,21 @@ export function argsDenoteFileOpenAttempt(clientArgs: string[]): string | null {
export const CLIENT_ARGUMENTS = getClientArguments() export const CLIENT_ARGUMENTS = getClientArguments()
/** Decide what are client arguments, @see {@link CLIENT_ARGUMENTS}. */ /** Decide what are client arguments, @see {@link CLIENT_ARGUMENTS}. */
function getClientArguments(): string[] { function getClientArguments(args = process.argv): readonly string[] {
if (electronIsDev) { if (electronIsDev) {
// Client arguments are separated from the electron dev mode arguments by a '--' argument. // Client arguments are separated from the electron dev mode arguments by a '--' argument.
const separator = '--' const separator = '--'
const separatorIndex = process.argv.indexOf(separator) const separatorIndex = args.indexOf(separator)
if (separatorIndex === NOT_FOUND) { if (separatorIndex === NOT_FOUND) {
// If there is no separator, client gets no arguments. // If there is no separator, client gets no arguments.
return [] return []
} else { } else {
// Drop everything before the separator. // Drop everything before the separator.
return process.argv.slice(separatorIndex + 1) return args.slice(separatorIndex + 1)
} }
} else { } else {
// Drop the leading executable name. // Drop the leading executable name.
return process.argv.slice(1) return args.slice(1)
} }
} }
@ -101,39 +99,14 @@ export function isFileOpenable(path: string): boolean {
) )
} }
/** On macOS when an Enso-associated file is opened, the application is first started and then it /** Callback called when a file is opened via the `open-file` event. */
* receives the `open-file` event. However, if there is already an instance of Enso running, export function onFileOpened(event: electron.Event, path: string): project.ProjectInfo | null {
* 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: electron.Event, path: string): string | null {
logger.log(`Received 'open-file' event for path '${path}'.`) logger.log(`Received 'open-file' event for path '${path}'.`)
if (isFileOpenable(path)) { if (isFileOpenable(path)) {
logger.log(`The file '${path}' is openable.`) logger.log(`The file '${path}' is openable.`)
// If we are not ready, we can still decide to open a project rather than enter the welcome event.preventDefault()
// screen. However, we still check for the presence of arguments, to prevent hijacking the logger.log(`Opening file '${path}'.`)
// user-spawned IDE instance (OS-spawned will not have arguments set). return handleOpenFile(path)
if (!electron.app.isReady() && CLIENT_ARGUMENTS.length === 0) {
event.preventDefault()
logger.log(`Opening file '${path}'.`)
return handleOpenFile(path)
} else {
// 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 +
"'."
)
const args = [path]
const child = childProcess.spawn(process.execPath, args, {
detached: true,
stdio: 'ignore',
})
// Prevent parent (this) process from waiting for the child to exit.
child.unref()
return null
}
} else { } else {
logger.log(`The file '${path}' is not openable, ignoring the 'open-file' event.`) logger.log(`The file '${path}' is not openable, ignoring the 'open-file' event.`)
return null return null
@ -143,11 +116,32 @@ export function onFileOpened(event: electron.Event, path: string): string | null
/** Set up the `open-file` event handler that might import a project and invoke the given callback, /** 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. * 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) { export function setOpenFileEventHandler(setProjectToOpen: (info: project.ProjectInfo) => void) {
electron.app.on('open-file', (event, path) => { electron.app.on('open-file', (event, path) => {
const projectId = onFileOpened(event, path) logger.log(`Opening file '${path}'.`)
if (typeof projectId === 'string') { const projectInfo = onFileOpened(event, path)
setProjectToOpen(projectId) if (projectInfo) {
setProjectToOpen(projectInfo)
}
})
electron.app.on('second-instance', (event, _argv, _workingDir, additionalData) => {
// Check if additional data is an object that contains the URL.
logger.log(`Checking path`, additionalData)
const path =
additionalData != null &&
typeof additionalData === 'object' &&
'fileToOpen' in additionalData &&
typeof additionalData.fileToOpen === 'string'
? additionalData.fileToOpen
: null
if (path != null) {
logger.log(`Got path '${path.toString()}' from second instance.`)
event.preventDefault()
const projectInfo = onFileOpened(event, path)
if (projectInfo) {
setProjectToOpen(projectInfo)
}
} }
}) })
} }
@ -159,7 +153,7 @@ export function setOpenFileEventHandler(setProjectToOpen: (id: string) => void)
* @param openedFile - The path to the file to open. * @param openedFile - The path to the file to open.
* @returns The ID of the project to open. * @returns The ID of the project to open.
* @throws {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 { export function handleOpenFile(openedFile: string): project.ProjectInfo {
try { try {
return project.importProjectFromPath(openedFile) return project.importProjectFromPath(openedFile)
} catch (error) { } catch (error) {
@ -189,7 +183,7 @@ export function handleFileArguments(openedFile: string | null, args: clientConfi
// This makes the IDE open the relevant project. Also, this prevents us from using this // 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 // method after IDE has been fully set up, as the initializing code would have already
// read the value of this argument. // read the value of this argument.
args.groups.startup.options.project.value = handleOpenFile(openedFile) args.groups.startup.options.project.value = handleOpenFile(openedFile).id
} catch (e) { } catch (e) {
// If we failed to open the file, we should enter the usual welcome screen. // If we failed to open the file, we should enter the usual welcome screen.
// The `handleOpenFile` function will have already displayed an error message. // The `handleOpenFile` function will have already displayed an error message.

View File

@ -60,8 +60,12 @@ class App {
log.addFileLog() log.addFileLog()
urlAssociations.registerAssociations() urlAssociations.registerAssociations()
// Register file associations for macOS. // Register file associations for macOS.
fileAssociations.setOpenFileEventHandler(id => { fileAssociations.setOpenFileEventHandler(project => {
this.setProjectToOpenOnStartup(id) if (electron.app.isReady()) {
this.window?.webContents.send(ipc.Channel.openProject, project)
} else {
this.setProjectToOpenOnStartup(project.id)
}
}) })
electron.app.commandLine.appendSwitch('allow-insecure-localhost', 'true') electron.app.commandLine.appendSwitch('allow-insecure-localhost', 'true')
@ -79,7 +83,6 @@ class App {
) )
const { windowSize, chromeOptions, fileToOpen, urlToOpen } = this.processArguments() const { windowSize, chromeOptions, fileToOpen, urlToOpen } = this.processArguments()
this.handleItemOpening(fileToOpen, urlToOpen)
if (this.args.options.version.value) { if (this.args.options.version.value) {
await this.printVersion() await this.printVersion()
electron.app.quit() electron.app.quit()
@ -89,37 +92,57 @@ class App {
electron.app.quit() electron.app.quit()
}) })
} else { } else {
this.setChromeOptions(chromeOptions) const isOriginalInstance = electron.app.requestSingleInstanceLock({
security.enableAll() fileToOpen,
electron.app.on('before-quit', () => (this.isQuitting = true)) urlToOpen,
electron.app.whenReady().then( })
() => { if (isOriginalInstance) {
logger.log('Electron application is ready.') this.handleItemOpening(fileToOpen, urlToOpen)
void this.main(windowSize) this.setChromeOptions(chromeOptions)
}, security.enableAll()
err => { electron.app.on('before-quit', () => {
logger.error('Failed to initialize electron.', err) this.isQuitting = true
} })
) electron.app.on('second-instance', (_event, argv) => {
this.registerShortcuts() logger.log(`Got data from 'second-instance' event: '${argv.toString()}'.`)
// The second instances will close themselves, but our window likely is not in the
// foreground - the focus went to the "second instance" of the application.
if (this.window) {
if (this.window.isMinimized()) {
this.window.restore()
}
this.window.focus()
} else {
logger.error('No window found after receiving URL from second instance.')
}
})
electron.app.whenReady().then(
async () => {
logger.log('Electron application is ready.')
await this.main(windowSize)
},
error => {
logger.error('Failed to initialize Electron.', error)
}
)
this.registerShortcuts()
} else {
logger.log('Another instance of the application is already running, exiting.')
electron.app.quit()
}
} }
} }
/** Process the command line arguments. */ /** Process the command line arguments. */
processArguments() { processArguments(args = fileAssociations.CLIENT_ARGUMENTS) {
// We parse only "client arguments", so we don't have to worry about the Electron-Dev vs // We parse only "client arguments", so we don't have to worry about the Electron-Dev vs
// Electron-Proper distinction. // Electron-Proper distinction.
const fileToOpen = fileAssociations.argsDenoteFileOpenAttempt( const fileToOpen = fileAssociations.argsDenoteFileOpenAttempt(args)
fileAssociations.CLIENT_ARGUMENTS const urlToOpen = urlAssociations.argsDenoteUrlOpenAttempt(args)
)
const urlToOpen = urlAssociations.argsDenoteUrlOpenAttempt(
fileAssociations.CLIENT_ARGUMENTS
)
// If we are opening a file (i.e. we were spawned with just a path of the file to open as // If we are opening a file (i.e. we were spawned with just a path of the file to open as
// the argument) or URL, it means that effectively we don't have any non-standard arguments. // the argument) or URL, it means that effectively we don't have any non-standard arguments.
// We just need to let caller know that we are opening a file. // We just need to let caller know that we are opening a file.
const argsToParse = const argsToParse = fileToOpen != null || urlToOpen != null ? [] : args
fileToOpen != null || urlToOpen != null ? [] : fileAssociations.CLIENT_ARGUMENTS
return { ...configParser.parseArgs(argsToParse), fileToOpen, urlToOpen } return { ...configParser.parseArgs(argsToParse), fileToOpen, urlToOpen }
} }
@ -153,8 +176,8 @@ class App {
// This makes the IDE open the relevant project. Also, this prevents us from using // 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 // this method after the IDE has been fully set up, as the initializing code
// would have already read the value of this argument. // would have already read the value of this argument.
const projectId = fileAssociations.handleOpenFile(fileToOpen) const projectInfo = fileAssociations.handleOpenFile(fileToOpen)
this.setProjectToOpenOnStartup(projectId) this.setProjectToOpenOnStartup(projectInfo.id)
} }
if (urlToOpen != null) { if (urlToOpen != null) {
@ -170,14 +193,14 @@ class App {
* Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. */ * Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. */
setChromeOptions(chromeOptions: configParser.ChromeOption[]) { setChromeOptions(chromeOptions: configParser.ChromeOption[]) {
const addIf = ( const addIf = (
opt: contentConfig.Option<boolean>, option: contentConfig.Option<boolean>,
chromeOptName: string, chromeOptName: string,
value?: string value?: string
) => { ) => {
if (opt.value) { if (option.value) {
const chromeOption = new configParser.ChromeOption(chromeOptName, value) const chromeOption = new configParser.ChromeOption(chromeOptName, value)
const chromeOptionStr = chromeOption.display() const chromeOptionStr = chromeOption.display()
const optionName = opt.qualifiedName() const optionName = option.qualifiedName()
logger.log(`Setting '${chromeOptionStr}' because '${optionName}' was enabled.`) logger.log(`Setting '${chromeOptionStr}' because '${optionName}' was enabled.`)
chromeOptions.push(chromeOption) chromeOptions.push(chromeOption)
} }
@ -233,7 +256,7 @@ class App {
* not yet created at this point, but it will be created by the time the * not yet created at this point, but it will be created by the time the
* authentication module uses the lambda providing the window. */ * authentication module uses the lambda providing the window. */
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
authentication.initModule(() => this.window!) authentication.initAuthentication(() => this.window!)
}) })
} catch (err) { } catch (err) {
logger.error('Failed to initialize the application, shutting down. Error: ', err) logger.error('Failed to initialize the application, shutting down. Error: ', err)
@ -390,9 +413,9 @@ class App {
} }
) )
window.on('close', evt => { window.on('close', event => {
if (!this.isQuitting && !this.args.groups.window.options.closeToQuit.value) { if (!this.isQuitting && !this.args.groups.window.options.closeToQuit.value) {
evt.preventDefault() event.preventDefault()
window.hide() window.hide()
} }
}) })

View File

@ -15,14 +15,14 @@ export enum Channel {
quit = 'quit-ide', quit = 'quit-ide',
/** Channel for requesting that a URL be opened by the system browser. */ /** Channel for requesting that a URL be opened by the system browser. */
openUrlInSystemBrowser = 'open-url-in-system-browser', openUrlInSystemBrowser = 'open-url-in-system-browser',
/** Channel for setting a callback that handles deep links to this application. */
setDeepLinkHandler = 'set-deep-link-handler',
/** Channel for signaling that a deep link to this application was opened. */ /** Channel for signaling that a deep link to this application was opened. */
openDeepLink = 'open-deep-link', openDeepLink = 'open-deep-link',
/** Channel for signaling that access token be saved to a credentials file. */ /** Channel for signaling that access token be saved to a credentials file. */
saveAccessToken = 'save-access-token', saveAccessToken = 'save-access-token',
/** Channel for importing a project or project bundle from the given path. */ /** Channel for importing a project or project bundle from the given path. */
importProjectFromPath = 'import-project-from-path', importProjectFromPath = 'import-project-from-path',
/** Channel for opening project */
openProject = 'open-project',
goBack = 'go-back', goBack = 'go-back',
goForward = 'go-forward', goForward = 'go-forward',
/** Channel for selecting files and directories using the system file browser. */ /** Channel for selecting files and directories using the system file browser. */

View File

@ -7,46 +7,49 @@ import * as electron from 'electron'
import * as debug from 'debug' import * as debug from 'debug'
import * as ipc from 'ipc' import * as ipc from 'ipc'
import type * as projectManagement from 'project-management'
// ================= // =================
// === Constants === // === Constants ===
// ================= // =================
/** Name given to the {@link BACKEND_API} object, when it is exposed on the Electron main
* window. */
const BACKEND_API_KEY = 'backendApi' const BACKEND_API_KEY = 'backendApi'
/** Name given to the {@link AUTHENTICATION_API} object, when it is exposed on the Electron main
* window. */
const AUTHENTICATION_API_KEY = 'authenticationApi' const AUTHENTICATION_API_KEY = 'authenticationApi'
/** Name given to the {@link FILE_BROWSER_API} object, when it is exposed on the Electron main
* window. */
const FILE_BROWSER_API_KEY = 'fileBrowserApi' const FILE_BROWSER_API_KEY = 'fileBrowserApi'
const PROJECT_MANAGEMENT_API_KEY = 'projectManagementApi'
const NAVIGATION_API_KEY = 'navigationApi' const NAVIGATION_API_KEY = 'navigationApi'
const MENU_API_KEY = 'menuApi' const MENU_API_KEY = 'menuApi'
const SYSTEM_API_KEY = 'systemApi' const SYSTEM_API_KEY = 'systemApi'
const VERSION_INFO_KEY = 'versionInfo' const VERSION_INFO_KEY = 'versionInfo'
// =========================
// === exposeInMainWorld ===
// =========================
/** A type-safe wrapper around {@link electron.contextBridge.exposeInMainWorld}. */
function exposeInMainWorld<Key extends string & keyof typeof window>(
key: Key,
value: NonNullable<(typeof window)[Key]>
) {
electron.contextBridge.exposeInMainWorld(key, value)
}
// ============================= // =============================
// === importProjectFromPath === // === importProjectFromPath ===
// ============================= // =============================
const IMPORT_PROJECT_RESOLVE_FUNCTIONS = new Map<string, (projectId: string) => void>() const IMPORT_PROJECT_RESOLVE_FUNCTIONS = new Map<string, (projectId: string) => void>()
const BACKEND_API = { exposeInMainWorld(BACKEND_API_KEY, {
importProjectFromPath: (projectPath: string, directory: string | null = null) => { importProjectFromPath: (projectPath: string, directory: string | null = null) => {
electron.ipcRenderer.send(ipc.Channel.importProjectFromPath, projectPath, directory) electron.ipcRenderer.send(ipc.Channel.importProjectFromPath, projectPath, directory)
return new Promise<string>(resolve => { return new Promise<string>(resolve => {
IMPORT_PROJECT_RESOLVE_FUNCTIONS.set(projectPath, resolve) IMPORT_PROJECT_RESOLVE_FUNCTIONS.set(projectPath, resolve)
}) })
}, },
} })
electron.contextBridge.exposeInMainWorld(BACKEND_API_KEY, BACKEND_API)
electron.contextBridge.exposeInMainWorld(NAVIGATION_API_KEY, { exposeInMainWorld(NAVIGATION_API_KEY, {
goBack: () => { goBack: () => {
electron.ipcRenderer.send(ipc.Channel.goBack) electron.ipcRenderer.send(ipc.Channel.goBack)
}, },
@ -64,71 +67,21 @@ electron.ipcRenderer.on(
} }
) )
// =======================
// === Debug Info APIs ===
// =======================
// These APIs expose functionality for use from Rust. See the bindings in the `debug_api` module for
// the primary documentation.
/** Shutdown-related commands and events. */
electron.contextBridge.exposeInMainWorld('enso_lifecycle', {
/** 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: () => {
electron.ipcRenderer.send(ipc.Channel.quit)
},
})
// Save and load profile data.
let onProfiles: ((profiles: string[]) => void)[] = []
let profilesLoaded: string[] | null
electron.ipcRenderer.on(ipc.Channel.profilesLoaded, (_event, profiles: string[]) => {
for (const callback of onProfiles) {
callback(profiles)
}
onProfiles = []
profilesLoaded = profiles
})
electron.contextBridge.exposeInMainWorld('enso_profiling_data', {
// Delivers profiling log.
saveProfile: (data: unknown) => {
electron.ipcRenderer.send(ipc.Channel.saveProfile, data)
},
// Requests any loaded profiling logs.
loadProfiles: (callback: (profiles: string[]) => void) => {
if (profilesLoaded == null) {
electron.ipcRenderer.send('load-profiles')
onProfiles.push(callback)
} else {
callback(profilesLoaded)
}
},
})
electron.contextBridge.exposeInMainWorld('enso_hardware_info', {
// Open a page displaying GPU debug info.
openGpuDebugInfo: () => {
electron.ipcRenderer.send(ipc.Channel.openGpuDebugInfo)
},
})
// Access to the system console that Electron was run from.
electron.contextBridge.exposeInMainWorld('enso_console', {
// Print an error message with `console.error`.
error: (data: unknown) => {
electron.ipcRenderer.send('error', data)
},
})
// ========================== // ==========================
// === Authentication API === // === Authentication API ===
// ========================== // ==========================
let currentDeepLinkHandler: ((event: Electron.IpcRendererEvent, url: string) => void) | null = null /** A callback called when a deep link is opened. */
type OpenDeepLinkHandler = (url: string) => void
let deepLinkHandler: OpenDeepLinkHandler | null = null
electron.ipcRenderer.on(
ipc.Channel.openDeepLink,
(_event: Electron.IpcRendererEvent, ...args: Parameters<OpenDeepLinkHandler>) => {
deepLinkHandler?.(...args)
}
)
/** Object exposed on the Electron main window; provides proxy functions to: /** Object exposed on the Electron main window; provides proxy functions to:
* - open OAuth flows in the system browser, and * - open OAuth flows in the system browser, and
@ -136,12 +89,12 @@ let currentDeepLinkHandler: ((event: Electron.IpcRendererEvent, url: string) =>
* *
* Some functions (i.e., the functions to open URLs in the system browser) are not available in * 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 * sandboxed processes (i.e., the dashboard). So the
* {@link electron.contextBridge.exposeInMainWorld} API is used to expose these functions. * {@link exposeInMainWorld} API is used to expose these functions.
* The functions are exposed via this "API object", which is added to the main window. * The functions are exposed via this "API object", which is added to the main window.
* *
* For more details, see: * For more details, see:
* https://www.electronjs.org/docs/latest/api/context-bridge#api-functions. */ * https://www.electronjs.org/docs/latest/api/context-bridge#api-functions. */
const AUTHENTICATION_API = { exposeInMainWorld(AUTHENTICATION_API_KEY, {
/** Open 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 * OAuth URLs must be opened this way because the dashboard application is sandboxed and thus
@ -156,13 +109,7 @@ const AUTHENTICATION_API = {
* system browser or email client. Handling the links involves resuming whatever flow was in * system browser or email client. Handling the links involves resuming whatever flow was in
* progress when the link was opened (e.g., an OAuth registration flow). */ * progress when the link was opened (e.g., an OAuth registration flow). */
setDeepLinkHandler: (callback: (url: string) => void) => { setDeepLinkHandler: (callback: (url: string) => void) => {
if (currentDeepLinkHandler != null) { deepLinkHandler = callback
electron.ipcRenderer.off(ipc.Channel.openDeepLink, currentDeepLinkHandler)
}
currentDeepLinkHandler = (_event, url: string) => {
callback(url)
}
electron.ipcRenderer.on(ipc.Channel.openDeepLink, currentDeepLinkHandler)
}, },
/** Save the access token to a credentials file. /** Save the access token to a credentials file.
* *
@ -171,14 +118,37 @@ const AUTHENTICATION_API = {
saveAccessToken: (accessTokenPayload: SaveAccessTokenPayload | null) => { saveAccessToken: (accessTokenPayload: SaveAccessTokenPayload | null) => {
electron.ipcRenderer.send(ipc.Channel.saveAccessToken, accessTokenPayload) electron.ipcRenderer.send(ipc.Channel.saveAccessToken, accessTokenPayload)
}, },
} })
electron.contextBridge.exposeInMainWorld(AUTHENTICATION_API_KEY, AUTHENTICATION_API)
const FILE_BROWSER_API = { // ========================
// === File Browser API ===
// ========================
exposeInMainWorld(FILE_BROWSER_API_KEY, {
openFileBrowser: (kind: 'any' | 'directory' | 'file' | 'filePath', defaultPath?: string) => openFileBrowser: (kind: 'any' | 'directory' | 'file' | 'filePath', defaultPath?: string) =>
electron.ipcRenderer.invoke(ipc.Channel.openFileBrowser, kind, defaultPath), electron.ipcRenderer.invoke(ipc.Channel.openFileBrowser, kind, defaultPath),
} })
electron.contextBridge.exposeInMainWorld(FILE_BROWSER_API_KEY, FILE_BROWSER_API)
// ==============================
// === Project management API ===
// ==============================
/** A callback when a project is opened by opening a fileusing the system's default method. */
type OpenProjectHandler = (projectInfo: projectManagement.ProjectInfo) => void
let openProjectHandler: OpenProjectHandler | undefined
electron.ipcRenderer.on(
ipc.Channel.openProject,
(_event: Electron.IpcRendererEvent, ...args: Parameters<OpenProjectHandler>) => {
openProjectHandler?.(...args)
}
)
exposeInMainWorld(PROJECT_MANAGEMENT_API_KEY, {
setOpenProjectHandler: (handler: (projectInfo: projectManagement.ProjectInfo) => void) => {
openProjectHandler = handler
},
})
// ================ // ================
// === Menu API === // === Menu API ===
@ -190,31 +160,27 @@ electron.ipcRenderer.on(ipc.Channel.showAboutModal, () => {
showAboutModalHandler?.() showAboutModalHandler?.()
}) })
const MENU_API = { exposeInMainWorld(MENU_API_KEY, {
setShowAboutModalHandler: (callback: () => void) => { setShowAboutModalHandler: (callback: () => void) => {
showAboutModalHandler = callback showAboutModalHandler = callback
}, },
} })
electron.contextBridge.exposeInMainWorld(MENU_API_KEY, MENU_API)
// ================== // ==================
// === System API === // === System API ===
// ================== // ==================
const SYSTEM_API = { exposeInMainWorld(SYSTEM_API_KEY, {
downloadURL: (url: string, headers?: Record<string, string>) => { downloadURL: (url: string, headers?: Record<string, string>) => {
electron.ipcRenderer.send(ipc.Channel.downloadURL, url, headers) electron.ipcRenderer.send(ipc.Channel.downloadURL, url, headers)
}, },
showItemInFolder: (fullPath: string) => { showItemInFolder: (fullPath: string) => {
electron.ipcRenderer.send(ipc.Channel.showItemInFolder, fullPath) electron.ipcRenderer.send(ipc.Channel.showItemInFolder, fullPath)
}, },
} })
electron.contextBridge.exposeInMainWorld(SYSTEM_API_KEY, SYSTEM_API)
// ==================== // ====================
// === Version info === // === Version info ===
// ==================== // ====================
electron.contextBridge.exposeInMainWorld(VERSION_INFO_KEY, debug.VERSION_INFO) exposeInMainWorld(VERSION_INFO_KEY, debug.VERSION_INFO)

View File

@ -30,6 +30,17 @@ export const PROJECT_METADATA_RELATIVE_PATH = '.enso/project.json'
/** The filename suffix for the project bundle, including the leading period character. */ /** The filename suffix for the project bundle, including the leading period character. */
const BUNDLED_PROJECT_SUFFIX = '.enso-project' const BUNDLED_PROJECT_SUFFIX = '.enso-project'
// ===================
// === ProjectInfo ===
// ===================
/** Metadata for a newly imported project. */
export interface ProjectInfo {
readonly id: string
readonly name: string
readonly parentDirectory: string
}
// ====================== // ======================
// === Project Import === // === Project Import ===
// ====================== // ======================
@ -43,7 +54,7 @@ export function importProjectFromPath(
openedPath: string, openedPath: string,
directory?: string | null, directory?: string | null,
name: string | null = null name: string | null = null
): string { ) {
directory ??= getProjectsDirectory() directory ??= getProjectsDirectory()
if (pathModule.extname(openedPath).endsWith(BUNDLED_PROJECT_SUFFIX)) { if (pathModule.extname(openedPath).endsWith(BUNDLED_PROJECT_SUFFIX)) {
logger.log(`Path '${openedPath}' denotes a bundled project.`) logger.log(`Path '${openedPath}' denotes a bundled project.`)
@ -77,7 +88,7 @@ export function importBundle(
bundlePath: string, bundlePath: string,
directory?: string | null, directory?: string | null,
name: string | null = null name: string | null = null
): string { ) {
directory ??= getProjectsDirectory() directory ??= getProjectsDirectory()
logger.log( logger.log(
`Importing project '${bundlePath}' from bundle${name != null ? ` as '${name}'` : ''}.` `Importing project '${bundlePath}' from bundle${name != null ? ` as '${name}'` : ''}.`
@ -136,7 +147,7 @@ export async function uploadBundle(
bundle: stream.Readable, bundle: stream.Readable,
directory?: string | null, directory?: string | null,
name: string | null = null name: string | null = null
): Promise<string> { ) {
directory ??= getProjectsDirectory() directory ??= getProjectsDirectory()
logger.log(`Uploading project from bundle${name != null ? ` as '${name}'` : ''}.`) logger.log(`Uploading project from bundle${name != null ? ` as '${name}'` : ''}.`)
const targetPath = generateDirectoryName(name ?? 'Project', directory) const targetPath = generateDirectoryName(name ?? 'Project', directory)
@ -167,14 +178,14 @@ export function importDirectory(
rootPath: string, rootPath: string,
directory?: string | null, directory?: string | null,
name: string | null = null name: string | null = null
): string { ): ProjectInfo {
directory ??= getProjectsDirectory() directory ??= getProjectsDirectory()
if (isProjectInstalled(rootPath, directory)) { if (isProjectInstalled(rootPath, directory)) {
// Project is already visible to Project Manager, so we can just return its ID. // Project is already visible to Project Manager, so we can just return its ID.
logger.log(`Project already installed at '${rootPath}'.`) logger.log(`Project already installed at '${rootPath}'.`)
const id = getProjectId(rootPath) const id = getProjectId(rootPath)
if (id != null) { if (id != null) {
return id return { id, name: getPackageName(rootPath) ?? '', parentDirectory: directory }
} else { } else {
throw new Error(`Project already installed, but missing metadata.`) throw new Error(`Project already installed, but missing metadata.`)
} }
@ -410,7 +421,7 @@ export function bumpMetadata(
projectRoot: string, projectRoot: string,
parentDirectory: string, parentDirectory: string,
name: string | null name: string | null
): string { ): ProjectInfo {
if (name == null) { if (name == null) {
const currentName = getPackageName(projectRoot) ?? '' const currentName = getPackageName(projectRoot) ?? ''
let index: number | null = null let index: number | null = null
@ -437,9 +448,10 @@ export function bumpMetadata(
name = index == null ? currentName : `${currentName} (${index})` name = index == null ? currentName : `${currentName} (${index})`
} }
updatePackageName(projectRoot, name) updatePackageName(projectRoot, name)
return updateMetadata(projectRoot, metadata => ({ const id = updateMetadata(projectRoot, metadata => ({
...metadata, ...metadata,
id: generateId(), id: generateId(),
lastOpened: new Date().toISOString(), lastOpened: new Date().toISOString(),
})).id })).id
return { id, name, parentDirectory }
} }

View File

@ -47,7 +47,7 @@ export function registerAssociations() {
* @param clientArgs - A list of arguments passed to the application, stripped from the initial * @param clientArgs - A list of arguments passed to the application, stripped from the initial
* executable name and any electron dev mode arguments. * 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 { export function argsDenoteUrlOpenAttempt(clientArgs: readonly string[]): URL | null {
const arg = clientArgs[0] const arg = clientArgs[0]
let result: URL | null = null let result: URL | null = null
logger.log(`Checking if '${clientArgs.toString()}' denotes a URL to open.`) logger.log(`Checking if '${clientArgs.toString()}' denotes a URL to open.`)
@ -74,25 +74,8 @@ let initialUrl: URL | null = null
* @param openedUrl - The URL to open. */ * @param openedUrl - The URL to open. */
export function handleOpenUrl(openedUrl: URL) { export function handleOpenUrl(openedUrl: URL) {
logger.log(`Opening URL '${openedUrl.toString()}'.`) logger.log(`Opening URL '${openedUrl.toString()}'.`)
const appLock = electron.app.requestSingleInstanceLock({ openedUrl }) // We must wait for the application to be ready and then send the URL to the renderer process.
if (!appLock) { initialUrl = openedUrl
// If we failed to acquire the lock, it means that another instance of the application is
// already running. In this case, we must send the URL to the existing instance and exit.
logger.log('Another instance of the application is already running, exiting.')
// Note that we need here to exit rather than quit. Otherwise, the application would
// continue initializing and would create a new window, before quitting.
// We don't want anything to flash on the screen, so we just exit.
electron.app.exit(0)
} else {
// If we acquired the lock, it means that we are the first instance of the application.
// In this case, we must wait for the application to be ready and then send the URL to the
// renderer process.
logger.log(
'This is the first instance of the application, ' +
'saving the URL to be opened when the first window is opened.'
)
initialUrl = openedUrl
}
} }
/** Register the callback that will be called when the application is requested to open a URL. /** Register the callback that will be called when the application is requested to open a URL.
@ -100,10 +83,6 @@ export function handleOpenUrl(openedUrl: URL) {
* This method serves to unify the url handling between macOS and Windows. On macOS, the OS * This method serves to unify the url handling between macOS and Windows. On macOS, the OS
* handles the URL opening by passing the `open-url` event to the application. On Windows, a * handles the URL opening by passing the `open-url` event to the application. On Windows, a
* new instance of the application is started and the URL is passed as a command line argument. * new instance of the application is started and the URL is passed as a command line argument.
*
* This method registers the callback for both events. Note that on Windows it is necessary to
* 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) { export function registerUrlCallback(callback: (url: URL) => void) {
if (initialUrl != null) { if (initialUrl != null) {
@ -119,64 +98,19 @@ export function registerUrlCallback(callback: (url: URL) => void) {
}) })
// Second, register the callback for the `second-instance` event. This is used on Windows. // Second, register the callback for the `second-instance` event. This is used on Windows.
electron.app.on('second-instance', (event, argv) => { electron.app.on('second-instance', (event, _argv, _workingDir, additionalData) => {
logger.log(`Got data from 'second-instance' event: '${argv.toString()}'.`)
unsetAsUrlHandler()
// Check if additional data is an object that contains the URL. // Check if additional data is an object that contains the URL.
const requestOneLastElementSlice = -1 const url =
const lastArgumentSlice = argv.slice(requestOneLastElementSlice) additionalData != null &&
const url = argsDenoteUrlOpenAttempt(lastArgumentSlice) typeof additionalData === 'object' &&
'urlToOpen' in additionalData &&
additionalData.urlToOpen instanceof URL
? additionalData.urlToOpen
: null
if (url) { if (url) {
logger.log(`Got URL from 'second-instance' event: '${url.toString()}'.`)
// Even we received the URL, our Window likely is not in the foreground - the focus
// went to the "second instance" of the application. We must bring our Window to the
// foreground, so the user gets back to the IDE after the authentication.
const primaryWindow = electron.BrowserWindow.getAllWindows()[0]
if (primaryWindow) {
if (primaryWindow.isMinimized()) {
primaryWindow.restore()
}
primaryWindow.focus()
} else {
logger.error('No primary window found after receiving URL from second instance.')
}
logger.log(`Got URL from second instance: '${url.toString()}'.`) logger.log(`Got URL from second instance: '${url.toString()}'.`)
event.preventDefault() event.preventDefault()
callback(url) callback(url)
} }
}) })
} }
// ===============================
// === Temporary handler setup ===
// ===============================
/** Make this application instance the recipient of URL callbacks.
*
* After the callback is received (or no longer expected), the `urlCallbackCompleted` function
* must be called. Otherwise, other IDE instances will not be able to receive their URL
* callbacks.
*
* The mechanism is built on top of the Electron's
* [instance lock]{@link https://www.electronjs.org/docs/api/app#apprequestsingleinstancelock}
* functionality.
* @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()) {
const message = 'Another instance of the application is already running. Exiting.'
logger.error(message)
// eslint-disable-next-line no-restricted-syntax
throw new Error(message)
}
}
/** 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. */
export function unsetAsUrlHandler() {
logger.log('URL callback completed, releasing the lock.')
electron.app.releaseSingleInstanceLock()
}

View File

@ -28,7 +28,7 @@ export function goToPageActions(
drive: () => drive: () =>
step('Go to "Data Catalog" page', page => step('Go to "Data Catalog" page', page =>
page page
.getByRole('button') .getByRole('tab')
.filter({ has: page.getByText('Data Catalog') }) .filter({ has: page.getByText('Data Catalog') })
.click() .click()
).into(DrivePageActions), ).into(DrivePageActions),

View File

@ -2,7 +2,6 @@
import * as test from '@playwright/test' import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
import EditorPageActions from './actions/EditorPageActions'
// ============= // =============
// === Tests === // === Tests ===
@ -162,8 +161,7 @@ test.test('duplicate', ({ page }) =>
.newEmptyProject() .newEmptyProject()
.goToPage.drive() .goToPage.drive()
.driveTable.rightClickRow(0) .driveTable.rightClickRow(0)
.contextMenu.duplicateProject() .contextMenu.duplicate()
.goToPage.drive()
.driveTable.withRows(async rows => { .driveTable.withRows(async rows => {
// Assets: [0: New Project 1 (copy), 1: New Project 1] // Assets: [0: New Project 1 (copy), 1: New Project 1]
await test.expect(rows).toHaveCount(2) await test.expect(rows).toHaveCount(2)
@ -181,8 +179,6 @@ test.test('duplicate (keyboard)', ({ page }) =>
.goToPage.drive() .goToPage.drive()
.driveTable.clickRow(0) .driveTable.clickRow(0)
.press('Mod+D') .press('Mod+D')
.into(EditorPageActions)
.goToPage.drive()
.driveTable.withRows(async rows => { .driveTable.withRows(async rows => {
// Assets: [0: New Project 1 (copy), 1: New Project 1] // Assets: [0: New Project 1 (copy), 1: New Project 1]
await test.expect(rows).toHaveCount(2) await test.expect(rows).toHaveCount(2)

View File

@ -47,7 +47,7 @@ export default function Link(props: LinkProps) {
href: to, href: to,
className, className,
target: '_blank', target: '_blank',
onClick: () => { onPress: () => {
toastify.toast.success(getText('openedLinkInBrowser')) toastify.toast.success(getText('openedLinkInBrowser'))
}, },
})} })}

View File

@ -171,9 +171,7 @@ export default function AssetRow(props: AssetRowProps) {
} }
}, [isKeyboardSelected]) }, [isKeyboardSelected])
React.useImperativeHandle(updateAssetRef, () => newItem => { React.useImperativeHandle(updateAssetRef, () => setAsset)
setAsset(newItem)
})
const doCopyOnBackend = React.useCallback( const doCopyOnBackend = React.useCallback(
async (newParentId: backendModule.DirectoryId | null) => { async (newParentId: backendModule.DirectoryId | null) => {
@ -197,7 +195,10 @@ export default function AssetRow(props: AssetRowProps) {
// This is SAFE, as the type of the copied asset is guaranteed to be the same // This is SAFE, as the type of the copied asset is guaranteed to be the same
// as the type of the original asset. // as the type of the original asset.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
object.merger(copiedAsset.asset as Partial<backendModule.AnyAsset>) object.merger({
...copiedAsset.asset,
state: { type: backendModule.ProjectState.new },
} as Partial<backendModule.AnyAsset>)
) )
} catch (error) { } catch (error) {
toastAndLog('copyAssetError', error, asset.title) toastAndLog('copyAssetError', error, asset.title)

View File

@ -8,6 +8,7 @@ enum AssetListEventType {
newDatalink = 'new-datalink', newDatalink = 'new-datalink',
newSecret = 'new-secret', newSecret = 'new-secret',
insertAssets = 'insert-assets', insertAssets = 'insert-assets',
openProject = 'open-project',
duplicateProject = 'duplicate-project', duplicateProject = 'duplicate-project',
closeFolder = 'close-folder', closeFolder = 'close-folder',
copy = 'copy', copy = 'copy',

View File

@ -155,27 +155,27 @@ export interface AssetRemoveSelfEvent extends AssetBaseEvent<AssetEventType.remo
readonly id: backend.AssetId readonly id: backend.AssetId
} }
/** A signal to temporarily add labels to the selected assetss. */ /** A signal to temporarily add labels to the selected assets. */
export interface AssetTemporarilyAddLabelsEvent export interface AssetTemporarilyAddLabelsEvent
extends AssetBaseEvent<AssetEventType.temporarilyAddLabels> { extends AssetBaseEvent<AssetEventType.temporarilyAddLabels> {
readonly ids: ReadonlySet<backend.AssetId> readonly ids: ReadonlySet<backend.AssetId>
readonly labelNames: ReadonlySet<backend.LabelName> readonly labelNames: ReadonlySet<backend.LabelName>
} }
/** A signal to temporarily remove labels from the selected assetss. */ /** A signal to temporarily remove labels from the selected assets. */
export interface AssetTemporarilyRemoveLabelsEvent export interface AssetTemporarilyRemoveLabelsEvent
extends AssetBaseEvent<AssetEventType.temporarilyRemoveLabels> { extends AssetBaseEvent<AssetEventType.temporarilyRemoveLabels> {
readonly ids: ReadonlySet<backend.AssetId> readonly ids: ReadonlySet<backend.AssetId>
readonly labelNames: ReadonlySet<backend.LabelName> readonly labelNames: ReadonlySet<backend.LabelName>
} }
/** A signal to add labels to the selected assetss. */ /** A signal to add labels to the selected assets. */
export interface AssetAddLabelsEvent extends AssetBaseEvent<AssetEventType.addLabels> { export interface AssetAddLabelsEvent extends AssetBaseEvent<AssetEventType.addLabels> {
readonly ids: ReadonlySet<backend.AssetId> readonly ids: ReadonlySet<backend.AssetId>
readonly labelNames: ReadonlySet<backend.LabelName> readonly labelNames: ReadonlySet<backend.LabelName>
} }
/** A signal to remove labels from the selected assetss. */ /** A signal to remove labels from the selected assets. */
export interface AssetRemoveLabelsEvent extends AssetBaseEvent<AssetEventType.removeLabels> { export interface AssetRemoveLabelsEvent extends AssetBaseEvent<AssetEventType.removeLabels> {
readonly ids: ReadonlySet<backend.AssetId> readonly ids: ReadonlySet<backend.AssetId>
readonly labelNames: ReadonlySet<backend.LabelName> readonly labelNames: ReadonlySet<backend.LabelName>

View File

@ -20,6 +20,7 @@ interface AssetListEvents {
readonly newSecret: AssetListNewSecretEvent readonly newSecret: AssetListNewSecretEvent
readonly newDatalink: AssetListNewDatalinkEvent readonly newDatalink: AssetListNewDatalinkEvent
readonly insertAssets: AssetListInsertAssetsEvent readonly insertAssets: AssetListInsertAssetsEvent
readonly openProject: AssetListOpenProjectEvent
readonly duplicateProject: AssetListDuplicateProjectEvent readonly duplicateProject: AssetListDuplicateProjectEvent
readonly closeFolder: AssetListCloseFolderEvent readonly closeFolder: AssetListCloseFolderEvent
readonly copy: AssetListCopyEvent readonly copy: AssetListCopyEvent
@ -86,6 +87,15 @@ interface AssetListInsertAssetsEvent extends AssetListBaseEvent<AssetListEventTy
readonly assets: backend.AnyAsset[] readonly assets: backend.AnyAsset[]
} }
/** A signal to open the specified project. */
export interface AssetListOpenProjectEvent
extends AssetListBaseEvent<AssetListEventType.openProject> {
readonly id: backend.ProjectId
readonly backendType: backend.BackendType
readonly title: string
readonly parentId: backend.DirectoryId
}
/** A signal to duplicate a project. */ /** A signal to duplicate a project. */
interface AssetListDuplicateProjectEvent interface AssetListDuplicateProjectEvent
extends AssetListBaseEvent<AssetListEventType.duplicateProject> { extends AssetListBaseEvent<AssetListEventType.duplicateProject> {

View File

@ -1,6 +1,7 @@
/** @file Table displaying a list of projects. */ /** @file Table displaying a list of projects. */
import * as React from 'react' import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as toast from 'react-toastify' import * as toast from 'react-toastify'
import DropFilesImage from 'enso-assets/drop_files.svg' import DropFilesImage from 'enso-assets/drop_files.svg'
@ -386,6 +387,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const { setSuggestions, initialProjectName } = props const { setSuggestions, initialProjectName } = props
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
const queryClient = reactQuery.useQueryClient()
const openedProjects = projectsProvider.useLaunchedProjects() const openedProjects = projectsProvider.useLaunchedProjects()
const doOpenProject = projectHooks.useOpenProject() const doOpenProject = projectHooks.useOpenProject()
@ -1722,6 +1724,22 @@ export default function AssetsTable(props: AssetsTableProps) {
insertArbitraryAssets(event.assets, event.parentKey, event.parentId) insertArbitraryAssets(event.assets, event.parentKey, event.parentId)
break break
} }
case AssetListEventType.openProject: {
dispatchAssetEvent({ ...event, type: AssetEventType.openProject, runInBackground: false })
void queryClient.invalidateQueries({
queryKey: [
event.backendType,
'listDirectory',
{
parentId: null,
filterBy: backendModule.FilterBy.active,
recentProjects: false,
labels: null,
},
],
})
break
}
case AssetListEventType.duplicateProject: { case AssetListEventType.duplicateProject: {
const siblings = nodeMapRef.current.get(event.parentKey)?.children ?? [] const siblings = nodeMapRef.current.get(event.parentKey)?.children ?? []
const siblingTitles = new Set(siblings.map(sibling => sibling.item.title)) const siblingTitles = new Set(siblings.map(sibling => sibling.item.title))

View File

@ -12,7 +12,9 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import StatelessSpinner, * as spinnerModule from '#/components/StatelessSpinner'
import FocusArea from '#/components/styled/FocusArea' import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask'
import * as backend from '#/services/Backend' import * as backend from '#/services/Backend'
@ -31,8 +33,7 @@ const TAB_RADIUS_PX = 24
/** Context for a {@link TabBarContext}. */ /** Context for a {@link TabBarContext}. */
interface TabBarContextValue { interface TabBarContextValue {
readonly updateClipPath: (element: HTMLDivElement | null) => void readonly setSelectedTab: (element: HTMLElement) => void
readonly observeElement: (element: HTMLElement) => () => void
} }
const TabBarContext = React.createContext<TabBarContextValue | null>(null) const TabBarContext = React.createContext<TabBarContextValue | null>(null)
@ -55,22 +56,23 @@ export interface TabBarProps extends Readonly<React.PropsWithChildren> {}
export default function TabBar(props: TabBarProps) { export default function TabBar(props: TabBarProps) {
const { children } = props const { children } = props
const cleanupResizeObserverRef = React.useRef(() => {}) const cleanupResizeObserverRef = React.useRef(() => {})
const backgroundRef = React.useRef<HTMLDivElement | null>(null) const backgroundRef = React.useRef<HTMLDivElement | null>()
const selectedTabRef = React.useRef<HTMLDivElement | null>(null) const selectedTabRef = React.useRef<HTMLElement | null>(null)
const [resizeObserver] = React.useState( const [resizeObserver] = React.useState(
() => () =>
new ResizeObserver(() => { new ResizeObserver(() => {
updateClipPath(selectedTabRef.current) updateClipPath(selectedTabRef.current)
}) })
) )
const [updateClipPath] = React.useState(() => { const [updateClipPath] = React.useState(() => {
return (element: HTMLDivElement | null) => { return (element: HTMLElement | null) => {
const backgroundElement = backgroundRef.current const backgroundElement = backgroundRef.current
if (backgroundElement != null) { if (backgroundElement != null) {
selectedTabRef.current = element
if (element == null) { if (element == null) {
backgroundElement.style.clipPath = '' backgroundElement.style.clipPath = ''
} else { } else {
selectedTabRef.current = element
const bounds = element.getBoundingClientRect() const bounds = element.getBoundingClientRect()
const rootBounds = backgroundElement.getBoundingClientRect() const rootBounds = backgroundElement.getBoundingClientRect()
const tabLeft = bounds.left - rootBounds.left const tabLeft = bounds.left - rootBounds.left
@ -96,9 +98,24 @@ export default function TabBar(props: TabBarProps) {
} }
}) })
const setSelectedTab = React.useCallback(
(element: HTMLElement | null) => {
if (element) {
updateClipPath(element)
resizeObserver.observe(element)
return () => {
resizeObserver.unobserve(element)
}
} else {
return
}
},
[resizeObserver, updateClipPath]
)
const updateResizeObserver = (element: HTMLElement | null) => { const updateResizeObserver = (element: HTMLElement | null) => {
cleanupResizeObserverRef.current() cleanupResizeObserverRef.current()
if (element == null) { if (!(element instanceof HTMLElement)) {
cleanupResizeObserverRef.current = () => {} cleanupResizeObserverRef.current = () => {}
} else { } else {
resizeObserver.observe(element) resizeObserver.observe(element)
@ -109,58 +126,35 @@ export default function TabBar(props: TabBarProps) {
} }
return ( return (
<TabBarContext.Provider <div className="relative flex grow">
value={{ <TabBarContext.Provider value={{ setSelectedTab }}>
updateClipPath, <FocusArea direction="horizontal">
observeElement: element => { {innerProps => (
resizeObserver.observe(element) <aria.TabList
className="flex h-12 shrink-0 grow cursor-default items-center rounded-full"
return () => { {...innerProps}
resizeObserver.unobserve(element) >
} <aria.Tab>
}, {/* Putting the background in a `Tab` is a hack, but it is required otherwise there
}} * are issues with the ref to the background being detached, resulting in the clip
> * path cutout for the current tab not applying at all. */}
<div className="relative flex grow"> <div
<div ref={element => {
ref={element => { backgroundRef.current = element
backgroundRef.current = element updateResizeObserver(element)
updateResizeObserver(element) }}
}} className="pointer-events-none absolute inset-0 bg-primary/5"
className="pointer-events-none absolute inset-0 bg-primary/5" />
/> </aria.Tab>
<Tabs>{children}</Tabs> {children}
</div> </aria.TabList>
</TabBarContext.Provider> )}
</FocusArea>
</TabBarContext.Provider>
</div>
) )
} }
// ============
// === Tabs ===
// ============
/** Props for a {@link TabsInternal}. */
export interface InternalTabsProps extends Readonly<React.PropsWithChildren> {}
/** A tab list in a {@link TabBar}. */
function TabsInternal(props: InternalTabsProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { children } = props
return (
<FocusArea direction="horizontal">
{innerProps => (
<div
className="flex h-12 shrink-0 grow cursor-default items-center rounded-full"
{...aria.mergeProps<React.JSX.IntrinsicElements['div']>()(innerProps, { ref })}
>
{children}
</div>
)}
</FocusArea>
)
}
const Tabs = React.forwardRef(TabsInternal)
// =========== // ===========
// === Tab === // === Tab ===
// =========== // ===========
@ -168,36 +162,31 @@ const Tabs = React.forwardRef(TabsInternal)
/** Props for a {@link Tab}. */ /** Props for a {@link Tab}. */
interface InternalTabProps extends Readonly<React.PropsWithChildren> { interface InternalTabProps extends Readonly<React.PropsWithChildren> {
readonly 'data-testid'?: string readonly 'data-testid'?: string
readonly id: string
readonly project?: projectHooks.Project readonly project?: projectHooks.Project
readonly isActive: boolean readonly isActive: boolean
readonly isHidden?: boolean
readonly icon: string readonly icon: string
readonly labelId: text.TextId readonly labelId: text.TextId
readonly onPress: () => void
readonly onClose?: () => void readonly onClose?: () => void
readonly onLoadEnd?: () => void readonly onLoadEnd?: () => void
} }
/** A tab in a {@link TabBar}. */ /** A tab in a {@link TabBar}. */
export function Tab(props: InternalTabProps) { export function Tab(props: InternalTabProps) {
const { isActive, icon, labelId, children, onPress, onClose, project, onLoadEnd } = props const { id, project, isActive, isHidden = false, icon, labelId, children, onClose } = props
const { updateClipPath, observeElement } = useTabBarContext() const { onLoadEnd } = props
const { setSelectedTab } = useTabBarContext()
const ref = React.useRef<HTMLDivElement | null>(null) const ref = React.useRef<HTMLDivElement | null>(null)
const isLoadingRef = React.useRef(true) const isLoadingRef = React.useRef(true)
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const actuallyActive = isActive && !isHidden
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (isActive) { if (actuallyActive && ref.current) {
updateClipPath(ref.current) setSelectedTab(ref.current)
} }
}, [isActive, updateClipPath]) }, [actuallyActive, id, setSelectedTab])
React.useEffect(() => {
if (ref.current) {
return observeElement(ref.current)
} else {
return () => {}
}
}, [observeElement])
const { isLoading, data } = reactQuery.useQuery<backend.Project>( const { isLoading, data } = reactQuery.useQuery<backend.Project>(
project?.id project?.id
@ -216,38 +205,42 @@ export function Tab(props: InternalTabProps) {
}, [isFetching, onLoadEnd]) }, [isFetching, onLoadEnd])
return ( return (
<div <aria.Tab
ref={ref} data-testid={props['data-testid']}
ref={element => {
ref.current = element
if (actuallyActive && element) {
setSelectedTab(element)
}
}}
id={id}
isDisabled={isActive}
aria-label={getText(labelId)}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'group relative flex h-full items-center gap-3', 'relative flex h-full items-center gap-3 rounded-t-2xl px-4',
!isActive && 'hover:enabled:bg-frame' !isActive &&
'cursor-pointer opacity-50 hover:bg-frame hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-30 [&.disabled]:cursor-not-allowed [&.disabled]:opacity-30',
isHidden && 'hidden'
)} )}
> >
<ariaComponents.Button {isLoading ? (
data-testid={props['data-testid']} <StatelessSpinner
size="custom" state={spinnerModule.SpinnerState.loadingMedium}
variant="custom" size={16}
loaderPosition="icon" className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
icon={icon} />
isDisabled={false} ) : (
isActive={isActive} <SvgMask
loading={isActive ? false : isFetching} src={icon}
aria-label={getText(labelId)} className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
className={tailwindMerge.twMerge('h-full', onClose ? 'pl-4' : 'px-4')} />
contentClassName="gap-3" )}
tooltip={false} {children}
onPress={onPress}
>
<ariaComponents.Text truncate="1" className="max-w-32">
{children}
</ariaComponents.Text>
</ariaComponents.Button>
{onClose && ( {onClose && (
<div className="flex pr-4"> <div className="flex">
<ariaComponents.CloseButton onPress={onClose} /> <ariaComponents.CloseButton onPress={onClose} />
</div> </div>
)} )}
</div> </aria.Tab>
) )
} }

View File

@ -14,6 +14,7 @@ import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import LocalStorage from '#/utilities/LocalStorage' import LocalStorage from '#/utilities/LocalStorage'
import * as object from '#/utilities/object'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
// ================= // =================
@ -95,6 +96,9 @@ export function TermsOfServiceModal() {
) )
if (shouldDisplay) { if (shouldDisplay) {
// Note that this produces warnings about missing a `<Heading slot="title">`, even though
// all `ariaComponents.Dialog`s contain one. This is likely caused by Suspense discarding
// renders, and so it does not seem to be fixable.
return ( return (
<> <>
<ariaComponents.Dialog <ariaComponents.Dialog
@ -129,7 +133,7 @@ export function TermsOfServiceModal() {
)} )}
id={checkboxId} id={checkboxId}
data-testid="terms-of-service-checkbox" data-testid="terms-of-service-checkbox"
{...register('agree')} {...object.omit(register('agree'), 'isInvalid')}
/> />
<label htmlFor={checkboxId}> <label htmlFor={checkboxId}>

View File

@ -38,6 +38,7 @@ import * as tabBar from '#/layouts/TabBar'
import TabBar from '#/layouts/TabBar' import TabBar from '#/layouts/TabBar'
import UserBar from '#/layouts/UserBar' import UserBar from '#/layouts/UserBar'
import * as aria from '#/components/aria'
import Page from '#/components/Page' import Page from '#/components/Page'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal' import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
@ -107,13 +108,14 @@ function DashboardInner(props: DashboardProps) {
? localBackendModule.newProjectId(projectManager.UUID(initialProjectNameRaw)) ? localBackendModule.newProjectId(projectManager.UUID(initialProjectNameRaw))
: null : null
const initialProjectName = initialLocalProjectId ?? initialProjectNameRaw const initialProjectName = initialLocalProjectId ?? initialProjectNameRaw
const isUserEnabled = user.isEnabled
const defaultCategory = initialLocalProjectId == null ? Category.cloud : Category.local
const [category, setCategory] = searchParamsState.useSearchParamsState( const [category, setCategory] = searchParamsState.useSearchParamsState(
'driveCategory', 'driveCategory',
() => defaultCategory, () => {
const shouldDefaultToCloud =
initialLocalProjectId == null && (user.isEnabled || localBackend == null)
return shouldDefaultToCloud ? Category.cloud : Category.local
},
(value): value is Category => { (value): value is Category => {
if (array.includes(Object.values(Category), value)) { if (array.includes(Object.values(Category), value)) {
return categoryModule.isLocal(value) ? localBackend != null : true return categoryModule.isLocal(value) ? localBackend != null : true
@ -122,19 +124,11 @@ function DashboardInner(props: DashboardProps) {
} }
} }
) )
const isCloud = categoryModule.isCloud(category)
const page = projectsProvider.usePage() const page = projectsProvider.usePage()
const launchedProjects = projectsProvider.useLaunchedProjects() const launchedProjects = projectsProvider.useLaunchedProjects()
const selectedProject = launchedProjects.find(p => p.id === page) ?? null const selectedProject = launchedProjects.find(p => p.id === page) ?? null
if (isCloud && !isUserEnabled && localBackend != null) {
setTimeout(() => {
// This sets `BrowserRouter`, so it must not be set synchronously.
setCategory(Category.local)
})
}
const setPage = projectsProvider.useSetPage() const setPage = projectsProvider.useSetPage()
const openEditor = projectHooks.useOpenEditor() const openEditor = projectHooks.useOpenEditor()
const openProject = projectHooks.useOpenProject() const openProject = projectHooks.useOpenProject()
@ -168,6 +162,22 @@ function DashboardInner(props: DashboardProps) {
} }
}) })
React.useEffect(() => {
window.projectManagementApi?.setOpenProjectHandler(project => {
setCategory(Category.local)
dispatchAssetListEvent({
type: AssetListEventType.openProject,
backendType: backendModule.BackendType.local,
id: localBackendModule.newProjectId(projectManager.UUID(project.id)),
title: project.name,
parentId: localBackendModule.newDirectoryId(backendModule.Path(project.parentDirectory)),
})
})
return () => {
window.projectManagementApi?.setOpenProjectHandler(() => {})
}
}, [dispatchAssetListEvent, setCategory])
React.useEffect( React.useEffect(
() => () =>
inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', { inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
@ -246,23 +256,30 @@ function DashboardInner(props: DashboardProps) {
return ( return (
<Page hideInfoBar hideChat> <Page hideInfoBar hideChat>
<div className="flex text-xs text-primary"> <div
<div className="flex text-xs text-primary"
onContextMenu={event => {
event.preventDefault()
unsetModal()
}}
>
<aria.Tabs
className="relative flex h-screen grow select-none flex-col container-size" className="relative flex h-screen grow select-none flex-col container-size"
onContextMenu={event => { selectedKey={page}
event.preventDefault() onSelectionChange={newPage => {
unsetModal() const validated = projectsProvider.PAGES_SCHEMA.safeParse(newPage)
if (validated.success) {
setPage(validated.data)
}
}} }}
> >
<div className="flex"> <div className="flex">
<TabBar> <TabBar>
<tabBar.Tab <tabBar.Tab
id={projectsProvider.TabType.drive}
isActive={page === projectsProvider.TabType.drive} isActive={page === projectsProvider.TabType.drive}
icon={DriveIcon} icon={DriveIcon}
labelId="drivePageName" labelId="drivePageName"
onPress={() => {
setPage(projectsProvider.TabType.drive)
}}
> >
{getText('drivePageName')} {getText('drivePageName')}
</tabBar.Tab> </tabBar.Tab>
@ -270,14 +287,12 @@ function DashboardInner(props: DashboardProps) {
{launchedProjects.map(project => ( {launchedProjects.map(project => (
<tabBar.Tab <tabBar.Tab
data-testid="editor-tab-button" data-testid="editor-tab-button"
id={project.id}
project={project} project={project}
key={project.id} key={project.id}
isActive={page === project.id} isActive={page === project.id}
icon={EditorIcon} icon={EditorIcon}
labelId="editorPageName" labelId="editorPageName"
onPress={() => {
setPage(project.id)
}}
onClose={() => { onClose={() => {
closeProject(project) closeProject(project)
}} }}
@ -289,21 +304,18 @@ function DashboardInner(props: DashboardProps) {
</tabBar.Tab> </tabBar.Tab>
))} ))}
{page === projectsProvider.TabType.settings && ( <tabBar.Tab
<tabBar.Tab isActive
isActive id={projectsProvider.TabType.settings}
icon={SettingsIcon} isHidden={page !== projectsProvider.TabType.settings}
labelId="settingsPageName" icon={SettingsIcon}
onPress={() => { labelId="settingsPageName"
setPage(projectsProvider.TabType.settings) onClose={() => {
}} setPage(projectsProvider.TabType.drive)
onClose={() => { }}
setPage(projectsProvider.TabType.drive) >
}} {getText('settingsPageName')}
> </tabBar.Tab>
{getText('settingsPageName')}
</tabBar.Tab>
)}
</TabBar> </TabBar>
<UserBar <UserBar
@ -315,51 +327,63 @@ function DashboardInner(props: DashboardProps) {
onSignOut={onSignOut} onSignOut={onSignOut}
/> />
</div> </div>
<aria.TabPanel
<Drive shouldForceMount
assetsManagementApiRef={assetManagementApiRef} id={projectsProvider.TabType.drive}
category={category} className="flex grow [&[data-inert]]:hidden"
setCategory={setCategory} >
hidden={page !== projectsProvider.TabType.drive} <Drive
initialProjectName={initialProjectName} assetsManagementApiRef={assetManagementApiRef}
category={category}
setCategory={setCategory}
hidden={page !== projectsProvider.TabType.drive}
initialProjectName={initialProjectName}
/>
</aria.TabPanel>
{appRunner != null &&
launchedProjects.map(project => (
<aria.TabPanel
shouldForceMount
id={project.id}
className="flex grow [&[data-inert]]:hidden"
>
<Editor
key={project.id}
hidden={page !== project.id}
ydocUrl={ydocUrl}
project={project}
projectId={project.id}
appRunner={appRunner}
isOpening={openProjectMutation.isPending}
isOpeningFailed={openProjectMutation.isError}
openingError={openProjectMutation.error}
startProject={openProjectMutation.mutate}
renameProject={newName => {
renameProjectMutation.mutate({ newName, project })
}}
/>
</aria.TabPanel>
))}
<aria.TabPanel id={projectsProvider.TabType.settings} className="flex grow">
<Settings />
</aria.TabPanel>
</aria.Tabs>
{process.env.ENSO_CLOUD_CHAT_URL != null ? (
<Chat
isOpen={isHelpChatOpen}
doClose={() => {
setIsHelpChatOpen(false)
}}
endpoint={process.env.ENSO_CLOUD_CHAT_URL}
/> />
) : (
{launchedProjects.map(project => ( <ChatPlaceholder
<Editor isOpen={isHelpChatOpen}
key={project.id} doClose={() => {
hidden={page !== project.id} setIsHelpChatOpen(false)
ydocUrl={ydocUrl} }}
project={project} />
projectId={project.id} )}
appRunner={appRunner}
isOpening={openProjectMutation.isPending}
isOpeningFailed={openProjectMutation.isError}
openingError={openProjectMutation.error}
startProject={openProjectMutation.mutate}
renameProject={newName => {
renameProjectMutation.mutate({ newName, project })
}}
/>
))}
{page === projectsProvider.TabType.settings && <Settings />}
{process.env.ENSO_CLOUD_CHAT_URL != null ? (
<Chat
isOpen={isHelpChatOpen}
doClose={() => {
setIsHelpChatOpen(false)
}}
endpoint={process.env.ENSO_CLOUD_CHAT_URL}
/>
) : (
<ChatPlaceholder
isOpen={isHelpChatOpen}
doClose={() => {
setIsHelpChatOpen(false)
}}
/>
)}
</div>
</div> </div>
</Page> </Page>
) )

View File

@ -36,7 +36,7 @@ declare module '#/utilities/LocalStorage' {
} }
} }
const PAGES_SCHEMA = z export const PAGES_SCHEMA = z
.nativeEnum(TabType) .nativeEnum(TabType)
.or(z.custom<projectHooks.ProjectId>(value => typeof value === 'string')) .or(z.custom<projectHooks.ProjectId>(value => typeof value === 'string'))

View File

@ -93,6 +93,35 @@ interface SystemApi {
readonly showItemInFolder: (fullPath: string) => void readonly showItemInFolder: (fullPath: string) => void
} }
// ========================
// === File Browser API ===
// ========================
/** `window.fileBrowserApi` exposes functionality related to the system's default file picker. */
interface FileBrowserApi {
readonly openFileBrowser: (
kind: 'any' | 'directory' | 'file' | 'filePath',
defaultPath?: string
) => Promise<unknown>
}
// ==============================
// === Project Management API ===
// ==============================
/** Metadata for a newly imported project. */
interface ProjectInfo {
readonly id: string
readonly name: string
readonly parentDirectory: string
}
/** `window.projectManagementApi` exposes functionality related to system events related to
* project management. */
interface ProjectManagementApi {
readonly setOpenProjectHandler: (handler: (projectInfo: ProjectInfo) => void) => void
}
// ==================== // ====================
// === Version Info === // === Version Info ===
// ==================== // ====================
@ -120,6 +149,8 @@ declare global {
readonly navigationApi: NavigationApi readonly navigationApi: NavigationApi
readonly menuApi: MenuApi readonly menuApi: MenuApi
readonly systemApi?: SystemApi readonly systemApi?: SystemApi
readonly fileBrowserApi?: FileBrowserApi
readonly projectManagementApi?: ProjectManagementApi
readonly versionInfo?: VersionInfo readonly versionInfo?: VersionInfo
toggleDevtools: () => void toggleDevtools: () => void
} }

View File

@ -2738,7 +2738,9 @@ lazy val launcher = project
.in(file("engine/launcher")) .in(file("engine/launcher"))
.configs(Test) .configs(Test)
.settings( .settings(
frgaalJavaCompilerSetting,
resolvers += Resolver.bintrayRepo("gn0s1s", "releases"), resolvers += Resolver.bintrayRepo("gn0s1s", "releases"),
commands += WithDebugCommand.withDebug,
libraryDependencies ++= Seq( libraryDependencies ++= Seq(
"com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion, "com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion,
"org.apache.commons" % "commons-compress" % commonsCompressVersion, "org.apache.commons" % "commons-compress" % commonsCompressVersion,
@ -2751,7 +2753,7 @@ lazy val launcher = project
NativeImage.additionalCp := Seq.empty, NativeImage.additionalCp := Seq.empty,
rebuildNativeImage := NativeImage rebuildNativeImage := NativeImage
.buildNativeImage( .buildNativeImage(
"enso", "ensoup",
staticOnLinux = true, staticOnLinux = true,
additionalOptions = Seq( additionalOptions = Seq(
"-Dorg.apache.commons.logging.Log=org.apache.commons.logging.impl.NoOpLog", "-Dorg.apache.commons.logging.Log=org.apache.commons.logging.impl.NoOpLog",
@ -2766,7 +2768,7 @@ lazy val launcher = project
buildNativeImage := NativeImage buildNativeImage := NativeImage
.incrementalNativeImageBuild( .incrementalNativeImageBuild(
rebuildNativeImage, rebuildNativeImage,
"enso" "ensoup"
) )
.value, .value,
assembly / test := {}, assembly / test := {},

View File

@ -71,8 +71,7 @@ type Encoding
default -> Encoding = default -> Encoding =
# This factory method is used to publicly expose the `Default` constructor. # This factory method is used to publicly expose the `Default` constructor.
# The constructor itself has to be private, because we want to make `Value` constructor private, but all constructors must have the same privacy. # The constructor itself has to be private, because we want to make `Value` constructor private, but all constructors must have the same privacy.
# ToDo: This is a workaround for performance issue. Encoding.Default
Encoding.utf_8
## PRIVATE ## PRIVATE
A default encoding that will try to guess the encoding based on some heuristics. A default encoding that will try to guess the encoding based on some heuristics.

View File

@ -121,7 +121,7 @@ type File
temp.delete_if_exists temp.delete_if_exists
## Attach a warning to the file that it is a dry run ## Attach a warning to the file that it is a dry run
warning = Dry_Run_Operation.Warning "Only a dry run has occurred, with data written to a temporary file. Press the Run Workflow button ▶ to write the actual file." warning = Dry_Run_Operation.Warning "Only a dry run has occurred, with data written to a temporary file. Press the Write button ▶ to write the actual file."
Warning.attach warning temp Warning.attach warning temp
## ALIAS current directory ## ALIAS current directory

View File

@ -51,7 +51,7 @@ create_table_implementation connection table_name structure primary_key temporar
internal_create_table_structure connection effective_table_name structure primary_key effective_temporary on_problems internal_create_table_structure connection effective_table_name structure primary_key effective_temporary on_problems
if dry_run.not then connection.query (SQL_Query.Table_Name created_table_name) else if dry_run.not then connection.query (SQL_Query.Table_Name created_table_name) else
created_table = connection.base_connection.internal_allocate_dry_run_table created_table_name created_table = connection.base_connection.internal_allocate_dry_run_table created_table_name
warning = Dry_Run_Operation.Warning "Only a dry run of `create_table` has occurred, creating a temporary table ("+created_table_name.pretty+"). Run Workflow button ▶ to create the actual one." warning = Dry_Run_Operation.Warning "Only a dry run of `create_table` has occurred, creating a temporary table ("+created_table_name.pretty+"). Press the Write button ▶ to create the actual one."
Warning.attach warning created_table Warning.attach warning created_table
## PRIVATE ## PRIVATE
@ -118,7 +118,7 @@ select_into_table_implementation source_table connection table_name primary_key
connection.drop_table tmp_table_name if_exists=True connection.drop_table tmp_table_name if_exists=True
internal_upload_table source_table connection tmp_table_name primary_key temporary=True on_problems=on_problems row_limit=dry_run_row_limit internal_upload_table source_table connection tmp_table_name primary_key temporary=True on_problems=on_problems row_limit=dry_run_row_limit
temporary_table = connection.base_connection.internal_allocate_dry_run_table table.name temporary_table = connection.base_connection.internal_allocate_dry_run_table table.name
warning = Dry_Run_Operation.Warning "Only a dry run of `select_into_database_table` was performed - a temporary table ("+tmp_table_name+") was created, containing a sample of the data. Run Workflow button ▶ to write to the actual table." warning = Dry_Run_Operation.Warning "Only a dry run of `select_into_database_table` was performed - a temporary table ("+tmp_table_name+") was created, containing a sample of the data. Press the Write button ▶ to write to the actual table."
Warning.attach warning temporary_table Warning.attach warning temporary_table
## PRIVATE ## PRIVATE
@ -345,7 +345,7 @@ common_update_table (source_table : DB_Table | Table) (target_table : DB_Table)
above fails, the whole transaction will be rolled back. above fails, the whole transaction will be rolled back.
connection.drop_table tmp_table.name connection.drop_table tmp_table.name
if dry_run.not then resulting_table else if dry_run.not then resulting_table else
warning = Dry_Run_Operation.Warning "Only a dry run of `update_rows` was performed - the target table has been returned unchanged. Press the Run Workflow button ▶ to update the actual table." warning = Dry_Run_Operation.Warning "Only a dry run of `update_rows` was performed - the target table has been returned unchanged. Press the Write button ▶ to update the actual table."
Warning.attach warning resulting_table Warning.attach warning resulting_table
## PRIVATE ## PRIVATE
@ -551,7 +551,7 @@ common_delete_rows target_table key_values_to_delete key_columns allow_duplicate
source.drop_temporary_table connection source.drop_temporary_table connection
if dry_run.not then affected_row_count else if dry_run.not then affected_row_count else
suffix = source.dry_run_message_suffix suffix = source.dry_run_message_suffix
warning = Dry_Run_Operation.Warning "Only a dry run of `delete_rows` was performed - the target table has not been changed. Press the Run Workflow button ▶ to update the actual table."+suffix warning = Dry_Run_Operation.Warning "Only a dry run of `delete_rows` was performed - the target table has not been changed. Press the Write button ▶ to update the actual table."+suffix
Warning.attach warning affected_row_count Warning.attach warning affected_row_count
## PRIVATE ## PRIVATE

View File

@ -175,7 +175,7 @@ In order to build and run Enso you will need the following tools:
should be installed by default on most distributions. should be installed by default on most distributions.
- On Windows, the `run` command must be run in the latest version of - On Windows, the `run` command must be run in the latest version of
`Powershell` or in `cmd`. `Powershell` or in `cmd`.
- If you want to be able to build the Launcher Native Image, you will need a - If you want to be able to build the `ensoup` Native Image, you will need a
native C compiler for your platform as described in the native C compiler for your platform as described in the
[Native Image Prerequisites](https://www.graalvm.org/reference-manual/native-image/#prerequisites). [Native Image Prerequisites](https://www.graalvm.org/reference-manual/native-image/#prerequisites).
On Linux that will be `gcc`, on macOS you may need `xcode` and on Windows you On Linux that will be `gcc`, on macOS you may need `xcode` and on Windows you
@ -294,17 +294,9 @@ shell will execute the appropriate thing. Furthermore we have `testOnly` and
`benchOnly` that accept a glob pattern that delineates some subset of the tests `benchOnly` that accept a glob pattern that delineates some subset of the tests
or benchmarks to run (e.g. `testOnly *FunctionArguments*`). or benchmarks to run (e.g. `testOnly *FunctionArguments*`).
#### Building the Launcher Native Binary #### Building the Updater Native Binary
If you want to build the native launcher binary, you need to ensure that the Then, you can build the updater/launcher using:
Native Image component is installed in your GraalVM distribution. To install it,
run:
```bash
<path-to-graal-home>/bin/gu install native-image
```
Then, you can build the launcher using:
```bash ```bash
sbt launcher/buildNativeImage sbt launcher/buildNativeImage

View File

@ -15,7 +15,7 @@ any additional dependencies.
<!-- MarkdownTOC levels="2,3" autolink="true" --> <!-- MarkdownTOC levels="2,3" autolink="true" -->
- [Project Manager Bundle](#project-manager-bundle) - [Project Manager Bundle](#project-manager-bundle)
- [Launcher Bundles](#launcher-bundles) - [`ensoup` Bundles](#ensoup-bundles)
<!-- /MarkdownTOC --> <!-- /MarkdownTOC -->
@ -59,12 +59,12 @@ the case for example if the Project Manager bundle is packaged as part of IDE's
AppImage package). In such situation, it will be impossible to uninstall the AppImage package). In such situation, it will be impossible to uninstall the
bundled components and a relevant error message will be returned. bundled components and a relevant error message will be returned.
## Launcher Bundles ## `ensoup` Bundles
Bundles are also distributed for the launcher, but these are implemented using a Bundles are also distributed for the `ensoup` updater, but these are implemented
different mechanism. using a different mechanism.
Since the launcher can run in Since the `ensoup` can run in
[portable mode](distribution.md#portable-enso-distribution-layout), the bundled [portable mode](distribution.md#portable-enso-distribution-layout), the bundled
engine and runtime are simply included within its portable package. They can engine and runtime are simply included within its portable package. They can
then be used from within this portable package or then be used from within this portable package or

View File

@ -32,12 +32,14 @@ run, or use the default version if none specified. It should also be able to
launch other Enso components, provided as launch other Enso components, provided as
[plugins](./launcher.md#running-plugins). [plugins](./launcher.md#running-plugins).
<!--
> This launcher is under development. Until it is in a ready-to-use state, the > This launcher is under development. Until it is in a ready-to-use state, the
> Enso version packages provide simple launcher scripts in the `bin` directory > Enso version packages provide simple launcher scripts in the `bin` directory
> of that package. They are a temporary replacement for the launcher > of that package. They are a temporary replacement for the launcher
> functionality, so once the universal launcher matures, they will be removed. > functionality, so once the universal launcher matures, they will be removed.
> The universal launcher will not call the components through these scripts, as > The universal launcher will not call the components through these scripts, as
> it must have full control over which JVM is chosen and its parameters. > it must have full control over which JVM is chosen and its parameters.
-->
## Enso Distribution Layout ## Enso Distribution Layout
@ -55,7 +57,7 @@ The directory structure is as follows:
``` ```
extraction-location extraction-location
├── bin ├── bin
│ └── enso # The universal launcher, responsible for choosing the appropriate compiler version. │ └── ensoup # The universal launcher, responsible for choosing the appropriate compiler version.
├── config ├── config
│ └── global-config.yaml # Global user configuration. │ └── global-config.yaml # Global user configuration.
├── dist # Per-compiler-version distribution directories. ├── dist # Per-compiler-version distribution directories.
@ -112,7 +114,7 @@ ENSO_CONFIG_DIRECTORY
└── global-config.yaml # Global user configuration. └── global-config.yaml # Global user configuration.
ENSO_BIN_DIRECTORY ENSO_BIN_DIRECTORY
└── enso # The universal launcher, responsible for choosing the appropriate compiler version. └── ensoup # The universal launcher, responsible for choosing the appropriate compiler version.
``` ```
Where `ENSO_DATA_DIRECTORY`, `ENSO_CONFIG_DIRECTORY` and `ENSO_BIN_DIRECTORY` Where `ENSO_DATA_DIRECTORY`, `ENSO_CONFIG_DIRECTORY` and `ENSO_BIN_DIRECTORY`

View File

@ -6,7 +6,7 @@ tags: [distribution, launcher]
order: 4 order: 4
--- ---
# Enso Launcher # Enso Updater/Launcher
The launcher is used to run Enso commands (like the REPL, language server etc.) The launcher is used to run Enso commands (like the REPL, language server etc.)
and seamlessly manage Enso versions. This document describes it's features. Its and seamlessly manage Enso versions. This document describes it's features. Its

View File

@ -14,10 +14,10 @@ Each release has an "Assets" section at the bottom. You can click on this to
view the list of artifacts from which you can download the most appropriate view the list of artifacts from which you can download the most appropriate
version. version.
These assets contain bundles that include the Enso launcher, an engine version, These assets contain bundles that include the `ensoup` updater, an engine
and GraalVM, allowing you to get up and running immediately. Alternatively, you version, and GraalVM, allowing you to get up and running immediately.
can download just the launcher, which will handle downloading and installing the Alternatively, you can download just the updater, which will handle downloading
required components for you. and installing the required components for you.
<!-- MarkdownTOC levels="2,3" autolink="true" --> <!-- MarkdownTOC levels="2,3" autolink="true" -->

View File

@ -14,7 +14,7 @@ up as follows:
- [**sbt:**](sbt.md) The build tools that are used for building the project. - [**sbt:**](sbt.md) The build tools that are used for building the project.
- [**Native Image:**](native-image.md) Description of the Native Image build - [**Native Image:**](native-image.md) Description of the Native Image build
used for building the launcher native binary. used for building the `ensoup` native binary.
- [**Rust:**](rust.md) Description of integration of the Scala project with the - [**Rust:**](rust.md) Description of integration of the Scala project with the
Rust components. Rust components.
- [**Upgrading GraalVM:**](upgrading-graalvm.md) Description of steps that have - [**Upgrading GraalVM:**](upgrading-graalvm.md) Description of steps that have

View File

@ -140,8 +140,8 @@ environmental variable but it depends on which component we are executing.
server that collects logs (as defined in `logging-service.server` config key) server that collects logs (as defined in `logging-service.server` config key)
and the logs output can be overwritten by `ENSO_LOGSERVER_APPENDER` env and the logs output can be overwritten by `ENSO_LOGSERVER_APPENDER` env
variable variable
- `launcher` or `runner` - the default log output can be overwritten by defining - `ensoup` or `enso` - the default log output can be overwritten by defining the
the `ENSO_APPENDER_DEFAULT` env variable `ENSO_APPENDER_DEFAULT` env variable
For example, for the project manager to output to `console` one simply executes For example, for the project manager to output to `console` one simply executes

View File

@ -21,7 +21,7 @@ Native Image is used for building the Launcher.
- [Static Builds](#static-builds) - [Static Builds](#static-builds)
- [No Cross-Compilation](#no-cross-compilation) - [No Cross-Compilation](#no-cross-compilation)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Launcher Configuration](#launcher-configuration) - [`ensoup` Configuration](#ensoup-configuration)
- [Project Manager Configuration](#project-manager-configuration) - [Project Manager Configuration](#project-manager-configuration)
<!-- /MarkdownTOC --> <!-- /MarkdownTOC -->
@ -63,7 +63,7 @@ replaced with absolute paths to the bundle location.
The task is parametrized with `staticOnLinux` parameter which if set to `true`, The task is parametrized with `staticOnLinux` parameter which if set to `true`,
will statically link the built binary, to ensure portability between Linux will statically link the built binary, to ensure portability between Linux
distributions. For Windows and MacOS, the binaries should generally be portable, distributions. For Windows and MacOS, the binaries should generally be portable,
as described in [Launcher Portability](../distribution/launcher.md#portability). as described in [`ensoup` Portability](../distribution/launcher.md#portability).
## No Cross-Compilation ## No Cross-Compilation
@ -105,7 +105,7 @@ java \
<application arguments> <application arguments>
``` ```
For example, to update settings for the Launcher: For example, to update settings for the Launcher project:
```bash ```bash
java -agentlib:native-image-agent=config-merge-dir=engine/launcher/src/main/resources/META-INF/native-image/org/enso/launcher -jar launcher.jar <arguments> java -agentlib:native-image-agent=config-merge-dir=engine/launcher/src/main/resources/META-INF/native-image/org/enso/launcher -jar launcher.jar <arguments>
@ -139,7 +139,7 @@ After updating the Native Image configuration, make sure to clean it by running
cd tools/native-image-config-cleanup && npm install && npm start cd tools/native-image-config-cleanup && npm install && npm start
``` ```
### Launcher Configuration ### `ensoup` Configuration
In case of the launcher, to gather the relevant reflective accesses one wants to In case of the launcher, to gather the relevant reflective accesses one wants to
test as many execution paths as possible, especially the ones that are likely to test as many execution paths as possible, especially the ones that are likely to

View File

@ -18,13 +18,6 @@ take the following actions to be able to continue development after the upgrade:
updating, removing `engine/runtime/build-cache` directory may help). updating, removing `engine/runtime/build-cache` directory may help).
3. Do a full clean (it may not _always_ be required, but not doing it often 3. Do a full clean (it may not _always_ be required, but not doing it often
leads to problems so it is much safer to do it) by running `enso/clean`. leads to problems so it is much safer to do it) by running `enso/clean`.
4. To be able to build or run tests for the `launcher` project, Native Image for
the new GraalVM version has to be installed, as it is not included by
default. This can be done with
`<path-to-graal-home>/bin/gu install native-image`.
- If there are problems building the Native Image, removing
`engine/launcher/build-cache` (which contains the downloaded `musl`
package) may help.
## Upgrading the Build ## Upgrading the Build

View File

@ -0,0 +1 @@
package org.enso.launcher;

View File

@ -4,6 +4,9 @@ import org.enso.semver.SemVer
object Constants { object Constants {
/** Base name of the launcher executable */
val name = "ensoup"
/** The engine version in which the uploads command has been introduced. /** The engine version in which the uploads command has been introduced.
* *
* It is used to check by the launcher if the engine can handle this command * It is used to check by the launcher if the engine can handle this command

View File

@ -729,9 +729,9 @@ object LauncherApplication {
val application: Application[Config] = val application: Application[Config] =
Application( Application(
"enso", "ensoup",
"Enso", "Enso",
"Enso Launcher", "Enso Updater",
topLevelOpts, topLevelOpts,
commands, commands,
PluginManager PluginManager

View File

@ -21,6 +21,7 @@ import org.enso.launcher.distribution.{DefaultManagers, LauncherResourceManager}
import java.nio.file.{Files, Path} import java.nio.file.{Files, Path}
import scala.util.control.NonFatal import scala.util.control.NonFatal
import org.enso.launcher.Constants
/** Allows to [[uninstall]] an installed distribution. /** Allows to [[uninstall]] an installed distribution.
* *
@ -318,7 +319,8 @@ class DistributionUninstaller(
private def partiallyUninstallExecutableWindows(): Path = { private def partiallyUninstallExecutableWindows(): Path = {
val currentPath = manager.env.getPathToRunningExecutable val currentPath = manager.env.getPathToRunningExecutable
val newPath = currentPath.getParent.resolve(OS.executableName("enso.old")) val newPath =
currentPath.getParent.resolve(OS.executableName(Constants.name + ".old"))
Files.move(currentPath, newPath) Files.move(currentPath, newPath)
newPath newPath
} }
@ -338,7 +340,9 @@ class DistributionUninstaller(
parentToRemove: Option[Path] parentToRemove: Option[Path]
): Nothing = { ): Nothing = {
val temporaryLauncher = val temporaryLauncher =
Files.createTempDirectory("enso-uninstall") / OS.executableName("enso") Files.createTempDirectory("enso-uninstall") / OS.executableName(
Constants.name
)
val oldLauncher = myNewPath val oldLauncher = myNewPath
Files.copy(oldLauncher, temporaryLauncher) Files.copy(oldLauncher, temporaryLauncher)
InternalOpts InternalOpts

View File

@ -255,7 +255,7 @@ class LauncherUpgrader(
.iterateArchive(archivePath) { entry => .iterateArchive(archivePath) { entry =>
if ( if (
entry.relativePath.endsWith( entry.relativePath.endsWith(
Path.of("bin") / OS.executableName("enso") Path.of("bin") / OS.executableName(org.enso.launcher.Constants.name)
) )
) { ) {
entryFound = true entryFound = true

View File

@ -0,0 +1 @@
package org.enso.launcher;

View File

@ -113,7 +113,7 @@ trait NativeTest
* functionality. * functionality.
*/ */
def baseLauncherLocation: Path = def baseLauncherLocation: Path =
rootDirectory.resolve(OS.executableName("enso")) rootDirectory.resolve(OS.executableName(Constants.name))
/** Creates a copy of the tested launcher binary at the specified location. /** Creates a copy of the tested launcher binary at the specified location.
* *

View File

@ -11,7 +11,7 @@ import org.enso.testkit.WithTemporaryDirectory
class InstallerSpec extends NativeTest with WithTemporaryDirectory { class InstallerSpec extends NativeTest with WithTemporaryDirectory {
def portableRoot = getTestDirectory / "portable" def portableRoot = getTestDirectory / "portable"
def portableLauncher = def portableLauncher =
portableRoot / "bin" / OS.executableName("enso") portableRoot / "bin" / OS.executableName(Constants.name)
def preparePortableDistribution(): Unit = { def preparePortableDistribution(): Unit = {
copyLauncherTo(portableLauncher) copyLauncherTo(portableLauncher)
@ -66,9 +66,13 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
env env
) )
(installedRoot / "bin" / OS.executableName("enso")).toFile should exist (installedRoot / "bin" / OS.executableName(
Constants.name
)).toFile should exist
assert( assert(
Files.isExecutable(installedRoot / "bin" / OS.executableName("enso")), Files.isExecutable(
installedRoot / "bin" / OS.executableName(Constants.name)
),
"The installed file should be executable." "The installed file should be executable."
) )
@ -97,7 +101,9 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
env env
) )
(installedRoot / "bin" / OS.executableName("enso")).toFile should exist (installedRoot / "bin" / OS.executableName(
Constants.name
)).toFile should exist
portableLauncher.toFile should exist portableLauncher.toFile should exist
} }

View File

@ -30,10 +30,11 @@ class UninstallerSpec extends NativeTest with WithTemporaryDirectory {
val configDirectory = val configDirectory =
if (everythingInsideData) installedRoot / "config" if (everythingInsideData) installedRoot / "config"
else getTestDirectory / "enso-config" else getTestDirectory / "enso-config"
val dataDirectory = installedRoot val dataDirectory = installedRoot
val runDirectory = installedRoot val runDirectory = installedRoot
val logDirectory = installedRoot / "log" val logDirectory = installedRoot / "log"
val portableLauncher = binDirectory / OS.executableName("enso") val portableLauncher =
binDirectory / OS.executableName(org.enso.launcher.Constants.name)
copyLauncherTo(portableLauncher) copyLauncherTo(portableLauncher)
Files.createDirectories(dataDirectory / "dist") Files.createDirectories(dataDirectory / "dist")
Files.createDirectories(configDirectory) Files.createDirectories(configDirectory)

View File

@ -36,7 +36,11 @@ class UpgradeSpec
/** Location of the actual launcher executable that is wrapped by the shims. /** Location of the actual launcher executable that is wrapped by the shims.
*/ */
private val realLauncherLocation = private val realLauncherLocation =
Path.of(".").resolve(OS.executableName("enso")).toAbsolutePath.normalize Path
.of(".")
.resolve(OS.executableName(Constants.name))
.toAbsolutePath
.normalize
/** Path to a launcher shim that pretends to be `version`. /** Path to a launcher shim that pretends to be `version`.
*/ */
@ -57,7 +61,7 @@ class UpgradeSpec
Files.createDirectories(destinationDirectory) Files.createDirectories(destinationDirectory)
Files.copy( Files.copy(
builtLauncherBinary(version), builtLauncherBinary(version),
destinationDirectory / OS.executableName("enso"), destinationDirectory / OS.executableName(Constants.name),
StandardCopyOption.REPLACE_EXISTING StandardCopyOption.REPLACE_EXISTING
) )
} }
@ -103,7 +107,7 @@ class UpgradeSpec
/** Path to the launcher executable in the temporary distribution. /** Path to the launcher executable in the temporary distribution.
*/ */
private def launcherPath = private def launcherPath =
getTestDirectory / "enso" / "bin" / OS.executableName("enso") getTestDirectory / "enso" / "bin" / OS.executableName(Constants.name)
/** Runs `enso version` to inspect the version reported by the launcher. /** Runs `enso version` to inspect the version reported by the launcher.
* @return the reported version * @return the reported version
@ -274,7 +278,7 @@ class UpgradeSpec
.listDirectory(binDirectory) .listDirectory(binDirectory)
.map(_.getFileName.toString) .map(_.getFileName.toString)
.filter(_.startsWith("enso")) .filter(_.startsWith("enso"))
leftOverExecutables shouldEqual Seq(OS.executableName("enso")) leftOverExecutables shouldEqual Seq(OS.executableName(Constants.name))
} }
} finally { } finally {
if (process.isAlive) { if (process.isAlive) {

View File

@ -103,9 +103,9 @@ public final class EnsoFile implements EnsoObject {
return ArrayLikeHelpers.wrapStrings(MEMBERS); return ArrayLikeHelpers.wrapStrings(MEMBERS);
} }
@TruffleBoundary
@ExportMessage @ExportMessage
Object invokeMember( static Object invokeMember(
EnsoOutputStream os,
String name, String name,
Object[] args, Object[] args,
@Cached ArrayLikeLengthNode lengthNode, @Cached ArrayLikeLengthNode lengthNode,
@ -130,20 +130,28 @@ public final class EnsoFile implements EnsoObject {
throw ArityException.create(1, 3, args.length); throw ArityException.create(1, 3, args.length);
} }
} }
var buf = new byte[8192];
var at = 0;
for (long i = from; i < to; i++) { for (long i = from; i < to; i++) {
var elem = atNode.executeAt(args[0], i); var elem = atNode.executeAt(args[0], i);
var byt = iop.asInt(elem); buf[at++] = iop.asByte(elem);
os.write(byt); if (at == buf.length) {
os.write(buf, 0, buf.length);
at = 0;
}
} }
yield this; if (at > 0) {
os.write(buf, 0, at);
}
yield os;
} }
case "flush" -> { case "flush" -> {
os.flush(); os.flush();
yield this; yield os;
} }
case "close" -> { case "close" -> {
os.close(); os.close();
yield this; yield os;
} }
default -> throw UnknownIdentifierException.create(name); default -> throw UnknownIdentifierException.create(name);
}; };
@ -155,6 +163,21 @@ public final class EnsoFile implements EnsoObject {
} }
} }
@TruffleBoundary
final void write(byte[] buf, int offset, int length) throws IOException {
os.write(buf, offset, length);
}
@TruffleBoundary
final void flush() throws IOException {
os.flush();
}
@TruffleBoundary
final void close() throws IOException {
os.close();
}
@Override @Override
public String toString() { public String toString() {
return "EnsoOutputStream"; return "EnsoOutputStream";

View File

@ -411,7 +411,7 @@ class DistributionManager(val env: Environment) {
def irCacheDirectory: Path = this.cacheDirectory / "ir" def irCacheDirectory: Path = this.cacheDirectory / "ir"
private def executableName: String = private def executableName: String =
OS.executableName("enso") OS.executableName("ensoup")
/** The path where the binary executable of the installed distribution /** The path where the binary executable of the installed distribution
* should be placed by default. * should be placed by default.

View File

@ -11,6 +11,7 @@ import org.enso.projectmanager.control.core.syntax._
import org.enso.projectmanager.control.effect.syntax._ import org.enso.projectmanager.control.effect.syntax._
import org.enso.projectmanager.infrastructure.repository.ProjectRepositoryFactory import org.enso.projectmanager.infrastructure.repository.ProjectRepositoryFactory
import org.enso.projectmanager.service.ProjectService import org.enso.projectmanager.service.ProjectService
import org.slf4j.LoggerFactory
import java.io.{File, InputStream} import java.io.{File, InputStream}
import java.nio.file.Files import java.nio.file.Files
@ -21,13 +22,16 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
projectRepositoryFactory: ProjectRepositoryFactory[F] projectRepositoryFactory: ProjectRepositoryFactory[F]
) extends FileSystemServiceApi[F] { ) extends FileSystemServiceApi[F] {
private lazy val logger = LoggerFactory.getLogger(this.getClass)
/** @inheritdoc */ /** @inheritdoc */
override def exists(path: File): F[FileSystemServiceFailure, Boolean] = override def exists(path: File): F[FileSystemServiceFailure, Boolean] =
fileSystem fileSystem
.exists(path) .exists(path)
.mapError(_ => .mapError { error =>
logger.warn("Failed to check if path exists", error)
FileSystemServiceFailure.FileSystem("Failed to check if path exists") FileSystemServiceFailure.FileSystem("Failed to check if path exists")
) }
/** @inheritdoc */ /** @inheritdoc */
override def list( override def list(
@ -35,9 +39,10 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
): F[FileSystemServiceFailure, Seq[FileSystemEntry]] = ): F[FileSystemServiceFailure, Seq[FileSystemEntry]] =
fileSystem fileSystem
.list(path) .list(path)
.mapError(_ => .mapError { error =>
logger.warn("Failed to list directories", error)
FileSystemServiceFailure.FileSystem("Failed to list directories") FileSystemServiceFailure.FileSystem("Failed to list directories")
) }
.flatMap { files => .flatMap { files =>
Traverse[List].traverse(files)(toFileSystemEntry).map(_.flatten) Traverse[List].traverse(files)(toFileSystemEntry).map(_.flatten)
} }
@ -46,29 +51,37 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
override def createDirectory(path: File): F[FileSystemServiceFailure, Unit] = override def createDirectory(path: File): F[FileSystemServiceFailure, Unit] =
fileSystem fileSystem
.createDir(path) .createDir(path)
.mapError(_ => .mapError { error =>
logger.warn("Failed to create directory", error)
FileSystemServiceFailure.FileSystem("Failed to create directory") FileSystemServiceFailure.FileSystem("Failed to create directory")
) }
/** @inheritdoc */ /** @inheritdoc */
override def delete(path: File): F[FileSystemServiceFailure, Unit] = override def delete(path: File): F[FileSystemServiceFailure, Unit] =
fileSystem fileSystem
.remove(path) .remove(path)
.mapError(_ => .mapError { error =>
logger.warn("Failed to delete path", error)
FileSystemServiceFailure.FileSystem("Failed to delete path") FileSystemServiceFailure.FileSystem("Failed to delete path")
) }
/** @inheritdoc */ /** @inheritdoc */
override def move(from: File, to: File): F[FileSystemServiceFailure, Unit] = override def move(from: File, to: File): F[FileSystemServiceFailure, Unit] =
fileSystem fileSystem
.move(from, to) .move(from, to)
.mapError(_ => FileSystemServiceFailure.FileSystem("Failed to move path")) .mapError { error =>
logger.warn("Failed to list directories", error)
FileSystemServiceFailure.FileSystem("Failed to move path")
}
/** @inheritdoc */ /** @inheritdoc */
override def copy(from: File, to: File): F[FileSystemServiceFailure, Unit] = override def copy(from: File, to: File): F[FileSystemServiceFailure, Unit] =
fileSystem fileSystem
.copy(from, to) .copy(from, to)
.mapError(_ => FileSystemServiceFailure.FileSystem("Failed to copy path")) .mapError { error =>
logger.warn("Failed to copy path", error)
FileSystemServiceFailure.FileSystem("Failed to copy path")
}
/** @inheritdoc */ /** @inheritdoc */
override def write( override def write(
@ -77,9 +90,10 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
): F[FileSystemServiceFailure, Unit] = ): F[FileSystemServiceFailure, Unit] =
fileSystem fileSystem
.writeFile(path, contents) .writeFile(path, contents)
.mapError(_ => .mapError { error =>
logger.warn("Failed to write path", error)
FileSystemServiceFailure.FileSystem("Failed to write path") FileSystemServiceFailure.FileSystem("Failed to write path")
) }
private def toFileSystemEntry( private def toFileSystemEntry(
path: File path: File

View File

@ -414,7 +414,7 @@ object DistributionPackage {
) )
copyFilesIncremental( copyFilesIncremental(
Seq(file(executableName("enso"))), Seq(file(executableName("ensoup"))),
distributionRoot / "bin", distributionRoot / "bin",
cacheFactory.make("launcher-exe") cacheFactory.make("launcher-exe")
) )

View File

@ -211,7 +211,7 @@ add_specs suite_builder =
(Ordering.hash x0) . should_equal (Ordering.hash x1) (Ordering.hash x0) . should_equal (Ordering.hash x1)
group_builder.specify "should compare correctly to Integer and Float" <| group_builder.specify "should compare correctly to Integer and Float" pending="https://github.com/enso-org/enso/issues/10163" <|
values = [] values = []
+ [[0.1, 0.1]] + [[0.1, 0.1]]
+ [["0.1", 0.1]] + [["0.1", 0.1]]

View File

@ -117,7 +117,7 @@ add_specs suite_builder =
'{"type":"Date_Time","constructor":"new","year":2023,"month":9,"day":29,"hour":11,"second":52}'.should_parse_as (JS_Object.from_pairs [["type", "Date_Time"], ["constructor", "new"], ["year", 2023], ["month", 9], ["day", 29], ["hour", 11], ["second", 52]]) '{"type":"Date_Time","constructor":"new","year":2023,"month":9,"day":29,"hour":11,"second":52}'.should_parse_as (JS_Object.from_pairs [["type", "Date_Time"], ["constructor", "new"], ["year", 2023], ["month", 9], ["day", 29], ["hour", 11], ["second", 52]])
'{"type":"Date_Time","constructor":"new","year":2023,"month":9,"day":29,"hour":11,"minute":52,"nanosecond":572104300}'.should_parse_as (JS_Object.from_pairs [["type", "Date_Time"], ["constructor", "new"], ["year", 2023], ["month", 9], ["day", 29], ["hour", 11], ["minute", 52], ["nanosecond", 572104300]]) '{"type":"Date_Time","constructor":"new","year":2023,"month":9,"day":29,"hour":11,"minute":52,"nanosecond":572104300}'.should_parse_as (JS_Object.from_pairs [["type", "Date_Time"], ["constructor", "new"], ["year", 2023], ["month", 9], ["day", 29], ["hour", 11], ["minute", 52], ["nanosecond", 572104300]])
group_builder.specify "should be able to read a JSON file with a BOM indicating UTF-16 encoding" pending="Encoding.default turned off temporarily" <| group_builder.specify "should be able to read a JSON file with a BOM indicating UTF-16 encoding" <|
utf_16_le_bom = [-1, -2] utf_16_le_bom = [-1, -2]
bytes = utf_16_le_bom + ("{}".bytes Encoding.utf_16_le) bytes = utf_16_le_bom + ("{}".bytes Encoding.utf_16_le)
f = File.create_temporary_file "json-with-bom" ".json" f = File.create_temporary_file "json-with-bom" ".json"

View File

@ -68,7 +68,7 @@ add_specs suite_builder =
default_warning.should_equal invalid_ascii_out default_warning.should_equal invalid_ascii_out
Problems.get_attached_warnings default_warning . should_contain_the_same_elements_as problems Problems.get_attached_warnings default_warning . should_contain_the_same_elements_as problems
suite_builder.group "Default Encoding" pending="Encoding.default turned off temporarily" group_builder-> suite_builder.group "Default Encoding" group_builder->
group_builder.specify "should try reading as UTF-8 by default" <| group_builder.specify "should try reading as UTF-8 by default" <|
bytes = [65, -60, -123, -60, -103] bytes = [65, -60, -123, -60, -103]
# A ą ę # A ą ę

View File

@ -6,12 +6,25 @@ import Standard.Examples
options = Bench.options . set_warmup (Bench.phase_conf 2 5) . set_measure (Bench.phase_conf 2 5) options = Bench.options . set_warmup (Bench.phase_conf 2 5) . set_measure (Bench.phase_conf 2 5)
type Lazy_Data
private Write ~table:Table
collect_benches = Bench.build builder-> collect_benches = Bench.build builder->
assert Examples.csv_2500_rows.exists "Expecting the file to exist at "+Examples.csv_2500_rows.path assert Examples.csv_2500_rows.exists "Expecting the file to exist at "+Examples.csv_2500_rows.path
write_data = Lazy_Data.Write (Examples.csv_2500_rows . read)
builder.group ("Read_csv_file") options group_builder-> builder.group ("Read_csv_file") options group_builder->
group_builder.specify "data_csv" <| group_builder.specify "data_csv" <|
table = Examples.csv_2500_rows . read table = Examples.csv_2500_rows . read
assert (table.row_count == 2500) "Expecting two and half thousand rows, but got "+table.row_count.to_text assert (table.row_count == 2500) "Expecting two and half thousand rows, but got "+table.row_count.to_text
builder.group ("Write_csv_file") options group_builder->
group_builder.specify "data_csv" <|
file = File.create_temporary_file "data_csv"
Panic.with_finalizer file.delete <|
assert (file.size == 0) "File "+file.to_text+" shall be empty, size: "+file.size.to_text
write_data.table . write file (..Delimited delimiter="," headers=False)
assert (file.size > 111111) "File "+file.to_text+" exists now, size: "+file.size.to_text
main = collect_benches . run_main main = collect_benches . run_main

View File

@ -475,7 +475,7 @@ add_specs suite_builder =
Delimited_Format.Delimited ',' . with_line_endings Line_Ending_Style.Unix . should_equal (Delimited_Format.Delimited ',' line_endings=Line_Ending_Style.Unix) Delimited_Format.Delimited ',' . with_line_endings Line_Ending_Style.Unix . should_equal (Delimited_Format.Delimited ',' line_endings=Line_Ending_Style.Unix)
utf_16_le_bom = [-1, -2] utf_16_le_bom = [-1, -2]
group_builder.specify "(in default mode) should detect UTF-16 encoding if BOM is present" pending="Encoding.default turned off temporarily" <| group_builder.specify "(in default mode) should detect UTF-16 encoding if BOM is present" <|
bytes = utf_16_le_bom + ('a,b\n1,2'.bytes Encoding.utf_16_le) bytes = utf_16_le_bom + ('a,b\n1,2'.bytes Encoding.utf_16_le)
f = File.create_temporary_file "delimited-utf-16-bom" ".csv" f = File.create_temporary_file "delimited-utf-16-bom" ".csv"
bytes.write_bytes f . should_succeed bytes.write_bytes f . should_succeed
@ -485,7 +485,7 @@ add_specs suite_builder =
# No hidden BOM in the column name # No hidden BOM in the column name
table.column_names.first.utf_8 . should_equal [97] table.column_names.first.utf_8 . should_equal [97]
group_builder.specify "(in default mode) should skip UTF-8 BOM if it was present" pending="Encoding.default turned off temporarily" <| group_builder.specify "(in default mode) should skip UTF-8 BOM if it was present" <|
utf_8_bom = [-17, -69, -65] utf_8_bom = [-17, -69, -65]
bytes = utf_8_bom + ('a,b\n1,2'.bytes Encoding.utf_8) bytes = utf_8_bom + ('a,b\n1,2'.bytes Encoding.utf_8)
f = File.create_temporary_file "delimited-utf-8-bom" ".csv" f = File.create_temporary_file "delimited-utf-8-bom" ".csv"
@ -506,9 +506,10 @@ add_specs suite_builder =
# The first column name now contains this invalid character, because it wasn't a BOM # The first column name now contains this invalid character, because it wasn't a BOM
r.column_names.first . should_equal "￾a" r.column_names.first . should_equal "￾a"
group_builder.specify "if UTF-16 encoding was selected but an inverted BOM is detected, a warning is issued (pt 2)" pending="Encoding.default turned off temporarily" <| group_builder.specify "if UTF-16 encoding was selected but an inverted BOM is detected, a warning is issued (pt 2)" <|
bytes = utf_16_le_bom + ('a,b\n1,2'.bytes Encoding.utf_16_be) bytes = utf_16_le_bom + ('a,b\n1,2'.bytes Encoding.utf_16_be)
f = File.create_temporary_file "delimited-utf-16-inverted-bom" ".csv" f = File.create_temporary_file "delimited-utf-16-inverted-bom" ".csv"
bytes.write_bytes f . should_succeed
# If we read without specifying the encoding, we will infer UTF-16 LE encoding because of the BOM and get garbage: # If we read without specifying the encoding, we will infer UTF-16 LE encoding because of the BOM and get garbage:
r2 = f.read r2 = f.read
@ -527,7 +528,7 @@ add_specs suite_builder =
r.first_column.to_vector . should_equal ['\uFFFD'] r.first_column.to_vector . should_equal ['\uFFFD']
Problems.expect_only_warning Encoding_Error r Problems.expect_only_warning Encoding_Error r
group_builder.specify "should fall back to Windows-1252 encoding if invalid UTF-8 characters are encountered in Default encoding" pending="Encoding.default turned off temporarily" <| group_builder.specify "should fall back to Windows-1252 encoding if invalid UTF-8 characters are encountered in Default encoding" <|
f = File.create_temporary_file "delimited-invalid-utf-8" ".csv" f = File.create_temporary_file "delimited-invalid-utf-8" ".csv"
# On the simple characters all three encodings (ASCII, UTF-8 and Win-1252) agree, so we can use ASCII bytes. # On the simple characters all three encodings (ASCII, UTF-8 and Win-1252) agree, so we can use ASCII bytes.
bytes = ('A,B\n1,y'.bytes Encoding.ascii) + [-1] + ('z\n2,-'.bytes Encoding.ascii) bytes = ('A,B\n1,y'.bytes Encoding.ascii) + [-1] + ('z\n2,-'.bytes Encoding.ascii)

View File

@ -569,7 +569,7 @@ add_specs suite_builder =
## If the Delimited config has Encoding.default, the encoding for read will be determined by BOM and Win-1252 fallback heuristics. ## If the Delimited config has Encoding.default, the encoding for read will be determined by BOM and Win-1252 fallback heuristics.
The same encoding should be used for writing, to ensure that when the resulting file is read, all content is correctly decoded. The same encoding should be used for writing, to ensure that when the resulting file is read, all content is correctly decoded.
group_builder.specify "should use the same effective encoding for writing as the one that would be used for reading" pending="Encoding.default turned off temporarily" <| group_builder.specify "should use the same effective encoding for writing as the one that would be used for reading" <|
f = File.create_temporary_file "append-detect" ".csv" f = File.create_temporary_file "append-detect" ".csv"
Test.with_clue "UTF-16 detected by BOM: " <| Test.with_clue "UTF-16 detected by BOM: " <|
bom = [-1, -2] bom = [-1, -2]