mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 12:21:37 +03:00
merge
This commit is contained in:
commit
553bf6b4cc
@ -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
|
||||||
|
@ -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)"
|
||||||
/>
|
/>
|
||||||
|
@ -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"
|
||||||
|
@ -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) => {
|
||||||
|
@ -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
|
||||||
|
@ -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) => {
|
||||||
|
@ -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.
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -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. */
|
||||||
|
@ -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)
|
||||||
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
||||||
}
|
|
||||||
|
@ -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),
|
||||||
|
@ -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)
|
||||||
|
@ -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'))
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
@ -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)
|
||||||
|
@ -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',
|
||||||
|
@ -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>
|
||||||
|
@ -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> {
|
||||||
|
@ -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))
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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}>
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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'))
|
||||||
|
|
||||||
|
31
app/ide-desktop/lib/types/globals.d.ts
vendored
31
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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 := {},
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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`
|
||||||
|
@ -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
|
||||||
|
@ -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" -->
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
package org.enso.launcher;
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
package org.enso.launcher;
|
@ -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.
|
||||||
*
|
*
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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) {
|
||||||
|
@ -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";
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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")
|
||||||
)
|
)
|
||||||
|
@ -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]]
|
||||||
|
@ -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"
|
||||||
|
@ -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 ą ę
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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]
|
||||||
|
Loading…
Reference in New Issue
Block a user