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
- [Enforce conversion method return type][10468]
- [Renaming launcher executable to ensoup][10535]
[10468]: https://github.com/enso-org/enso/pull/10468
[10535]: https://github.com/enso-org/enso/pull/10535
#### Enso IDE
@ -12,7 +14,7 @@
- [Numeric Widget does not accept non-numeric input][10457]. This is to prevent
node being completely altered by accidental code put to the widget.
- [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].
[10433]: https://github.com/enso-org/enso/pull/10443

View File

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

View File

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

View File

@ -39,10 +39,9 @@
* credentials.
*
* To redirect the user from the IDE to an external source:
* 1. Call the {@link initIpc} function to register a listener for
* {@link ipc.Channel.openUrlInSystemBrowser} IPC events.
* 2. Emit an {@link ipc.Channel.openUrlInSystemBrowser} event. The listener registered in the
* {@link initIpc} function will use the {@link opener} library to open the event's {@link URL}
* 1. Register a listener for {@link ipc.Channel.openUrlInSystemBrowser} IPC events.
* 2. Emit an {@link ipc.Channel.openUrlInSystemBrowser} event. The listener registered in step
* 1 will use the {@link opener} library to open the event's {@link URL}
* argument in the system web browser, in a cross-platform way.
*
* ## Redirect To IDE
@ -57,7 +56,7 @@
*
* To prepare the application to handle deep links:
* - 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`).
*
* 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
* {@link URL} to redirect the user to the dashboard, to the page specified in the {@link URL}'s
* `pathname`. */
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
@ -97,48 +95,17 @@ const logger = contentConfig.logger
* 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
* theory, it never will in practice. */
export function initModule(window: () => electron.BrowserWindow) {
initIpc()
initOpenUrlListener(window)
initSaveAccessTokenListener()
}
/** 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() {
export function initAuthentication(window: () => electron.BrowserWindow) {
// Listen for events to open a URL externally in a browser the user trusts. This is used for
// OAuth authentication, both for trustworthiness and for convenience (the ability to use the
// browser's saved passwords).
electron.ipcMain.on(ipc.Channel.openUrlInSystemBrowser, (_event, url: string) => {
logger.log(`Opening URL in system browser: '${url}'.`)
urlAssociations.setAsUrlHandler()
logger.log(`Opening URL '${url}' in the default browser.`)
opener(url)
})
}
/** Register a listener that fires a callback for `open-url` events, when the URL is a deep link.
*
* This listener is used to open a page in *this* application window, when the user is
* redirected to a URL with a protocol supported by this application.
*
* 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) {
// Listen for events to handle deep links.
urlAssociations.registerUrlCallback(url => {
onOpenUrl(url, window)
})
}
/** Handle the 'open-url' event by parsing the received URL, checking if it is a deep link, and
* sending it to the appropriate BrowserWindow via IPC.
* @param url - The URL to handle.
* @param window - A function that returns the BrowserWindow to send the parsed URL to. */
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.`)
@ -146,16 +113,9 @@ export function onOpenUrl(url: URL, window: () => electron.BrowserWindow) {
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() {
// Listen for events to save the given user credentials to `~/.enso/credentials`.
electron.ipcMain.on(
ipc.Channel.saveAccessToken,
(event, accessTokenPayload: SaveAccessTokenPayload | null) => {

View File

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

View File

@ -220,7 +220,7 @@ export class ChromeOption {
/** Replace `-no-...` with `--no-...`. This is a hotfix for a Yargs bug:
* https://github.com/yargs/yargs-parser/issues/468. */
function fixArgvNoPrefix(argv: string[]): string[] {
function fixArgvNoPrefix(argv: readonly string[]): readonly string[] {
const singleDashPrefix = '-no-'
const doubleDashPrefix = '--no-'
return argv.map(arg => {
@ -234,13 +234,13 @@ function fixArgvNoPrefix(argv: string[]): string[] {
/** Command line options, split into regular arguments and Chrome options. */
interface ArgvAndChromeOptions {
readonly argv: string[]
readonly argv: readonly string[]
readonly chromeOptions: ChromeOption[]
}
/** Parse the given list of arguments into two distinct sets: regular arguments and those specific
* to Chrome. */
function argvAndChromeOptions(processArgs: string[]): ArgvAndChromeOptions {
function argvAndChromeOptions(processArgs: readonly string[]): ArgvAndChromeOptions {
const chromeOptionRegex = /--?chrome.([^=]*)(?:=(.*))?/
const argv = []
const chromeOptions: ChromeOption[] = []
@ -275,7 +275,7 @@ function argvAndChromeOptions(processArgs: string[]): ArgvAndChromeOptions {
// =====================
/** 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 { argv, chromeOptions } = argvAndChromeOptions(fixArgvNoPrefix(clientArgs))
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
* process, and launching new instances of the IDE when necessary. The module also exports
* constants related to file associations and project handling. */
import * as childProcess from 'node:child_process'
import * as fsSync from 'node:fs'
import * as pathModule from 'node:path'
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
* executable name and any electron dev mode arguments.
* @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]
let result: string | null = null
// 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()
/** Decide what are client arguments, @see {@link CLIENT_ARGUMENTS}. */
function getClientArguments(): string[] {
function getClientArguments(args = process.argv): readonly string[] {
if (electronIsDev) {
// Client arguments are separated from the electron dev mode arguments by a '--' argument.
const separator = '--'
const separatorIndex = process.argv.indexOf(separator)
const separatorIndex = args.indexOf(separator)
if (separatorIndex === NOT_FOUND) {
// If there is no separator, client gets no arguments.
return []
} else {
// Drop everything before the separator.
return process.argv.slice(separatorIndex + 1)
return args.slice(separatorIndex + 1)
}
} else {
// 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
* receives the `open-file` event. However, if there is already an instance of Enso running,
* it receives the `open-file` event (and no new instance is created for us). In this case,
* we manually start a new instance of the application and pass the file path to it (using the
* Windows-style command). */
export function onFileOpened(event: electron.Event, path: string): string | null {
/** Callback called when a file is opened via the `open-file` event. */
export function onFileOpened(event: electron.Event, path: string): project.ProjectInfo | null {
logger.log(`Received 'open-file' event for path '${path}'.`)
if (isFileOpenable(path)) {
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
// screen. However, we still check for the presence of arguments, to prevent hijacking the
// user-spawned IDE instance (OS-spawned will not have arguments set).
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 {
logger.log(`The file '${path}' is not openable, ignoring the 'open-file' event.`)
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,
* 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. */
export function setOpenFileEventHandler(setProjectToOpen: (id: string) => void) {
export function setOpenFileEventHandler(setProjectToOpen: (info: project.ProjectInfo) => void) {
electron.app.on('open-file', (event, path) => {
const projectId = onFileOpened(event, path)
if (typeof projectId === 'string') {
setProjectToOpen(projectId)
logger.log(`Opening file '${path}'.`)
const projectInfo = onFileOpened(event, path)
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.
* @returns The ID of the project to open.
* @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 {
return project.importProjectFromPath(openedFile)
} 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
// method after IDE has been fully set up, as the initializing code would have already
// 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) {
// If we failed to open the file, we should enter the usual welcome screen.
// The `handleOpenFile` function will have already displayed an error message.

View File

@ -60,8 +60,12 @@ class App {
log.addFileLog()
urlAssociations.registerAssociations()
// Register file associations for macOS.
fileAssociations.setOpenFileEventHandler(id => {
this.setProjectToOpenOnStartup(id)
fileAssociations.setOpenFileEventHandler(project => {
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')
@ -79,7 +83,6 @@ class App {
)
const { windowSize, chromeOptions, fileToOpen, urlToOpen } = this.processArguments()
this.handleItemOpening(fileToOpen, urlToOpen)
if (this.args.options.version.value) {
await this.printVersion()
electron.app.quit()
@ -89,37 +92,57 @@ class App {
electron.app.quit()
})
} else {
const isOriginalInstance = electron.app.requestSingleInstanceLock({
fileToOpen,
urlToOpen,
})
if (isOriginalInstance) {
this.handleItemOpening(fileToOpen, urlToOpen)
this.setChromeOptions(chromeOptions)
security.enableAll()
electron.app.on('before-quit', () => (this.isQuitting = true))
electron.app.on('before-quit', () => {
this.isQuitting = true
})
electron.app.on('second-instance', (_event, argv) => {
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.')
void this.main(windowSize)
await this.main(windowSize)
},
err => {
logger.error('Failed to initialize electron.', err)
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. */
processArguments() {
processArguments(args = fileAssociations.CLIENT_ARGUMENTS) {
// We parse only "client arguments", so we don't have to worry about the Electron-Dev vs
// Electron-Proper distinction.
const fileToOpen = fileAssociations.argsDenoteFileOpenAttempt(
fileAssociations.CLIENT_ARGUMENTS
)
const urlToOpen = urlAssociations.argsDenoteUrlOpenAttempt(
fileAssociations.CLIENT_ARGUMENTS
)
const fileToOpen = fileAssociations.argsDenoteFileOpenAttempt(args)
const urlToOpen = urlAssociations.argsDenoteUrlOpenAttempt(args)
// 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.
// We just need to let caller know that we are opening a file.
const argsToParse =
fileToOpen != null || urlToOpen != null ? [] : fileAssociations.CLIENT_ARGUMENTS
const argsToParse = fileToOpen != null || urlToOpen != null ? [] : args
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 method after the IDE has been fully set up, as the initializing code
// would have already read the value of this argument.
const projectId = fileAssociations.handleOpenFile(fileToOpen)
this.setProjectToOpenOnStartup(projectId)
const projectInfo = fileAssociations.handleOpenFile(fileToOpen)
this.setProjectToOpenOnStartup(projectInfo.id)
}
if (urlToOpen != null) {
@ -170,14 +193,14 @@ class App {
* Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. */
setChromeOptions(chromeOptions: configParser.ChromeOption[]) {
const addIf = (
opt: contentConfig.Option<boolean>,
option: contentConfig.Option<boolean>,
chromeOptName: string,
value?: string
) => {
if (opt.value) {
if (option.value) {
const chromeOption = new configParser.ChromeOption(chromeOptName, value)
const chromeOptionStr = chromeOption.display()
const optionName = opt.qualifiedName()
const optionName = option.qualifiedName()
logger.log(`Setting '${chromeOptionStr}' because '${optionName}' was enabled.`)
chromeOptions.push(chromeOption)
}
@ -233,7 +256,7 @@ class App {
* not yet created at this point, but it will be created by the time the
* authentication module uses the lambda providing the window. */
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
authentication.initModule(() => this.window!)
authentication.initAuthentication(() => this.window!)
})
} catch (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) {
evt.preventDefault()
event.preventDefault()
window.hide()
}
})

View File

@ -15,14 +15,14 @@ export enum Channel {
quit = 'quit-ide',
/** Channel for requesting that a URL be opened by the 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. */
openDeepLink = 'open-deep-link',
/** Channel for signaling that access token be saved to a credentials file. */
saveAccessToken = 'save-access-token',
/** Channel for importing a project or project bundle from the given path. */
importProjectFromPath = 'import-project-from-path',
/** Channel for opening project */
openProject = 'open-project',
goBack = 'go-back',
goForward = 'go-forward',
/** 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 ipc from 'ipc'
import type * as projectManagement from 'project-management'
// =================
// === Constants ===
// =================
/** Name given to the {@link BACKEND_API} object, when it is exposed on the Electron main
* window. */
const BACKEND_API_KEY = 'backendApi'
/** Name given to the {@link AUTHENTICATION_API} object, when it is exposed on the Electron main
* window. */
const AUTHENTICATION_API_KEY = 'authenticationApi'
/** 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 PROJECT_MANAGEMENT_API_KEY = 'projectManagementApi'
const NAVIGATION_API_KEY = 'navigationApi'
const MENU_API_KEY = 'menuApi'
const SYSTEM_API_KEY = 'systemApi'
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 ===
// =============================
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) => {
electron.ipcRenderer.send(ipc.Channel.importProjectFromPath, projectPath, directory)
return new Promise<string>(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: () => {
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 ===
// ==========================
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:
* - 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
* 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.
*
* For more details, see:
* 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).
*
* 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
* progress when the link was opened (e.g., an OAuth registration flow). */
setDeepLinkHandler: (callback: (url: string) => void) => {
if (currentDeepLinkHandler != null) {
electron.ipcRenderer.off(ipc.Channel.openDeepLink, currentDeepLinkHandler)
}
currentDeepLinkHandler = (_event, url: string) => {
callback(url)
}
electron.ipcRenderer.on(ipc.Channel.openDeepLink, currentDeepLinkHandler)
deepLinkHandler = callback
},
/** Save the access token to a credentials file.
*
@ -171,14 +118,37 @@ const AUTHENTICATION_API = {
saveAccessToken: (accessTokenPayload: SaveAccessTokenPayload | null) => {
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) =>
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 ===
@ -190,31 +160,27 @@ electron.ipcRenderer.on(ipc.Channel.showAboutModal, () => {
showAboutModalHandler?.()
})
const MENU_API = {
exposeInMainWorld(MENU_API_KEY, {
setShowAboutModalHandler: (callback: () => void) => {
showAboutModalHandler = callback
},
}
electron.contextBridge.exposeInMainWorld(MENU_API_KEY, MENU_API)
})
// ==================
// === System API ===
// ==================
const SYSTEM_API = {
exposeInMainWorld(SYSTEM_API_KEY, {
downloadURL: (url: string, headers?: Record<string, string>) => {
electron.ipcRenderer.send(ipc.Channel.downloadURL, url, headers)
},
showItemInFolder: (fullPath: string) => {
electron.ipcRenderer.send(ipc.Channel.showItemInFolder, fullPath)
},
}
electron.contextBridge.exposeInMainWorld(SYSTEM_API_KEY, SYSTEM_API)
})
// ====================
// === 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. */
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 ===
// ======================
@ -43,7 +54,7 @@ export function importProjectFromPath(
openedPath: string,
directory?: string | null,
name: string | null = null
): string {
) {
directory ??= getProjectsDirectory()
if (pathModule.extname(openedPath).endsWith(BUNDLED_PROJECT_SUFFIX)) {
logger.log(`Path '${openedPath}' denotes a bundled project.`)
@ -77,7 +88,7 @@ export function importBundle(
bundlePath: string,
directory?: string | null,
name: string | null = null
): string {
) {
directory ??= getProjectsDirectory()
logger.log(
`Importing project '${bundlePath}' from bundle${name != null ? ` as '${name}'` : ''}.`
@ -136,7 +147,7 @@ export async function uploadBundle(
bundle: stream.Readable,
directory?: string | null,
name: string | null = null
): Promise<string> {
) {
directory ??= getProjectsDirectory()
logger.log(`Uploading project from bundle${name != null ? ` as '${name}'` : ''}.`)
const targetPath = generateDirectoryName(name ?? 'Project', directory)
@ -167,14 +178,14 @@ export function importDirectory(
rootPath: string,
directory?: string | null,
name: string | null = null
): string {
): ProjectInfo {
directory ??= getProjectsDirectory()
if (isProjectInstalled(rootPath, directory)) {
// Project is already visible to Project Manager, so we can just return its ID.
logger.log(`Project already installed at '${rootPath}'.`)
const id = getProjectId(rootPath)
if (id != null) {
return id
return { id, name: getPackageName(rootPath) ?? '', parentDirectory: directory }
} else {
throw new Error(`Project already installed, but missing metadata.`)
}
@ -410,7 +421,7 @@ export function bumpMetadata(
projectRoot: string,
parentDirectory: string,
name: string | null
): string {
): ProjectInfo {
if (name == null) {
const currentName = getPackageName(projectRoot) ?? ''
let index: number | null = null
@ -437,9 +448,10 @@ export function bumpMetadata(
name = index == null ? currentName : `${currentName} (${index})`
}
updatePackageName(projectRoot, name)
return updateMetadata(projectRoot, metadata => ({
const id = updateMetadata(projectRoot, metadata => ({
...metadata,
id: generateId(),
lastOpened: new Date().toISOString(),
})).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
* executable name and any electron dev mode arguments.
* @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]
let result: URL | null = null
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. */
export function handleOpenUrl(openedUrl: URL) {
logger.log(`Opening URL '${openedUrl.toString()}'.`)
const appLock = electron.app.requestSingleInstanceLock({ openedUrl })
if (!appLock) {
// 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.'
)
// We must wait for the application to be ready and then send the URL to the renderer process.
initialUrl = openedUrl
}
}
/** 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
* 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.
*
* 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. */
export function registerUrlCallback(callback: (url: URL) => void) {
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.
electron.app.on('second-instance', (event, argv) => {
logger.log(`Got data from 'second-instance' event: '${argv.toString()}'.`)
unsetAsUrlHandler()
electron.app.on('second-instance', (event, _argv, _workingDir, additionalData) => {
// Check if additional data is an object that contains the URL.
const requestOneLastElementSlice = -1
const lastArgumentSlice = argv.slice(requestOneLastElementSlice)
const url = argsDenoteUrlOpenAttempt(lastArgumentSlice)
const url =
additionalData != null &&
typeof additionalData === 'object' &&
'urlToOpen' in additionalData &&
additionalData.urlToOpen instanceof URL
? additionalData.urlToOpen
: null
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()}'.`)
event.preventDefault()
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: () =>
step('Go to "Data Catalog" page', page =>
page
.getByRole('button')
.getByRole('tab')
.filter({ has: page.getByText('Data Catalog') })
.click()
).into(DrivePageActions),

View File

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

View File

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

View File

@ -171,9 +171,7 @@ export default function AssetRow(props: AssetRowProps) {
}
}, [isKeyboardSelected])
React.useImperativeHandle(updateAssetRef, () => newItem => {
setAsset(newItem)
})
React.useImperativeHandle(updateAssetRef, () => setAsset)
const doCopyOnBackend = React.useCallback(
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
// as the type of the original asset.
// 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) {
toastAndLog('copyAssetError', error, asset.title)

View File

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

View File

@ -155,27 +155,27 @@ export interface AssetRemoveSelfEvent extends AssetBaseEvent<AssetEventType.remo
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
extends AssetBaseEvent<AssetEventType.temporarilyAddLabels> {
readonly ids: ReadonlySet<backend.AssetId>
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
extends AssetBaseEvent<AssetEventType.temporarilyRemoveLabels> {
readonly ids: ReadonlySet<backend.AssetId>
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> {
readonly ids: ReadonlySet<backend.AssetId>
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> {
readonly ids: ReadonlySet<backend.AssetId>
readonly labelNames: ReadonlySet<backend.LabelName>

View File

@ -20,6 +20,7 @@ interface AssetListEvents {
readonly newSecret: AssetListNewSecretEvent
readonly newDatalink: AssetListNewDatalinkEvent
readonly insertAssets: AssetListInsertAssetsEvent
readonly openProject: AssetListOpenProjectEvent
readonly duplicateProject: AssetListDuplicateProjectEvent
readonly closeFolder: AssetListCloseFolderEvent
readonly copy: AssetListCopyEvent
@ -86,6 +87,15 @@ interface AssetListInsertAssetsEvent extends AssetListBaseEvent<AssetListEventTy
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. */
interface AssetListDuplicateProjectEvent
extends AssetListBaseEvent<AssetListEventType.duplicateProject> {

View File

@ -1,6 +1,7 @@
/** @file Table displaying a list of projects. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as toast from 'react-toastify'
import DropFilesImage from 'enso-assets/drop_files.svg'
@ -386,6 +387,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const { setSuggestions, initialProjectName } = props
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
const queryClient = reactQuery.useQueryClient()
const openedProjects = projectsProvider.useLaunchedProjects()
const doOpenProject = projectHooks.useOpenProject()
@ -1722,6 +1724,22 @@ export default function AssetsTable(props: AssetsTableProps) {
insertArbitraryAssets(event.assets, event.parentKey, event.parentId)
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: {
const siblings = nodeMapRef.current.get(event.parentKey)?.children ?? []
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 ariaComponents from '#/components/AriaComponents'
import StatelessSpinner, * as spinnerModule from '#/components/StatelessSpinner'
import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask'
import * as backend from '#/services/Backend'
@ -31,8 +33,7 @@ const TAB_RADIUS_PX = 24
/** Context for a {@link TabBarContext}. */
interface TabBarContextValue {
readonly updateClipPath: (element: HTMLDivElement | null) => void
readonly observeElement: (element: HTMLElement) => () => void
readonly setSelectedTab: (element: HTMLElement) => void
}
const TabBarContext = React.createContext<TabBarContextValue | null>(null)
@ -55,22 +56,23 @@ export interface TabBarProps extends Readonly<React.PropsWithChildren> {}
export default function TabBar(props: TabBarProps) {
const { children } = props
const cleanupResizeObserverRef = React.useRef(() => {})
const backgroundRef = React.useRef<HTMLDivElement | null>(null)
const selectedTabRef = React.useRef<HTMLDivElement | null>(null)
const backgroundRef = React.useRef<HTMLDivElement | null>()
const selectedTabRef = React.useRef<HTMLElement | null>(null)
const [resizeObserver] = React.useState(
() =>
new ResizeObserver(() => {
updateClipPath(selectedTabRef.current)
})
)
const [updateClipPath] = React.useState(() => {
return (element: HTMLDivElement | null) => {
return (element: HTMLElement | null) => {
const backgroundElement = backgroundRef.current
if (backgroundElement != null) {
selectedTabRef.current = element
if (element == null) {
backgroundElement.style.clipPath = ''
} else {
selectedTabRef.current = element
const bounds = element.getBoundingClientRect()
const rootBounds = backgroundElement.getBoundingClientRect()
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) => {
cleanupResizeObserverRef.current()
if (element == null) {
if (!(element instanceof HTMLElement)) {
cleanupResizeObserverRef.current = () => {}
} else {
resizeObserver.observe(element)
@ -109,19 +126,18 @@ export default function TabBar(props: TabBarProps) {
}
return (
<TabBarContext.Provider
value={{
updateClipPath,
observeElement: element => {
resizeObserver.observe(element)
return () => {
resizeObserver.unobserve(element)
}
},
}}
>
<div className="relative flex grow">
<TabBarContext.Provider value={{ setSelectedTab }}>
<FocusArea direction="horizontal">
{innerProps => (
<aria.TabList
className="flex h-12 shrink-0 grow cursor-default items-center rounded-full"
{...innerProps}
>
<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
ref={element => {
backgroundRef.current = element
@ -129,38 +145,16 @@ export default function TabBar(props: TabBarProps) {
}}
className="pointer-events-none absolute inset-0 bg-primary/5"
/>
<Tabs>{children}</Tabs>
</div>
</TabBarContext.Provider>
)
}
// ============
// === 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 })}
>
</aria.Tab>
{children}
</div>
</aria.TabList>
)}
</FocusArea>
</TabBarContext.Provider>
</div>
)
}
const Tabs = React.forwardRef(TabsInternal)
// ===========
// === Tab ===
// ===========
@ -168,36 +162,31 @@ const Tabs = React.forwardRef(TabsInternal)
/** Props for a {@link Tab}. */
interface InternalTabProps extends Readonly<React.PropsWithChildren> {
readonly 'data-testid'?: string
readonly id: string
readonly project?: projectHooks.Project
readonly isActive: boolean
readonly isHidden?: boolean
readonly icon: string
readonly labelId: text.TextId
readonly onPress: () => void
readonly onClose?: () => void
readonly onLoadEnd?: () => void
}
/** A tab in a {@link TabBar}. */
export function Tab(props: InternalTabProps) {
const { isActive, icon, labelId, children, onPress, onClose, project, onLoadEnd } = props
const { updateClipPath, observeElement } = useTabBarContext()
const { id, project, isActive, isHidden = false, icon, labelId, children, onClose } = props
const { onLoadEnd } = props
const { setSelectedTab } = useTabBarContext()
const ref = React.useRef<HTMLDivElement | null>(null)
const isLoadingRef = React.useRef(true)
const { getText } = textProvider.useText()
const actuallyActive = isActive && !isHidden
React.useLayoutEffect(() => {
if (isActive) {
updateClipPath(ref.current)
if (actuallyActive && ref.current) {
setSelectedTab(ref.current)
}
}, [isActive, updateClipPath])
React.useEffect(() => {
if (ref.current) {
return observeElement(ref.current)
} else {
return () => {}
}
}, [observeElement])
}, [actuallyActive, id, setSelectedTab])
const { isLoading, data } = reactQuery.useQuery<backend.Project>(
project?.id
@ -216,38 +205,42 @@ export function Tab(props: InternalTabProps) {
}, [isFetching, onLoadEnd])
return (
<div
ref={ref}
<aria.Tab
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(
'group relative flex h-full items-center gap-3',
!isActive && 'hover:enabled:bg-frame'
'relative flex h-full items-center gap-3 rounded-t-2xl px-4',
!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
data-testid={props['data-testid']}
size="custom"
variant="custom"
loaderPosition="icon"
icon={icon}
isDisabled={false}
isActive={isActive}
loading={isActive ? false : isFetching}
aria-label={getText(labelId)}
className={tailwindMerge.twMerge('h-full', onClose ? 'pl-4' : 'px-4')}
contentClassName="gap-3"
tooltip={false}
onPress={onPress}
>
<ariaComponents.Text truncate="1" className="max-w-32">
{isLoading ? (
<StatelessSpinner
state={spinnerModule.SpinnerState.loadingMedium}
size={16}
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
/>
) : (
<SvgMask
src={icon}
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
/>
)}
{children}
</ariaComponents.Text>
</ariaComponents.Button>
{onClose && (
<div className="flex pr-4">
<div className="flex">
<ariaComponents.CloseButton onPress={onClose} />
</div>
)}
</div>
</aria.Tab>
)
}

View File

@ -14,6 +14,7 @@ import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import LocalStorage from '#/utilities/LocalStorage'
import * as object from '#/utilities/object'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// =================
@ -95,6 +96,9 @@ export function TermsOfServiceModal() {
)
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 (
<>
<ariaComponents.Dialog
@ -129,7 +133,7 @@ export function TermsOfServiceModal() {
)}
id={checkboxId}
data-testid="terms-of-service-checkbox"
{...register('agree')}
{...object.omit(register('agree'), 'isInvalid')}
/>
<label htmlFor={checkboxId}>

View File

@ -38,6 +38,7 @@ import * as tabBar from '#/layouts/TabBar'
import TabBar from '#/layouts/TabBar'
import UserBar from '#/layouts/UserBar'
import * as aria from '#/components/aria'
import Page from '#/components/Page'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
@ -107,13 +108,14 @@ function DashboardInner(props: DashboardProps) {
? localBackendModule.newProjectId(projectManager.UUID(initialProjectNameRaw))
: null
const initialProjectName = initialLocalProjectId ?? initialProjectNameRaw
const isUserEnabled = user.isEnabled
const defaultCategory = initialLocalProjectId == null ? Category.cloud : Category.local
const [category, setCategory] = searchParamsState.useSearchParamsState(
'driveCategory',
() => defaultCategory,
() => {
const shouldDefaultToCloud =
initialLocalProjectId == null && (user.isEnabled || localBackend == null)
return shouldDefaultToCloud ? Category.cloud : Category.local
},
(value): value is Category => {
if (array.includes(Object.values(Category), value)) {
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 launchedProjects = projectsProvider.useLaunchedProjects()
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 openEditor = projectHooks.useOpenEditor()
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(
() =>
inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
@ -246,23 +256,30 @@ function DashboardInner(props: DashboardProps) {
return (
<Page hideInfoBar hideChat>
<div className="flex text-xs text-primary">
<div
className="relative flex h-screen grow select-none flex-col container-size"
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"
selectedKey={page}
onSelectionChange={newPage => {
const validated = projectsProvider.PAGES_SCHEMA.safeParse(newPage)
if (validated.success) {
setPage(validated.data)
}
}}
>
<div className="flex">
<TabBar>
<tabBar.Tab
id={projectsProvider.TabType.drive}
isActive={page === projectsProvider.TabType.drive}
icon={DriveIcon}
labelId="drivePageName"
onPress={() => {
setPage(projectsProvider.TabType.drive)
}}
>
{getText('drivePageName')}
</tabBar.Tab>
@ -270,14 +287,12 @@ function DashboardInner(props: DashboardProps) {
{launchedProjects.map(project => (
<tabBar.Tab
data-testid="editor-tab-button"
id={project.id}
project={project}
key={project.id}
isActive={page === project.id}
icon={EditorIcon}
labelId="editorPageName"
onPress={() => {
setPage(project.id)
}}
onClose={() => {
closeProject(project)
}}
@ -289,21 +304,18 @@ function DashboardInner(props: DashboardProps) {
</tabBar.Tab>
))}
{page === projectsProvider.TabType.settings && (
<tabBar.Tab
isActive
id={projectsProvider.TabType.settings}
isHidden={page !== projectsProvider.TabType.settings}
icon={SettingsIcon}
labelId="settingsPageName"
onPress={() => {
setPage(projectsProvider.TabType.settings)
}}
onClose={() => {
setPage(projectsProvider.TabType.drive)
}}
>
{getText('settingsPageName')}
</tabBar.Tab>
)}
</TabBar>
<UserBar
@ -315,7 +327,11 @@ function DashboardInner(props: DashboardProps) {
onSignOut={onSignOut}
/>
</div>
<aria.TabPanel
shouldForceMount
id={projectsProvider.TabType.drive}
className="flex grow [&[data-inert]]:hidden"
>
<Drive
assetsManagementApiRef={assetManagementApiRef}
category={category}
@ -323,8 +339,14 @@ function DashboardInner(props: DashboardProps) {
hidden={page !== projectsProvider.TabType.drive}
initialProjectName={initialProjectName}
/>
{launchedProjects.map(project => (
</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}
@ -340,9 +362,12 @@ function DashboardInner(props: DashboardProps) {
renameProjectMutation.mutate({ newName, project })
}}
/>
</aria.TabPanel>
))}
{page === projectsProvider.TabType.settings && <Settings />}
<aria.TabPanel id={projectsProvider.TabType.settings} className="flex grow">
<Settings />
</aria.TabPanel>
</aria.Tabs>
{process.env.ENSO_CLOUD_CHAT_URL != null ? (
<Chat
isOpen={isHelpChatOpen}
@ -360,7 +385,6 @@ function DashboardInner(props: DashboardProps) {
/>
)}
</div>
</div>
</Page>
)
}

View File

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

View File

@ -93,6 +93,35 @@ interface SystemApi {
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 ===
// ====================
@ -120,6 +149,8 @@ declare global {
readonly navigationApi: NavigationApi
readonly menuApi: MenuApi
readonly systemApi?: SystemApi
readonly fileBrowserApi?: FileBrowserApi
readonly projectManagementApi?: ProjectManagementApi
readonly versionInfo?: VersionInfo
toggleDevtools: () => void
}

View File

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

View File

@ -71,8 +71,7 @@ type Encoding
default -> Encoding =
# 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.
# ToDo: This is a workaround for performance issue.
Encoding.utf_8
Encoding.Default
## PRIVATE
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
## 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
## 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
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
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
## 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
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
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
## 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.
connection.drop_table tmp_table.name
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
## PRIVATE
@ -551,7 +551,7 @@ common_delete_rows target_table key_values_to_delete key_columns allow_duplicate
source.drop_temporary_table connection
if dry_run.not then affected_row_count else
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
## 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.
- On Windows, the `run` command must be run in the latest version of
`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 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
@ -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
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
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:
Then, you can build the updater/launcher using:
```bash
sbt launcher/buildNativeImage

View File

@ -15,7 +15,7 @@ any additional dependencies.
<!-- MarkdownTOC levels="2,3" autolink="true" -->
- [Project Manager Bundle](#project-manager-bundle)
- [Launcher Bundles](#launcher-bundles)
- [`ensoup` Bundles](#ensoup-bundles)
<!-- /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
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
different mechanism.
Bundles are also distributed for the `ensoup` updater, but these are implemented
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
engine and runtime are simply included within its portable package. They can
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
[plugins](./launcher.md#running-plugins).
<!--
> 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
> of that package. They are a temporary replacement for the launcher
> functionality, so once the universal launcher matures, they will be removed.
> 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.
-->
## Enso Distribution Layout
@ -55,7 +57,7 @@ The directory structure is as follows:
```
extraction-location
├── bin
│ └── enso # The universal launcher, responsible for choosing the appropriate compiler version.
│ └── ensoup # The universal launcher, responsible for choosing the appropriate compiler version.
├── config
│ └── global-config.yaml # Global user configuration.
├── dist # Per-compiler-version distribution directories.
@ -112,7 +114,7 @@ ENSO_CONFIG_DIRECTORY
└── global-config.yaml # Global user configuration.
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`

View File

@ -6,7 +6,7 @@ tags: [distribution, launcher]
order: 4
---
# Enso Launcher
# Enso Updater/Launcher
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

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
version.
These assets contain bundles that include the Enso launcher, an engine version,
and GraalVM, allowing you to get up and running immediately. Alternatively, you
can download just the launcher, which will handle downloading and installing the
required components for you.
These assets contain bundles that include the `ensoup` updater, an engine
version, and GraalVM, allowing you to get up and running immediately.
Alternatively, you can download just the updater, which will handle downloading
and installing the required components for you.
<!-- 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.
- [**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 components.
- [**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)
and the logs output can be overwritten by `ENSO_LOGSERVER_APPENDER` env
variable
- `launcher` or `runner` - the default log output can be overwritten by defining
the `ENSO_APPENDER_DEFAULT` env variable
- `ensoup` or `enso` - the default log output can be overwritten by defining the
`ENSO_APPENDER_DEFAULT` env variable
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)
- [No Cross-Compilation](#no-cross-compilation)
- [Configuration](#configuration)
- [Launcher Configuration](#launcher-configuration)
- [`ensoup` Configuration](#ensoup-configuration)
- [Project Manager Configuration](#project-manager-configuration)
<!-- /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`,
will statically link the built binary, to ensure portability between Linux
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
@ -105,7 +105,7 @@ java \
<application arguments>
```
For example, to update settings for the Launcher:
For example, to update settings for the Launcher project:
```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>
@ -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
```
### Launcher Configuration
### `ensoup` Configuration
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

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

View File

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

View File

@ -4,6 +4,9 @@ import org.enso.semver.SemVer
object Constants {
/** Base name of the launcher executable */
val name = "ensoup"
/** 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

View File

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

View File

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

View File

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

View File

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

View File

@ -113,7 +113,7 @@ trait NativeTest
* functionality.
*/
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.
*

View File

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

View File

@ -33,7 +33,8 @@ class UninstallerSpec extends NativeTest with WithTemporaryDirectory {
val dataDirectory = installedRoot
val runDirectory = installedRoot
val logDirectory = installedRoot / "log"
val portableLauncher = binDirectory / OS.executableName("enso")
val portableLauncher =
binDirectory / OS.executableName(org.enso.launcher.Constants.name)
copyLauncherTo(portableLauncher)
Files.createDirectories(dataDirectory / "dist")
Files.createDirectories(configDirectory)

View File

@ -36,7 +36,11 @@ class UpgradeSpec
/** Location of the actual launcher executable that is wrapped by the shims.
*/
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`.
*/
@ -57,7 +61,7 @@ class UpgradeSpec
Files.createDirectories(destinationDirectory)
Files.copy(
builtLauncherBinary(version),
destinationDirectory / OS.executableName("enso"),
destinationDirectory / OS.executableName(Constants.name),
StandardCopyOption.REPLACE_EXISTING
)
}
@ -103,7 +107,7 @@ class UpgradeSpec
/** Path to the launcher executable in the temporary distribution.
*/
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.
* @return the reported version
@ -274,7 +278,7 @@ class UpgradeSpec
.listDirectory(binDirectory)
.map(_.getFileName.toString)
.filter(_.startsWith("enso"))
leftOverExecutables shouldEqual Seq(OS.executableName("enso"))
leftOverExecutables shouldEqual Seq(OS.executableName(Constants.name))
}
} finally {
if (process.isAlive) {

View File

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

View File

@ -411,7 +411,7 @@ class DistributionManager(val env: Environment) {
def irCacheDirectory: Path = this.cacheDirectory / "ir"
private def executableName: String =
OS.executableName("enso")
OS.executableName("ensoup")
/** The path where the binary executable of the installed distribution
* 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.infrastructure.repository.ProjectRepositoryFactory
import org.enso.projectmanager.service.ProjectService
import org.slf4j.LoggerFactory
import java.io.{File, InputStream}
import java.nio.file.Files
@ -21,13 +22,16 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
projectRepositoryFactory: ProjectRepositoryFactory[F]
) extends FileSystemServiceApi[F] {
private lazy val logger = LoggerFactory.getLogger(this.getClass)
/** @inheritdoc */
override def exists(path: File): F[FileSystemServiceFailure, Boolean] =
fileSystem
.exists(path)
.mapError(_ =>
.mapError { error =>
logger.warn("Failed to check if path exists", error)
FileSystemServiceFailure.FileSystem("Failed to check if path exists")
)
}
/** @inheritdoc */
override def list(
@ -35,9 +39,10 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
): F[FileSystemServiceFailure, Seq[FileSystemEntry]] =
fileSystem
.list(path)
.mapError(_ =>
.mapError { error =>
logger.warn("Failed to list directories", error)
FileSystemServiceFailure.FileSystem("Failed to list directories")
)
}
.flatMap { files =>
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] =
fileSystem
.createDir(path)
.mapError(_ =>
.mapError { error =>
logger.warn("Failed to create directory", error)
FileSystemServiceFailure.FileSystem("Failed to create directory")
)
}
/** @inheritdoc */
override def delete(path: File): F[FileSystemServiceFailure, Unit] =
fileSystem
.remove(path)
.mapError(_ =>
.mapError { error =>
logger.warn("Failed to delete path", error)
FileSystemServiceFailure.FileSystem("Failed to delete path")
)
}
/** @inheritdoc */
override def move(from: File, to: File): F[FileSystemServiceFailure, Unit] =
fileSystem
.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 */
override def copy(from: File, to: File): F[FileSystemServiceFailure, Unit] =
fileSystem
.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 */
override def write(
@ -77,9 +90,10 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
): F[FileSystemServiceFailure, Unit] =
fileSystem
.writeFile(path, contents)
.mapError(_ =>
.mapError { error =>
logger.warn("Failed to write path", error)
FileSystemServiceFailure.FileSystem("Failed to write path")
)
}
private def toFileSystemEntry(
path: File

View File

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

View File

@ -211,7 +211,7 @@ add_specs suite_builder =
(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 = []
+ [[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,"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]
bytes = utf_16_le_bom + ("{}".bytes Encoding.utf_16_le)
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
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" <|
bytes = [65, -60, -123, -60, -103]
# 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)
type Lazy_Data
private Write ~table:Table
collect_benches = Bench.build builder->
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->
group_builder.specify "data_csv" <|
table = Examples.csv_2500_rows . read
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

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)
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)
f = File.create_temporary_file "delimited-utf-16-bom" ".csv"
bytes.write_bytes f . should_succeed
@ -485,7 +485,7 @@ add_specs suite_builder =
# No hidden BOM in the column name
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]
bytes = utf_8_bom + ('a,b\n1,2'.bytes Encoding.utf_8)
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
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)
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:
r2 = f.read
@ -527,7 +528,7 @@ add_specs suite_builder =
r.first_column.to_vector . should_equal ['\uFFFD']
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"
# 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)

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.
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"
Test.with_clue "UTF-16 detected by BOM: " <|
bom = [-1, -2]