mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 07:12:20 +03:00
merge
This commit is contained in:
commit
553bf6b4cc
@ -3,8 +3,10 @@
|
||||
#### Enso Language & Runtime
|
||||
|
||||
- [Enforce conversion method return type][10468]
|
||||
- [Renaming launcher executable to ensoup][10535]
|
||||
|
||||
[10468]: https://github.com/enso-org/enso/pull/10468
|
||||
[10535]: https://github.com/enso-org/enso/pull/10535
|
||||
|
||||
#### Enso IDE
|
||||
|
||||
@ -12,7 +14,7 @@
|
||||
- [Numeric Widget does not accept non-numeric input][10457]. This is to prevent
|
||||
node being completely altered by accidental code put to the widget.
|
||||
- [Redesigned "record control" panel][10509]. Now it contains more intuitive
|
||||
"refresh" and "run workflow" buttons.
|
||||
"refresh" and "write all" buttons.
|
||||
- [Warning messages do not obscure visualization buttons][10546].
|
||||
|
||||
[10433]: https://github.com/enso-org/enso/pull/10443
|
||||
|
@ -92,7 +92,7 @@ function readableBinding(binding: keyof (typeof graphBindings)['bindings']) {
|
||||
icon="record"
|
||||
class="slot7 record"
|
||||
data-testid="toggleRecord"
|
||||
title="Record"
|
||||
title="Write Always"
|
||||
:modelValue="props.isRecordingOverridden"
|
||||
@update:modelValue="emit('update:isRecordingOverridden', $event)"
|
||||
/>
|
||||
|
@ -18,7 +18,7 @@ const project = useProjectStore()
|
||||
</div>
|
||||
<div class="control right-end">
|
||||
<SvgButton
|
||||
title="Run Workflow"
|
||||
title="Write All"
|
||||
class="iconButton"
|
||||
name="workflow_play"
|
||||
draggable="false"
|
||||
|
@ -39,10 +39,9 @@
|
||||
* credentials.
|
||||
*
|
||||
* To redirect the user from the IDE to an external source:
|
||||
* 1. Call the {@link initIpc} function to register a listener for
|
||||
* {@link ipc.Channel.openUrlInSystemBrowser} IPC events.
|
||||
* 2. Emit an {@link ipc.Channel.openUrlInSystemBrowser} event. The listener registered in the
|
||||
* {@link initIpc} function will use the {@link opener} library to open the event's {@link URL}
|
||||
* 1. Register a listener for {@link ipc.Channel.openUrlInSystemBrowser} IPC events.
|
||||
* 2. Emit an {@link ipc.Channel.openUrlInSystemBrowser} event. The listener registered in step
|
||||
* 1 will use the {@link opener} library to open the event's {@link URL}
|
||||
* argument in the system web browser, in a cross-platform way.
|
||||
*
|
||||
* ## Redirect To IDE
|
||||
@ -57,7 +56,7 @@
|
||||
*
|
||||
* To prepare the application to handle deep links:
|
||||
* - Register a custom URL protocol scheme with the OS (c.f., `electron-builder-config.ts`).
|
||||
* - Define a listener for Electron `OPEN_URL_EVENT`s (c.f., {@link initOpenUrlListener}).
|
||||
* - Define a listener for Electron `OPEN_URL_EVENT`s.
|
||||
* - Define a listener for {@link ipc.Channel.openDeepLink} events (c.f., `preload.ts`).
|
||||
*
|
||||
* Then when the user clicks on a deep link from an external source to the IDE:
|
||||
@ -71,7 +70,6 @@
|
||||
* Then it parses the {@link URL} from the event's {@link URL} argument. Then it uses the
|
||||
* {@link URL} to redirect the user to the dashboard, to the page specified in the {@link URL}'s
|
||||
* `pathname`. */
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
@ -97,48 +95,17 @@ const logger = contentConfig.logger
|
||||
* not a variable because the main window is not available when this function is called. This module
|
||||
* does not use the `window` until after it is initialized, so while the lambda may return `null` in
|
||||
* theory, it never will in practice. */
|
||||
export function initModule(window: () => electron.BrowserWindow) {
|
||||
initIpc()
|
||||
initOpenUrlListener(window)
|
||||
initSaveAccessTokenListener()
|
||||
}
|
||||
|
||||
/** Register an Inter-Process Communication (IPC) channel between the Electron application and the
|
||||
* served website.
|
||||
*
|
||||
* This channel listens for {@link ipc.Channel.openUrlInSystemBrowser} events. When this kind of
|
||||
* event is fired, this listener will assume that the first and only argument of the event is a URL.
|
||||
* This listener will then attempt to open the URL in a cross-platform way. The intent is to open
|
||||
* the URL in the system browser.
|
||||
*
|
||||
* This functionality is necessary because we don't want to run the OAuth flow in the app. Users
|
||||
* don't trust Electron apps to handle their credentials. */
|
||||
function initIpc() {
|
||||
export function initAuthentication(window: () => electron.BrowserWindow) {
|
||||
// Listen for events to open a URL externally in a browser the user trusts. This is used for
|
||||
// OAuth authentication, both for trustworthiness and for convenience (the ability to use the
|
||||
// browser's saved passwords).
|
||||
electron.ipcMain.on(ipc.Channel.openUrlInSystemBrowser, (_event, url: string) => {
|
||||
logger.log(`Opening URL in system browser: '${url}'.`)
|
||||
urlAssociations.setAsUrlHandler()
|
||||
logger.log(`Opening URL '${url}' in the default browser.`)
|
||||
opener(url)
|
||||
})
|
||||
}
|
||||
|
||||
/** Register a listener that fires a callback for `open-url` events, when the URL is a deep link.
|
||||
*
|
||||
* This listener is used to open a page in *this* application window, when the user is
|
||||
* redirected to a URL with a protocol supported by this application.
|
||||
*
|
||||
* All URLs that aren't deep links (i.e., URLs that don't use the {@link common.DEEP_LINK_SCHEME}
|
||||
* protocol) will be ignored by this handler. Non-deep link URLs will be handled by Electron. */
|
||||
function initOpenUrlListener(window: () => electron.BrowserWindow) {
|
||||
// Listen for events to handle deep links.
|
||||
urlAssociations.registerUrlCallback(url => {
|
||||
onOpenUrl(url, window)
|
||||
})
|
||||
}
|
||||
|
||||
/** Handle the 'open-url' event by parsing the received URL, checking if it is a deep link, and
|
||||
* sending it to the appropriate BrowserWindow via IPC.
|
||||
* @param url - The URL to handle.
|
||||
* @param window - A function that returns the BrowserWindow to send the parsed URL to. */
|
||||
export function onOpenUrl(url: URL, window: () => electron.BrowserWindow) {
|
||||
logger.log(`Received 'open-url' event for '${url.toString()}'.`)
|
||||
if (url.protocol !== `${common.DEEP_LINK_SCHEME}:`) {
|
||||
logger.error(`'${url.toString()}' is not a deep link, ignoring.`)
|
||||
@ -146,16 +113,9 @@ export function onOpenUrl(url: URL, window: () => electron.BrowserWindow) {
|
||||
logger.log(`'${url.toString()}' is a deep link, sending to renderer.`)
|
||||
window().webContents.send(ipc.Channel.openDeepLink, url.toString())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** Register a listener that fires a callback for `save-access-token` events.
|
||||
*
|
||||
* This listener is used to save given access token to credentials file to be later used by
|
||||
* the backend.
|
||||
*
|
||||
* The credentials file is placed in the user's home directory in the `.enso` subdirectory
|
||||
* in the `credentials` file. */
|
||||
function initSaveAccessTokenListener() {
|
||||
// Listen for events to save the given user credentials to `~/.enso/credentials`.
|
||||
electron.ipcMain.on(
|
||||
ipc.Channel.saveAccessToken,
|
||||
(event, accessTokenPayload: SaveAccessTokenPayload | null) => {
|
||||
|
@ -40,7 +40,7 @@ export interface ExternalFunctions {
|
||||
project: stream.Readable,
|
||||
directory: string | null,
|
||||
name: string | null
|
||||
) => Promise<string>
|
||||
) => Promise<projectManagement.ProjectInfo>
|
||||
readonly runProjectManagerCommand: (
|
||||
cliArguments: string[],
|
||||
body?: NodeJS.ReadableStream
|
||||
@ -269,14 +269,14 @@ export class Server {
|
||||
const name = url.searchParams.get('name')
|
||||
void this.config.externalFunctions
|
||||
.uploadProjectBundle(request, directory, name)
|
||||
.then(id => {
|
||||
.then(project => {
|
||||
response
|
||||
.writeHead(HTTP_STATUS_OK, [
|
||||
['Content-Length', String(id.length)],
|
||||
['Content-Length', String(project.id.length)],
|
||||
['Content-Type', 'text/plain'],
|
||||
...common.COOP_COEP_CORP_HEADERS,
|
||||
])
|
||||
.end(id)
|
||||
.end(project.id)
|
||||
})
|
||||
.catch(() => {
|
||||
response
|
||||
|
@ -220,7 +220,7 @@ export class ChromeOption {
|
||||
|
||||
/** Replace `-no-...` with `--no-...`. This is a hotfix for a Yargs bug:
|
||||
* https://github.com/yargs/yargs-parser/issues/468. */
|
||||
function fixArgvNoPrefix(argv: string[]): string[] {
|
||||
function fixArgvNoPrefix(argv: readonly string[]): readonly string[] {
|
||||
const singleDashPrefix = '-no-'
|
||||
const doubleDashPrefix = '--no-'
|
||||
return argv.map(arg => {
|
||||
@ -234,13 +234,13 @@ function fixArgvNoPrefix(argv: string[]): string[] {
|
||||
|
||||
/** Command line options, split into regular arguments and Chrome options. */
|
||||
interface ArgvAndChromeOptions {
|
||||
readonly argv: string[]
|
||||
readonly argv: readonly string[]
|
||||
readonly chromeOptions: ChromeOption[]
|
||||
}
|
||||
|
||||
/** Parse the given list of arguments into two distinct sets: regular arguments and those specific
|
||||
* to Chrome. */
|
||||
function argvAndChromeOptions(processArgs: string[]): ArgvAndChromeOptions {
|
||||
function argvAndChromeOptions(processArgs: readonly string[]): ArgvAndChromeOptions {
|
||||
const chromeOptionRegex = /--?chrome.([^=]*)(?:=(.*))?/
|
||||
const argv = []
|
||||
const chromeOptions: ChromeOption[] = []
|
||||
@ -275,7 +275,7 @@ function argvAndChromeOptions(processArgs: string[]): ArgvAndChromeOptions {
|
||||
// =====================
|
||||
|
||||
/** Parse command line arguments. */
|
||||
export function parseArgs(clientArgs: string[] = fileAssociations.CLIENT_ARGUMENTS) {
|
||||
export function parseArgs(clientArgs: readonly string[] = fileAssociations.CLIENT_ARGUMENTS) {
|
||||
const args = config.CONFIG
|
||||
const { argv, chromeOptions } = argvAndChromeOptions(fixArgvNoPrefix(clientArgs))
|
||||
const yargsOptions = args.optionsRecursive().reduce((opts: Record<string, Options>, option) => {
|
||||
|
@ -4,8 +4,6 @@
|
||||
* It includes utilities for determining if a file can be opened, managing the file opening
|
||||
* process, and launching new instances of the IDE when necessary. The module also exports
|
||||
* constants related to file associations and project handling. */
|
||||
|
||||
import * as childProcess from 'node:child_process'
|
||||
import * as fsSync from 'node:fs'
|
||||
import * as pathModule from 'node:path'
|
||||
import process from 'node:process'
|
||||
@ -49,7 +47,7 @@ export const SOURCE_FILE_SUFFIX = fileAssociations.SOURCE_FILE_SUFFIX
|
||||
* @param clientArgs - A list of arguments passed to the application, stripped from the initial
|
||||
* executable name and any electron dev mode arguments.
|
||||
* @returns The path to the file to open, or `null` if no file was specified. */
|
||||
export function argsDenoteFileOpenAttempt(clientArgs: string[]): string | null {
|
||||
export function argsDenoteFileOpenAttempt(clientArgs: readonly string[]): string | null {
|
||||
const arg = clientArgs[0]
|
||||
let result: string | null = null
|
||||
// If the application is invoked with exactly one argument and this argument is a file, we
|
||||
@ -70,21 +68,21 @@ export function argsDenoteFileOpenAttempt(clientArgs: string[]): string | null {
|
||||
export const CLIENT_ARGUMENTS = getClientArguments()
|
||||
|
||||
/** Decide what are client arguments, @see {@link CLIENT_ARGUMENTS}. */
|
||||
function getClientArguments(): string[] {
|
||||
function getClientArguments(args = process.argv): readonly string[] {
|
||||
if (electronIsDev) {
|
||||
// Client arguments are separated from the electron dev mode arguments by a '--' argument.
|
||||
const separator = '--'
|
||||
const separatorIndex = process.argv.indexOf(separator)
|
||||
const separatorIndex = args.indexOf(separator)
|
||||
if (separatorIndex === NOT_FOUND) {
|
||||
// If there is no separator, client gets no arguments.
|
||||
return []
|
||||
} else {
|
||||
// Drop everything before the separator.
|
||||
return process.argv.slice(separatorIndex + 1)
|
||||
return args.slice(separatorIndex + 1)
|
||||
}
|
||||
} else {
|
||||
// Drop the leading executable name.
|
||||
return process.argv.slice(1)
|
||||
return args.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,39 +99,14 @@ export function isFileOpenable(path: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** On macOS when an Enso-associated file is opened, the application is first started and then it
|
||||
* receives the `open-file` event. However, if there is already an instance of Enso running,
|
||||
* it receives the `open-file` event (and no new instance is created for us). In this case,
|
||||
* we manually start a new instance of the application and pass the file path to it (using the
|
||||
* Windows-style command). */
|
||||
export function onFileOpened(event: electron.Event, path: string): string | null {
|
||||
/** Callback called when a file is opened via the `open-file` event. */
|
||||
export function onFileOpened(event: electron.Event, path: string): project.ProjectInfo | null {
|
||||
logger.log(`Received 'open-file' event for path '${path}'.`)
|
||||
if (isFileOpenable(path)) {
|
||||
logger.log(`The file '${path}' is openable.`)
|
||||
// If we are not ready, we can still decide to open a project rather than enter the welcome
|
||||
// screen. However, we still check for the presence of arguments, to prevent hijacking the
|
||||
// user-spawned IDE instance (OS-spawned will not have arguments set).
|
||||
if (!electron.app.isReady() && CLIENT_ARGUMENTS.length === 0) {
|
||||
event.preventDefault()
|
||||
logger.log(`Opening file '${path}'.`)
|
||||
return handleOpenFile(path)
|
||||
} else {
|
||||
// Another copy of the application needs to be started, as the first one is
|
||||
// already running.
|
||||
logger.log(
|
||||
"The application is already initialized. Starting a new instance to open file '" +
|
||||
path +
|
||||
"'."
|
||||
)
|
||||
const args = [path]
|
||||
const child = childProcess.spawn(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
// Prevent parent (this) process from waiting for the child to exit.
|
||||
child.unref()
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
logger.log(`The file '${path}' is not openable, ignoring the 'open-file' event.`)
|
||||
return null
|
||||
@ -143,11 +116,32 @@ export function onFileOpened(event: electron.Event, path: string): string | null
|
||||
/** Set up the `open-file` event handler that might import a project and invoke the given callback,
|
||||
* if this IDE instance should load the project. See {@link onFileOpened} for more details.
|
||||
* @param setProjectToOpen - A function that will be called with the ID of the project to open. */
|
||||
export function setOpenFileEventHandler(setProjectToOpen: (id: string) => void) {
|
||||
export function setOpenFileEventHandler(setProjectToOpen: (info: project.ProjectInfo) => void) {
|
||||
electron.app.on('open-file', (event, path) => {
|
||||
const projectId = onFileOpened(event, path)
|
||||
if (typeof projectId === 'string') {
|
||||
setProjectToOpen(projectId)
|
||||
logger.log(`Opening file '${path}'.`)
|
||||
const projectInfo = onFileOpened(event, path)
|
||||
if (projectInfo) {
|
||||
setProjectToOpen(projectInfo)
|
||||
}
|
||||
})
|
||||
|
||||
electron.app.on('second-instance', (event, _argv, _workingDir, additionalData) => {
|
||||
// Check if additional data is an object that contains the URL.
|
||||
logger.log(`Checking path`, additionalData)
|
||||
const path =
|
||||
additionalData != null &&
|
||||
typeof additionalData === 'object' &&
|
||||
'fileToOpen' in additionalData &&
|
||||
typeof additionalData.fileToOpen === 'string'
|
||||
? additionalData.fileToOpen
|
||||
: null
|
||||
if (path != null) {
|
||||
logger.log(`Got path '${path.toString()}' from second instance.`)
|
||||
event.preventDefault()
|
||||
const projectInfo = onFileOpened(event, path)
|
||||
if (projectInfo) {
|
||||
setProjectToOpen(projectInfo)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -159,7 +153,7 @@ export function setOpenFileEventHandler(setProjectToOpen: (id: string) => void)
|
||||
* @param openedFile - The path to the file to open.
|
||||
* @returns The ID of the project to open.
|
||||
* @throws {Error} if the project from the file cannot be opened or imported. */
|
||||
export function handleOpenFile(openedFile: string): string {
|
||||
export function handleOpenFile(openedFile: string): project.ProjectInfo {
|
||||
try {
|
||||
return project.importProjectFromPath(openedFile)
|
||||
} catch (error) {
|
||||
@ -189,7 +183,7 @@ export function handleFileArguments(openedFile: string | null, args: clientConfi
|
||||
// This makes the IDE open the relevant project. Also, this prevents us from using this
|
||||
// method after IDE has been fully set up, as the initializing code would have already
|
||||
// read the value of this argument.
|
||||
args.groups.startup.options.project.value = handleOpenFile(openedFile)
|
||||
args.groups.startup.options.project.value = handleOpenFile(openedFile).id
|
||||
} catch (e) {
|
||||
// If we failed to open the file, we should enter the usual welcome screen.
|
||||
// The `handleOpenFile` function will have already displayed an error message.
|
||||
|
@ -60,8 +60,12 @@ class App {
|
||||
log.addFileLog()
|
||||
urlAssociations.registerAssociations()
|
||||
// Register file associations for macOS.
|
||||
fileAssociations.setOpenFileEventHandler(id => {
|
||||
this.setProjectToOpenOnStartup(id)
|
||||
fileAssociations.setOpenFileEventHandler(project => {
|
||||
if (electron.app.isReady()) {
|
||||
this.window?.webContents.send(ipc.Channel.openProject, project)
|
||||
} else {
|
||||
this.setProjectToOpenOnStartup(project.id)
|
||||
}
|
||||
})
|
||||
|
||||
electron.app.commandLine.appendSwitch('allow-insecure-localhost', 'true')
|
||||
@ -79,7 +83,6 @@ class App {
|
||||
)
|
||||
|
||||
const { windowSize, chromeOptions, fileToOpen, urlToOpen } = this.processArguments()
|
||||
this.handleItemOpening(fileToOpen, urlToOpen)
|
||||
if (this.args.options.version.value) {
|
||||
await this.printVersion()
|
||||
electron.app.quit()
|
||||
@ -89,37 +92,57 @@ class App {
|
||||
electron.app.quit()
|
||||
})
|
||||
} else {
|
||||
const isOriginalInstance = electron.app.requestSingleInstanceLock({
|
||||
fileToOpen,
|
||||
urlToOpen,
|
||||
})
|
||||
if (isOriginalInstance) {
|
||||
this.handleItemOpening(fileToOpen, urlToOpen)
|
||||
this.setChromeOptions(chromeOptions)
|
||||
security.enableAll()
|
||||
electron.app.on('before-quit', () => (this.isQuitting = true))
|
||||
electron.app.on('before-quit', () => {
|
||||
this.isQuitting = true
|
||||
})
|
||||
electron.app.on('second-instance', (_event, argv) => {
|
||||
logger.log(`Got data from 'second-instance' event: '${argv.toString()}'.`)
|
||||
// The second instances will close themselves, but our window likely is not in the
|
||||
// foreground - the focus went to the "second instance" of the application.
|
||||
if (this.window) {
|
||||
if (this.window.isMinimized()) {
|
||||
this.window.restore()
|
||||
}
|
||||
this.window.focus()
|
||||
} else {
|
||||
logger.error('No window found after receiving URL from second instance.')
|
||||
}
|
||||
})
|
||||
electron.app.whenReady().then(
|
||||
() => {
|
||||
async () => {
|
||||
logger.log('Electron application is ready.')
|
||||
void this.main(windowSize)
|
||||
await this.main(windowSize)
|
||||
},
|
||||
err => {
|
||||
logger.error('Failed to initialize electron.', err)
|
||||
error => {
|
||||
logger.error('Failed to initialize Electron.', error)
|
||||
}
|
||||
)
|
||||
this.registerShortcuts()
|
||||
} else {
|
||||
logger.log('Another instance of the application is already running, exiting.')
|
||||
electron.app.quit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Process the command line arguments. */
|
||||
processArguments() {
|
||||
processArguments(args = fileAssociations.CLIENT_ARGUMENTS) {
|
||||
// We parse only "client arguments", so we don't have to worry about the Electron-Dev vs
|
||||
// Electron-Proper distinction.
|
||||
const fileToOpen = fileAssociations.argsDenoteFileOpenAttempt(
|
||||
fileAssociations.CLIENT_ARGUMENTS
|
||||
)
|
||||
const urlToOpen = urlAssociations.argsDenoteUrlOpenAttempt(
|
||||
fileAssociations.CLIENT_ARGUMENTS
|
||||
)
|
||||
const fileToOpen = fileAssociations.argsDenoteFileOpenAttempt(args)
|
||||
const urlToOpen = urlAssociations.argsDenoteUrlOpenAttempt(args)
|
||||
// If we are opening a file (i.e. we were spawned with just a path of the file to open as
|
||||
// the argument) or URL, it means that effectively we don't have any non-standard arguments.
|
||||
// We just need to let caller know that we are opening a file.
|
||||
const argsToParse =
|
||||
fileToOpen != null || urlToOpen != null ? [] : fileAssociations.CLIENT_ARGUMENTS
|
||||
const argsToParse = fileToOpen != null || urlToOpen != null ? [] : args
|
||||
return { ...configParser.parseArgs(argsToParse), fileToOpen, urlToOpen }
|
||||
}
|
||||
|
||||
@ -153,8 +176,8 @@ class App {
|
||||
// This makes the IDE open the relevant project. Also, this prevents us from using
|
||||
// this method after the IDE has been fully set up, as the initializing code
|
||||
// would have already read the value of this argument.
|
||||
const projectId = fileAssociations.handleOpenFile(fileToOpen)
|
||||
this.setProjectToOpenOnStartup(projectId)
|
||||
const projectInfo = fileAssociations.handleOpenFile(fileToOpen)
|
||||
this.setProjectToOpenOnStartup(projectInfo.id)
|
||||
}
|
||||
|
||||
if (urlToOpen != null) {
|
||||
@ -170,14 +193,14 @@ class App {
|
||||
* Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. */
|
||||
setChromeOptions(chromeOptions: configParser.ChromeOption[]) {
|
||||
const addIf = (
|
||||
opt: contentConfig.Option<boolean>,
|
||||
option: contentConfig.Option<boolean>,
|
||||
chromeOptName: string,
|
||||
value?: string
|
||||
) => {
|
||||
if (opt.value) {
|
||||
if (option.value) {
|
||||
const chromeOption = new configParser.ChromeOption(chromeOptName, value)
|
||||
const chromeOptionStr = chromeOption.display()
|
||||
const optionName = opt.qualifiedName()
|
||||
const optionName = option.qualifiedName()
|
||||
logger.log(`Setting '${chromeOptionStr}' because '${optionName}' was enabled.`)
|
||||
chromeOptions.push(chromeOption)
|
||||
}
|
||||
@ -233,7 +256,7 @@ class App {
|
||||
* not yet created at this point, but it will be created by the time the
|
||||
* authentication module uses the lambda providing the window. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
authentication.initModule(() => this.window!)
|
||||
authentication.initAuthentication(() => this.window!)
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to initialize the application, shutting down. Error: ', err)
|
||||
@ -390,9 +413,9 @@ class App {
|
||||
}
|
||||
)
|
||||
|
||||
window.on('close', evt => {
|
||||
window.on('close', event => {
|
||||
if (!this.isQuitting && !this.args.groups.window.options.closeToQuit.value) {
|
||||
evt.preventDefault()
|
||||
event.preventDefault()
|
||||
window.hide()
|
||||
}
|
||||
})
|
||||
|
@ -15,14 +15,14 @@ export enum Channel {
|
||||
quit = 'quit-ide',
|
||||
/** Channel for requesting that a URL be opened by the system browser. */
|
||||
openUrlInSystemBrowser = 'open-url-in-system-browser',
|
||||
/** Channel for setting a callback that handles deep links to this application. */
|
||||
setDeepLinkHandler = 'set-deep-link-handler',
|
||||
/** Channel for signaling that a deep link to this application was opened. */
|
||||
openDeepLink = 'open-deep-link',
|
||||
/** Channel for signaling that access token be saved to a credentials file. */
|
||||
saveAccessToken = 'save-access-token',
|
||||
/** Channel for importing a project or project bundle from the given path. */
|
||||
importProjectFromPath = 'import-project-from-path',
|
||||
/** Channel for opening project */
|
||||
openProject = 'open-project',
|
||||
goBack = 'go-back',
|
||||
goForward = 'go-forward',
|
||||
/** Channel for selecting files and directories using the system file browser. */
|
||||
|
@ -7,46 +7,49 @@ import * as electron from 'electron'
|
||||
|
||||
import * as debug from 'debug'
|
||||
import * as ipc from 'ipc'
|
||||
import type * as projectManagement from 'project-management'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** Name given to the {@link BACKEND_API} object, when it is exposed on the Electron main
|
||||
* window. */
|
||||
const BACKEND_API_KEY = 'backendApi'
|
||||
/** Name given to the {@link AUTHENTICATION_API} object, when it is exposed on the Electron main
|
||||
* window. */
|
||||
const AUTHENTICATION_API_KEY = 'authenticationApi'
|
||||
/** Name given to the {@link FILE_BROWSER_API} object, when it is exposed on the Electron main
|
||||
* window. */
|
||||
const FILE_BROWSER_API_KEY = 'fileBrowserApi'
|
||||
|
||||
const PROJECT_MANAGEMENT_API_KEY = 'projectManagementApi'
|
||||
const NAVIGATION_API_KEY = 'navigationApi'
|
||||
|
||||
const MENU_API_KEY = 'menuApi'
|
||||
|
||||
const SYSTEM_API_KEY = 'systemApi'
|
||||
|
||||
const VERSION_INFO_KEY = 'versionInfo'
|
||||
|
||||
// =========================
|
||||
// === exposeInMainWorld ===
|
||||
// =========================
|
||||
|
||||
/** A type-safe wrapper around {@link electron.contextBridge.exposeInMainWorld}. */
|
||||
function exposeInMainWorld<Key extends string & keyof typeof window>(
|
||||
key: Key,
|
||||
value: NonNullable<(typeof window)[Key]>
|
||||
) {
|
||||
electron.contextBridge.exposeInMainWorld(key, value)
|
||||
}
|
||||
|
||||
// =============================
|
||||
// === importProjectFromPath ===
|
||||
// =============================
|
||||
|
||||
const IMPORT_PROJECT_RESOLVE_FUNCTIONS = new Map<string, (projectId: string) => void>()
|
||||
|
||||
const BACKEND_API = {
|
||||
exposeInMainWorld(BACKEND_API_KEY, {
|
||||
importProjectFromPath: (projectPath: string, directory: string | null = null) => {
|
||||
electron.ipcRenderer.send(ipc.Channel.importProjectFromPath, projectPath, directory)
|
||||
return new Promise<string>(resolve => {
|
||||
IMPORT_PROJECT_RESOLVE_FUNCTIONS.set(projectPath, resolve)
|
||||
})
|
||||
},
|
||||
}
|
||||
electron.contextBridge.exposeInMainWorld(BACKEND_API_KEY, BACKEND_API)
|
||||
})
|
||||
|
||||
electron.contextBridge.exposeInMainWorld(NAVIGATION_API_KEY, {
|
||||
exposeInMainWorld(NAVIGATION_API_KEY, {
|
||||
goBack: () => {
|
||||
electron.ipcRenderer.send(ipc.Channel.goBack)
|
||||
},
|
||||
@ -64,71 +67,21 @@ electron.ipcRenderer.on(
|
||||
}
|
||||
)
|
||||
|
||||
// =======================
|
||||
// === Debug Info APIs ===
|
||||
// =======================
|
||||
|
||||
// These APIs expose functionality for use from Rust. See the bindings in the `debug_api` module for
|
||||
// the primary documentation.
|
||||
|
||||
/** Shutdown-related commands and events. */
|
||||
electron.contextBridge.exposeInMainWorld('enso_lifecycle', {
|
||||
/** Allow application-exit to be initiated from WASM code.
|
||||
* This is used, for example, in a key binding (Ctrl+Alt+Q) that saves a performance profile and
|
||||
* exits. */
|
||||
quit: () => {
|
||||
electron.ipcRenderer.send(ipc.Channel.quit)
|
||||
},
|
||||
})
|
||||
|
||||
// Save and load profile data.
|
||||
let onProfiles: ((profiles: string[]) => void)[] = []
|
||||
let profilesLoaded: string[] | null
|
||||
|
||||
electron.ipcRenderer.on(ipc.Channel.profilesLoaded, (_event, profiles: string[]) => {
|
||||
for (const callback of onProfiles) {
|
||||
callback(profiles)
|
||||
}
|
||||
onProfiles = []
|
||||
profilesLoaded = profiles
|
||||
})
|
||||
|
||||
electron.contextBridge.exposeInMainWorld('enso_profiling_data', {
|
||||
// Delivers profiling log.
|
||||
saveProfile: (data: unknown) => {
|
||||
electron.ipcRenderer.send(ipc.Channel.saveProfile, data)
|
||||
},
|
||||
// Requests any loaded profiling logs.
|
||||
loadProfiles: (callback: (profiles: string[]) => void) => {
|
||||
if (profilesLoaded == null) {
|
||||
electron.ipcRenderer.send('load-profiles')
|
||||
onProfiles.push(callback)
|
||||
} else {
|
||||
callback(profilesLoaded)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
electron.contextBridge.exposeInMainWorld('enso_hardware_info', {
|
||||
// Open a page displaying GPU debug info.
|
||||
openGpuDebugInfo: () => {
|
||||
electron.ipcRenderer.send(ipc.Channel.openGpuDebugInfo)
|
||||
},
|
||||
})
|
||||
|
||||
// Access to the system console that Electron was run from.
|
||||
electron.contextBridge.exposeInMainWorld('enso_console', {
|
||||
// Print an error message with `console.error`.
|
||||
error: (data: unknown) => {
|
||||
electron.ipcRenderer.send('error', data)
|
||||
},
|
||||
})
|
||||
|
||||
// ==========================
|
||||
// === Authentication API ===
|
||||
// ==========================
|
||||
|
||||
let currentDeepLinkHandler: ((event: Electron.IpcRendererEvent, url: string) => void) | null = null
|
||||
/** A callback called when a deep link is opened. */
|
||||
type OpenDeepLinkHandler = (url: string) => void
|
||||
|
||||
let deepLinkHandler: OpenDeepLinkHandler | null = null
|
||||
|
||||
electron.ipcRenderer.on(
|
||||
ipc.Channel.openDeepLink,
|
||||
(_event: Electron.IpcRendererEvent, ...args: Parameters<OpenDeepLinkHandler>) => {
|
||||
deepLinkHandler?.(...args)
|
||||
}
|
||||
)
|
||||
|
||||
/** Object exposed on the Electron main window; provides proxy functions to:
|
||||
* - open OAuth flows in the system browser, and
|
||||
@ -136,12 +89,12 @@ let currentDeepLinkHandler: ((event: Electron.IpcRendererEvent, url: string) =>
|
||||
*
|
||||
* Some functions (i.e., the functions to open URLs in the system browser) are not available in
|
||||
* sandboxed processes (i.e., the dashboard). So the
|
||||
* {@link electron.contextBridge.exposeInMainWorld} API is used to expose these functions.
|
||||
* {@link exposeInMainWorld} API is used to expose these functions.
|
||||
* The functions are exposed via this "API object", which is added to the main window.
|
||||
*
|
||||
* For more details, see:
|
||||
* https://www.electronjs.org/docs/latest/api/context-bridge#api-functions. */
|
||||
const AUTHENTICATION_API = {
|
||||
exposeInMainWorld(AUTHENTICATION_API_KEY, {
|
||||
/** Open a URL in the system browser (rather than in the app).
|
||||
*
|
||||
* OAuth URLs must be opened this way because the dashboard application is sandboxed and thus
|
||||
@ -156,13 +109,7 @@ const AUTHENTICATION_API = {
|
||||
* system browser or email client. Handling the links involves resuming whatever flow was in
|
||||
* progress when the link was opened (e.g., an OAuth registration flow). */
|
||||
setDeepLinkHandler: (callback: (url: string) => void) => {
|
||||
if (currentDeepLinkHandler != null) {
|
||||
electron.ipcRenderer.off(ipc.Channel.openDeepLink, currentDeepLinkHandler)
|
||||
}
|
||||
currentDeepLinkHandler = (_event, url: string) => {
|
||||
callback(url)
|
||||
}
|
||||
electron.ipcRenderer.on(ipc.Channel.openDeepLink, currentDeepLinkHandler)
|
||||
deepLinkHandler = callback
|
||||
},
|
||||
/** Save the access token to a credentials file.
|
||||
*
|
||||
@ -171,14 +118,37 @@ const AUTHENTICATION_API = {
|
||||
saveAccessToken: (accessTokenPayload: SaveAccessTokenPayload | null) => {
|
||||
electron.ipcRenderer.send(ipc.Channel.saveAccessToken, accessTokenPayload)
|
||||
},
|
||||
}
|
||||
electron.contextBridge.exposeInMainWorld(AUTHENTICATION_API_KEY, AUTHENTICATION_API)
|
||||
})
|
||||
|
||||
const FILE_BROWSER_API = {
|
||||
// ========================
|
||||
// === File Browser API ===
|
||||
// ========================
|
||||
|
||||
exposeInMainWorld(FILE_BROWSER_API_KEY, {
|
||||
openFileBrowser: (kind: 'any' | 'directory' | 'file' | 'filePath', defaultPath?: string) =>
|
||||
electron.ipcRenderer.invoke(ipc.Channel.openFileBrowser, kind, defaultPath),
|
||||
})
|
||||
|
||||
// ==============================
|
||||
// === 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)
|
||||
}
|
||||
electron.contextBridge.exposeInMainWorld(FILE_BROWSER_API_KEY, FILE_BROWSER_API)
|
||||
)
|
||||
|
||||
exposeInMainWorld(PROJECT_MANAGEMENT_API_KEY, {
|
||||
setOpenProjectHandler: (handler: (projectInfo: projectManagement.ProjectInfo) => void) => {
|
||||
openProjectHandler = handler
|
||||
},
|
||||
})
|
||||
|
||||
// ================
|
||||
// === Menu API ===
|
||||
@ -190,31 +160,27 @@ electron.ipcRenderer.on(ipc.Channel.showAboutModal, () => {
|
||||
showAboutModalHandler?.()
|
||||
})
|
||||
|
||||
const MENU_API = {
|
||||
exposeInMainWorld(MENU_API_KEY, {
|
||||
setShowAboutModalHandler: (callback: () => void) => {
|
||||
showAboutModalHandler = callback
|
||||
},
|
||||
}
|
||||
|
||||
electron.contextBridge.exposeInMainWorld(MENU_API_KEY, MENU_API)
|
||||
})
|
||||
|
||||
// ==================
|
||||
// === System API ===
|
||||
// ==================
|
||||
|
||||
const SYSTEM_API = {
|
||||
exposeInMainWorld(SYSTEM_API_KEY, {
|
||||
downloadURL: (url: string, headers?: Record<string, string>) => {
|
||||
electron.ipcRenderer.send(ipc.Channel.downloadURL, url, headers)
|
||||
},
|
||||
showItemInFolder: (fullPath: string) => {
|
||||
electron.ipcRenderer.send(ipc.Channel.showItemInFolder, fullPath)
|
||||
},
|
||||
}
|
||||
|
||||
electron.contextBridge.exposeInMainWorld(SYSTEM_API_KEY, SYSTEM_API)
|
||||
})
|
||||
|
||||
// ====================
|
||||
// === Version info ===
|
||||
// ====================
|
||||
|
||||
electron.contextBridge.exposeInMainWorld(VERSION_INFO_KEY, debug.VERSION_INFO)
|
||||
exposeInMainWorld(VERSION_INFO_KEY, debug.VERSION_INFO)
|
||||
|
@ -30,6 +30,17 @@ export const PROJECT_METADATA_RELATIVE_PATH = '.enso/project.json'
|
||||
/** The filename suffix for the project bundle, including the leading period character. */
|
||||
const BUNDLED_PROJECT_SUFFIX = '.enso-project'
|
||||
|
||||
// ===================
|
||||
// === ProjectInfo ===
|
||||
// ===================
|
||||
|
||||
/** Metadata for a newly imported project. */
|
||||
export interface ProjectInfo {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly parentDirectory: string
|
||||
}
|
||||
|
||||
// ======================
|
||||
// === Project Import ===
|
||||
// ======================
|
||||
@ -43,7 +54,7 @@ export function importProjectFromPath(
|
||||
openedPath: string,
|
||||
directory?: string | null,
|
||||
name: string | null = null
|
||||
): string {
|
||||
) {
|
||||
directory ??= getProjectsDirectory()
|
||||
if (pathModule.extname(openedPath).endsWith(BUNDLED_PROJECT_SUFFIX)) {
|
||||
logger.log(`Path '${openedPath}' denotes a bundled project.`)
|
||||
@ -77,7 +88,7 @@ export function importBundle(
|
||||
bundlePath: string,
|
||||
directory?: string | null,
|
||||
name: string | null = null
|
||||
): string {
|
||||
) {
|
||||
directory ??= getProjectsDirectory()
|
||||
logger.log(
|
||||
`Importing project '${bundlePath}' from bundle${name != null ? ` as '${name}'` : ''}.`
|
||||
@ -136,7 +147,7 @@ export async function uploadBundle(
|
||||
bundle: stream.Readable,
|
||||
directory?: string | null,
|
||||
name: string | null = null
|
||||
): Promise<string> {
|
||||
) {
|
||||
directory ??= getProjectsDirectory()
|
||||
logger.log(`Uploading project from bundle${name != null ? ` as '${name}'` : ''}.`)
|
||||
const targetPath = generateDirectoryName(name ?? 'Project', directory)
|
||||
@ -167,14 +178,14 @@ export function importDirectory(
|
||||
rootPath: string,
|
||||
directory?: string | null,
|
||||
name: string | null = null
|
||||
): string {
|
||||
): ProjectInfo {
|
||||
directory ??= getProjectsDirectory()
|
||||
if (isProjectInstalled(rootPath, directory)) {
|
||||
// Project is already visible to Project Manager, so we can just return its ID.
|
||||
logger.log(`Project already installed at '${rootPath}'.`)
|
||||
const id = getProjectId(rootPath)
|
||||
if (id != null) {
|
||||
return id
|
||||
return { id, name: getPackageName(rootPath) ?? '', parentDirectory: directory }
|
||||
} else {
|
||||
throw new Error(`Project already installed, but missing metadata.`)
|
||||
}
|
||||
@ -410,7 +421,7 @@ export function bumpMetadata(
|
||||
projectRoot: string,
|
||||
parentDirectory: string,
|
||||
name: string | null
|
||||
): string {
|
||||
): ProjectInfo {
|
||||
if (name == null) {
|
||||
const currentName = getPackageName(projectRoot) ?? ''
|
||||
let index: number | null = null
|
||||
@ -437,9 +448,10 @@ export function bumpMetadata(
|
||||
name = index == null ? currentName : `${currentName} (${index})`
|
||||
}
|
||||
updatePackageName(projectRoot, name)
|
||||
return updateMetadata(projectRoot, metadata => ({
|
||||
const id = updateMetadata(projectRoot, metadata => ({
|
||||
...metadata,
|
||||
id: generateId(),
|
||||
lastOpened: new Date().toISOString(),
|
||||
})).id
|
||||
return { id, name, parentDirectory }
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ export function registerAssociations() {
|
||||
* @param clientArgs - A list of arguments passed to the application, stripped from the initial
|
||||
* executable name and any electron dev mode arguments.
|
||||
* @returns The URL to open, or `null` if no file was specified. */
|
||||
export function argsDenoteUrlOpenAttempt(clientArgs: string[]): URL | null {
|
||||
export function argsDenoteUrlOpenAttempt(clientArgs: readonly string[]): URL | null {
|
||||
const arg = clientArgs[0]
|
||||
let result: URL | null = null
|
||||
logger.log(`Checking if '${clientArgs.toString()}' denotes a URL to open.`)
|
||||
@ -74,36 +74,15 @@ let initialUrl: URL | null = null
|
||||
* @param openedUrl - The URL to open. */
|
||||
export function handleOpenUrl(openedUrl: URL) {
|
||||
logger.log(`Opening URL '${openedUrl.toString()}'.`)
|
||||
const appLock = electron.app.requestSingleInstanceLock({ openedUrl })
|
||||
if (!appLock) {
|
||||
// If we failed to acquire the lock, it means that another instance of the application is
|
||||
// already running. In this case, we must send the URL to the existing instance and exit.
|
||||
logger.log('Another instance of the application is already running, exiting.')
|
||||
// Note that we need here to exit rather than quit. Otherwise, the application would
|
||||
// continue initializing and would create a new window, before quitting.
|
||||
// We don't want anything to flash on the screen, so we just exit.
|
||||
electron.app.exit(0)
|
||||
} else {
|
||||
// If we acquired the lock, it means that we are the first instance of the application.
|
||||
// In this case, we must wait for the application to be ready and then send the URL to the
|
||||
// renderer process.
|
||||
logger.log(
|
||||
'This is the first instance of the application, ' +
|
||||
'saving the URL to be opened when the first window is opened.'
|
||||
)
|
||||
// We must wait for the application to be ready and then send the URL to the renderer process.
|
||||
initialUrl = openedUrl
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the callback that will be called when the application is requested to open a URL.
|
||||
*
|
||||
* This method serves to unify the url handling between macOS and Windows. On macOS, the OS
|
||||
* handles the URL opening by passing the `open-url` event to the application. On Windows, a
|
||||
* new instance of the application is started and the URL is passed as a command line argument.
|
||||
*
|
||||
* This method registers the callback for both events. Note that on Windows it is necessary to
|
||||
* use {@link setAsUrlHandler} and {@link unsetAsUrlHandler} to ensure that the callback
|
||||
* is called.
|
||||
* @param callback - The callback to call when the application is requested to open a URL. */
|
||||
export function registerUrlCallback(callback: (url: URL) => void) {
|
||||
if (initialUrl != null) {
|
||||
@ -119,64 +98,19 @@ export function registerUrlCallback(callback: (url: URL) => void) {
|
||||
})
|
||||
|
||||
// Second, register the callback for the `second-instance` event. This is used on Windows.
|
||||
electron.app.on('second-instance', (event, argv) => {
|
||||
logger.log(`Got data from 'second-instance' event: '${argv.toString()}'.`)
|
||||
unsetAsUrlHandler()
|
||||
electron.app.on('second-instance', (event, _argv, _workingDir, additionalData) => {
|
||||
// Check if additional data is an object that contains the URL.
|
||||
const requestOneLastElementSlice = -1
|
||||
const lastArgumentSlice = argv.slice(requestOneLastElementSlice)
|
||||
const url = argsDenoteUrlOpenAttempt(lastArgumentSlice)
|
||||
const url =
|
||||
additionalData != null &&
|
||||
typeof additionalData === 'object' &&
|
||||
'urlToOpen' in additionalData &&
|
||||
additionalData.urlToOpen instanceof URL
|
||||
? additionalData.urlToOpen
|
||||
: null
|
||||
if (url) {
|
||||
logger.log(`Got URL from 'second-instance' event: '${url.toString()}'.`)
|
||||
// Even we received the URL, our Window likely is not in the foreground - the focus
|
||||
// went to the "second instance" of the application. We must bring our Window to the
|
||||
// foreground, so the user gets back to the IDE after the authentication.
|
||||
const primaryWindow = electron.BrowserWindow.getAllWindows()[0]
|
||||
if (primaryWindow) {
|
||||
if (primaryWindow.isMinimized()) {
|
||||
primaryWindow.restore()
|
||||
}
|
||||
primaryWindow.focus()
|
||||
} else {
|
||||
logger.error('No primary window found after receiving URL from second instance.')
|
||||
}
|
||||
logger.log(`Got URL from second instance: '${url.toString()}'.`)
|
||||
event.preventDefault()
|
||||
callback(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// === Temporary handler setup ===
|
||||
// ===============================
|
||||
|
||||
/** Make this application instance the recipient of URL callbacks.
|
||||
*
|
||||
* After the callback is received (or no longer expected), the `urlCallbackCompleted` function
|
||||
* must be called. Otherwise, other IDE instances will not be able to receive their URL
|
||||
* callbacks.
|
||||
*
|
||||
* The mechanism is built on top of the Electron's
|
||||
* [instance lock]{@link https://www.electronjs.org/docs/api/app#apprequestsingleinstancelock}
|
||||
* functionality.
|
||||
* @throws {Error} An error if another instance of the application has already acquired the lock. */
|
||||
export function setAsUrlHandler() {
|
||||
logger.log('Expecting URL callback, acquiring the lock.')
|
||||
if (!electron.app.requestSingleInstanceLock()) {
|
||||
const message = 'Another instance of the application is already running. Exiting.'
|
||||
logger.error(message)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop this application instance from receiving URL callbacks.
|
||||
*
|
||||
* This function releases the instance lock that was acquired by the {@link setAsUrlHandler}
|
||||
* function. This is necessary to ensure that other IDE instances can receive their
|
||||
* URL callbacks. */
|
||||
export function unsetAsUrlHandler() {
|
||||
logger.log('URL callback completed, releasing the lock.')
|
||||
electron.app.releaseSingleInstanceLock()
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ export function goToPageActions(
|
||||
drive: () =>
|
||||
step('Go to "Data Catalog" page', page =>
|
||||
page
|
||||
.getByRole('button')
|
||||
.getByRole('tab')
|
||||
.filter({ has: page.getByText('Data Catalog') })
|
||||
.click()
|
||||
).into(DrivePageActions),
|
||||
|
@ -2,7 +2,6 @@
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
import EditorPageActions from './actions/EditorPageActions'
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
@ -162,8 +161,7 @@ test.test('duplicate', ({ page }) =>
|
||||
.newEmptyProject()
|
||||
.goToPage.drive()
|
||||
.driveTable.rightClickRow(0)
|
||||
.contextMenu.duplicateProject()
|
||||
.goToPage.drive()
|
||||
.contextMenu.duplicate()
|
||||
.driveTable.withRows(async rows => {
|
||||
// Assets: [0: New Project 1 (copy), 1: New Project 1]
|
||||
await test.expect(rows).toHaveCount(2)
|
||||
@ -181,8 +179,6 @@ test.test('duplicate (keyboard)', ({ page }) =>
|
||||
.goToPage.drive()
|
||||
.driveTable.clickRow(0)
|
||||
.press('Mod+D')
|
||||
.into(EditorPageActions)
|
||||
.goToPage.drive()
|
||||
.driveTable.withRows(async rows => {
|
||||
// Assets: [0: New Project 1 (copy), 1: New Project 1]
|
||||
await test.expect(rows).toHaveCount(2)
|
||||
|
@ -47,7 +47,7 @@ export default function Link(props: LinkProps) {
|
||||
href: to,
|
||||
className,
|
||||
target: '_blank',
|
||||
onClick: () => {
|
||||
onPress: () => {
|
||||
toastify.toast.success(getText('openedLinkInBrowser'))
|
||||
},
|
||||
})}
|
||||
|
@ -171,9 +171,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
}, [isKeyboardSelected])
|
||||
|
||||
React.useImperativeHandle(updateAssetRef, () => newItem => {
|
||||
setAsset(newItem)
|
||||
})
|
||||
React.useImperativeHandle(updateAssetRef, () => setAsset)
|
||||
|
||||
const doCopyOnBackend = React.useCallback(
|
||||
async (newParentId: backendModule.DirectoryId | null) => {
|
||||
@ -197,7 +195,10 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
// This is SAFE, as the type of the copied asset is guaranteed to be the same
|
||||
// as the type of the original asset.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
object.merger(copiedAsset.asset as Partial<backendModule.AnyAsset>)
|
||||
object.merger({
|
||||
...copiedAsset.asset,
|
||||
state: { type: backendModule.ProjectState.new },
|
||||
} as Partial<backendModule.AnyAsset>)
|
||||
)
|
||||
} catch (error) {
|
||||
toastAndLog('copyAssetError', error, asset.title)
|
||||
|
@ -8,6 +8,7 @@ enum AssetListEventType {
|
||||
newDatalink = 'new-datalink',
|
||||
newSecret = 'new-secret',
|
||||
insertAssets = 'insert-assets',
|
||||
openProject = 'open-project',
|
||||
duplicateProject = 'duplicate-project',
|
||||
closeFolder = 'close-folder',
|
||||
copy = 'copy',
|
||||
|
@ -155,27 +155,27 @@ export interface AssetRemoveSelfEvent extends AssetBaseEvent<AssetEventType.remo
|
||||
readonly id: backend.AssetId
|
||||
}
|
||||
|
||||
/** A signal to temporarily add labels to the selected assetss. */
|
||||
/** A signal to temporarily add labels to the selected assets. */
|
||||
export interface AssetTemporarilyAddLabelsEvent
|
||||
extends AssetBaseEvent<AssetEventType.temporarilyAddLabels> {
|
||||
readonly ids: ReadonlySet<backend.AssetId>
|
||||
readonly labelNames: ReadonlySet<backend.LabelName>
|
||||
}
|
||||
|
||||
/** A signal to temporarily remove labels from the selected assetss. */
|
||||
/** A signal to temporarily remove labels from the selected assets. */
|
||||
export interface AssetTemporarilyRemoveLabelsEvent
|
||||
extends AssetBaseEvent<AssetEventType.temporarilyRemoveLabels> {
|
||||
readonly ids: ReadonlySet<backend.AssetId>
|
||||
readonly labelNames: ReadonlySet<backend.LabelName>
|
||||
}
|
||||
|
||||
/** A signal to add labels to the selected assetss. */
|
||||
/** A signal to add labels to the selected assets. */
|
||||
export interface AssetAddLabelsEvent extends AssetBaseEvent<AssetEventType.addLabels> {
|
||||
readonly ids: ReadonlySet<backend.AssetId>
|
||||
readonly labelNames: ReadonlySet<backend.LabelName>
|
||||
}
|
||||
|
||||
/** A signal to remove labels from the selected assetss. */
|
||||
/** A signal to remove labels from the selected assets. */
|
||||
export interface AssetRemoveLabelsEvent extends AssetBaseEvent<AssetEventType.removeLabels> {
|
||||
readonly ids: ReadonlySet<backend.AssetId>
|
||||
readonly labelNames: ReadonlySet<backend.LabelName>
|
||||
|
@ -20,6 +20,7 @@ interface AssetListEvents {
|
||||
readonly newSecret: AssetListNewSecretEvent
|
||||
readonly newDatalink: AssetListNewDatalinkEvent
|
||||
readonly insertAssets: AssetListInsertAssetsEvent
|
||||
readonly openProject: AssetListOpenProjectEvent
|
||||
readonly duplicateProject: AssetListDuplicateProjectEvent
|
||||
readonly closeFolder: AssetListCloseFolderEvent
|
||||
readonly copy: AssetListCopyEvent
|
||||
@ -86,6 +87,15 @@ interface AssetListInsertAssetsEvent extends AssetListBaseEvent<AssetListEventTy
|
||||
readonly assets: backend.AnyAsset[]
|
||||
}
|
||||
|
||||
/** A signal to open the specified project. */
|
||||
export interface AssetListOpenProjectEvent
|
||||
extends AssetListBaseEvent<AssetListEventType.openProject> {
|
||||
readonly id: backend.ProjectId
|
||||
readonly backendType: backend.BackendType
|
||||
readonly title: string
|
||||
readonly parentId: backend.DirectoryId
|
||||
}
|
||||
|
||||
/** A signal to duplicate a project. */
|
||||
interface AssetListDuplicateProjectEvent
|
||||
extends AssetListBaseEvent<AssetListEventType.duplicateProject> {
|
||||
|
@ -1,6 +1,7 @@
|
||||
/** @file Table displaying a list of projects. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import * as toast from 'react-toastify'
|
||||
|
||||
import DropFilesImage from 'enso-assets/drop_files.svg'
|
||||
@ -386,6 +387,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const { setSuggestions, initialProjectName } = props
|
||||
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
|
||||
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
const openedProjects = projectsProvider.useLaunchedProjects()
|
||||
const doOpenProject = projectHooks.useOpenProject()
|
||||
|
||||
@ -1722,6 +1724,22 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
insertArbitraryAssets(event.assets, event.parentKey, event.parentId)
|
||||
break
|
||||
}
|
||||
case AssetListEventType.openProject: {
|
||||
dispatchAssetEvent({ ...event, type: AssetEventType.openProject, runInBackground: false })
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
event.backendType,
|
||||
'listDirectory',
|
||||
{
|
||||
parentId: null,
|
||||
filterBy: backendModule.FilterBy.active,
|
||||
recentProjects: false,
|
||||
labels: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
break
|
||||
}
|
||||
case AssetListEventType.duplicateProject: {
|
||||
const siblings = nodeMapRef.current.get(event.parentKey)?.children ?? []
|
||||
const siblingTitles = new Set(siblings.map(sibling => sibling.item.title))
|
||||
|
@ -12,7 +12,9 @@ import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import StatelessSpinner, * as spinnerModule from '#/components/StatelessSpinner'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
@ -31,8 +33,7 @@ const TAB_RADIUS_PX = 24
|
||||
|
||||
/** Context for a {@link TabBarContext}. */
|
||||
interface TabBarContextValue {
|
||||
readonly updateClipPath: (element: HTMLDivElement | null) => void
|
||||
readonly observeElement: (element: HTMLElement) => () => void
|
||||
readonly setSelectedTab: (element: HTMLElement) => void
|
||||
}
|
||||
|
||||
const TabBarContext = React.createContext<TabBarContextValue | null>(null)
|
||||
@ -55,22 +56,23 @@ export interface TabBarProps extends Readonly<React.PropsWithChildren> {}
|
||||
export default function TabBar(props: TabBarProps) {
|
||||
const { children } = props
|
||||
const cleanupResizeObserverRef = React.useRef(() => {})
|
||||
const backgroundRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const selectedTabRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const backgroundRef = React.useRef<HTMLDivElement | null>()
|
||||
const selectedTabRef = React.useRef<HTMLElement | null>(null)
|
||||
const [resizeObserver] = React.useState(
|
||||
() =>
|
||||
new ResizeObserver(() => {
|
||||
updateClipPath(selectedTabRef.current)
|
||||
})
|
||||
)
|
||||
|
||||
const [updateClipPath] = React.useState(() => {
|
||||
return (element: HTMLDivElement | null) => {
|
||||
return (element: HTMLElement | null) => {
|
||||
const backgroundElement = backgroundRef.current
|
||||
if (backgroundElement != null) {
|
||||
selectedTabRef.current = element
|
||||
if (element == null) {
|
||||
backgroundElement.style.clipPath = ''
|
||||
} else {
|
||||
selectedTabRef.current = element
|
||||
const bounds = element.getBoundingClientRect()
|
||||
const rootBounds = backgroundElement.getBoundingClientRect()
|
||||
const tabLeft = bounds.left - rootBounds.left
|
||||
@ -96,9 +98,24 @@ export default function TabBar(props: TabBarProps) {
|
||||
}
|
||||
})
|
||||
|
||||
const setSelectedTab = React.useCallback(
|
||||
(element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
updateClipPath(element)
|
||||
resizeObserver.observe(element)
|
||||
return () => {
|
||||
resizeObserver.unobserve(element)
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
},
|
||||
[resizeObserver, updateClipPath]
|
||||
)
|
||||
|
||||
const updateResizeObserver = (element: HTMLElement | null) => {
|
||||
cleanupResizeObserverRef.current()
|
||||
if (element == null) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
cleanupResizeObserverRef.current = () => {}
|
||||
} else {
|
||||
resizeObserver.observe(element)
|
||||
@ -109,19 +126,18 @@ export default function TabBar(props: TabBarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<TabBarContext.Provider
|
||||
value={{
|
||||
updateClipPath,
|
||||
observeElement: element => {
|
||||
resizeObserver.observe(element)
|
||||
|
||||
return () => {
|
||||
resizeObserver.unobserve(element)
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="relative flex grow">
|
||||
<TabBarContext.Provider value={{ setSelectedTab }}>
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<aria.TabList
|
||||
className="flex h-12 shrink-0 grow cursor-default items-center rounded-full"
|
||||
{...innerProps}
|
||||
>
|
||||
<aria.Tab>
|
||||
{/* Putting the background in a `Tab` is a hack, but it is required otherwise there
|
||||
* are issues with the ref to the background being detached, resulting in the clip
|
||||
* path cutout for the current tab not applying at all. */}
|
||||
<div
|
||||
ref={element => {
|
||||
backgroundRef.current = element
|
||||
@ -129,38 +145,16 @@ export default function TabBar(props: TabBarProps) {
|
||||
}}
|
||||
className="pointer-events-none absolute inset-0 bg-primary/5"
|
||||
/>
|
||||
<Tabs>{children}</Tabs>
|
||||
</div>
|
||||
</TabBarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============
|
||||
// === Tabs ===
|
||||
// ============
|
||||
|
||||
/** Props for a {@link TabsInternal}. */
|
||||
export interface InternalTabsProps extends Readonly<React.PropsWithChildren> {}
|
||||
|
||||
/** A tab list in a {@link TabBar}. */
|
||||
function TabsInternal(props: InternalTabsProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
const { children } = props
|
||||
return (
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<div
|
||||
className="flex h-12 shrink-0 grow cursor-default items-center rounded-full"
|
||||
{...aria.mergeProps<React.JSX.IntrinsicElements['div']>()(innerProps, { ref })}
|
||||
>
|
||||
</aria.Tab>
|
||||
{children}
|
||||
</div>
|
||||
</aria.TabList>
|
||||
)}
|
||||
</FocusArea>
|
||||
</TabBarContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Tabs = React.forwardRef(TabsInternal)
|
||||
|
||||
// ===========
|
||||
// === Tab ===
|
||||
// ===========
|
||||
@ -168,36 +162,31 @@ const Tabs = React.forwardRef(TabsInternal)
|
||||
/** Props for a {@link Tab}. */
|
||||
interface InternalTabProps extends Readonly<React.PropsWithChildren> {
|
||||
readonly 'data-testid'?: string
|
||||
readonly id: string
|
||||
readonly project?: projectHooks.Project
|
||||
readonly isActive: boolean
|
||||
readonly isHidden?: boolean
|
||||
readonly icon: string
|
||||
readonly labelId: text.TextId
|
||||
readonly onPress: () => void
|
||||
readonly onClose?: () => void
|
||||
readonly onLoadEnd?: () => void
|
||||
}
|
||||
|
||||
/** A tab in a {@link TabBar}. */
|
||||
export function Tab(props: InternalTabProps) {
|
||||
const { isActive, icon, labelId, children, onPress, onClose, project, onLoadEnd } = props
|
||||
const { updateClipPath, observeElement } = useTabBarContext()
|
||||
const { id, project, isActive, isHidden = false, icon, labelId, children, onClose } = props
|
||||
const { onLoadEnd } = props
|
||||
const { setSelectedTab } = useTabBarContext()
|
||||
const ref = React.useRef<HTMLDivElement | null>(null)
|
||||
const isLoadingRef = React.useRef(true)
|
||||
const { getText } = textProvider.useText()
|
||||
const actuallyActive = isActive && !isHidden
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isActive) {
|
||||
updateClipPath(ref.current)
|
||||
if (actuallyActive && ref.current) {
|
||||
setSelectedTab(ref.current)
|
||||
}
|
||||
}, [isActive, updateClipPath])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ref.current) {
|
||||
return observeElement(ref.current)
|
||||
} else {
|
||||
return () => {}
|
||||
}
|
||||
}, [observeElement])
|
||||
}, [actuallyActive, id, setSelectedTab])
|
||||
|
||||
const { isLoading, data } = reactQuery.useQuery<backend.Project>(
|
||||
project?.id
|
||||
@ -216,38 +205,42 @@ export function Tab(props: InternalTabProps) {
|
||||
}, [isFetching, onLoadEnd])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
<aria.Tab
|
||||
data-testid={props['data-testid']}
|
||||
ref={element => {
|
||||
ref.current = element
|
||||
if (actuallyActive && element) {
|
||||
setSelectedTab(element)
|
||||
}
|
||||
}}
|
||||
id={id}
|
||||
isDisabled={isActive}
|
||||
aria-label={getText(labelId)}
|
||||
className={tailwindMerge.twMerge(
|
||||
'group relative flex h-full items-center gap-3',
|
||||
!isActive && 'hover:enabled:bg-frame'
|
||||
'relative flex h-full items-center gap-3 rounded-t-2xl px-4',
|
||||
!isActive &&
|
||||
'cursor-pointer opacity-50 hover:bg-frame hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-30 [&.disabled]:cursor-not-allowed [&.disabled]:opacity-30',
|
||||
isHidden && 'hidden'
|
||||
)}
|
||||
>
|
||||
<ariaComponents.Button
|
||||
data-testid={props['data-testid']}
|
||||
size="custom"
|
||||
variant="custom"
|
||||
loaderPosition="icon"
|
||||
icon={icon}
|
||||
isDisabled={false}
|
||||
isActive={isActive}
|
||||
loading={isActive ? false : isFetching}
|
||||
aria-label={getText(labelId)}
|
||||
className={tailwindMerge.twMerge('h-full', onClose ? 'pl-4' : 'px-4')}
|
||||
contentClassName="gap-3"
|
||||
tooltip={false}
|
||||
onPress={onPress}
|
||||
>
|
||||
<ariaComponents.Text truncate="1" className="max-w-32">
|
||||
{isLoading ? (
|
||||
<StatelessSpinner
|
||||
state={spinnerModule.SpinnerState.loadingMedium}
|
||||
size={16}
|
||||
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
|
||||
/>
|
||||
) : (
|
||||
<SvgMask
|
||||
src={icon}
|
||||
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</ariaComponents.Text>
|
||||
</ariaComponents.Button>
|
||||
|
||||
{onClose && (
|
||||
<div className="flex pr-4">
|
||||
<div className="flex">
|
||||
<ariaComponents.CloseButton onPress={onClose} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aria.Tab>
|
||||
)
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// =================
|
||||
@ -95,6 +96,9 @@ export function TermsOfServiceModal() {
|
||||
)
|
||||
|
||||
if (shouldDisplay) {
|
||||
// Note that this produces warnings about missing a `<Heading slot="title">`, even though
|
||||
// all `ariaComponents.Dialog`s contain one. This is likely caused by Suspense discarding
|
||||
// renders, and so it does not seem to be fixable.
|
||||
return (
|
||||
<>
|
||||
<ariaComponents.Dialog
|
||||
@ -129,7 +133,7 @@ export function TermsOfServiceModal() {
|
||||
)}
|
||||
id={checkboxId}
|
||||
data-testid="terms-of-service-checkbox"
|
||||
{...register('agree')}
|
||||
{...object.omit(register('agree'), 'isInvalid')}
|
||||
/>
|
||||
|
||||
<label htmlFor={checkboxId}>
|
||||
|
@ -38,6 +38,7 @@ import * as tabBar from '#/layouts/TabBar'
|
||||
import TabBar from '#/layouts/TabBar'
|
||||
import UserBar from '#/layouts/UserBar'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import Page from '#/components/Page'
|
||||
|
||||
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
||||
@ -107,13 +108,14 @@ function DashboardInner(props: DashboardProps) {
|
||||
? localBackendModule.newProjectId(projectManager.UUID(initialProjectNameRaw))
|
||||
: null
|
||||
const initialProjectName = initialLocalProjectId ?? initialProjectNameRaw
|
||||
const isUserEnabled = user.isEnabled
|
||||
|
||||
const defaultCategory = initialLocalProjectId == null ? Category.cloud : Category.local
|
||||
|
||||
const [category, setCategory] = searchParamsState.useSearchParamsState(
|
||||
'driveCategory',
|
||||
() => defaultCategory,
|
||||
() => {
|
||||
const shouldDefaultToCloud =
|
||||
initialLocalProjectId == null && (user.isEnabled || localBackend == null)
|
||||
return shouldDefaultToCloud ? Category.cloud : Category.local
|
||||
},
|
||||
(value): value is Category => {
|
||||
if (array.includes(Object.values(Category), value)) {
|
||||
return categoryModule.isLocal(value) ? localBackend != null : true
|
||||
@ -122,19 +124,11 @@ function DashboardInner(props: DashboardProps) {
|
||||
}
|
||||
}
|
||||
)
|
||||
const isCloud = categoryModule.isCloud(category)
|
||||
|
||||
const page = projectsProvider.usePage()
|
||||
const launchedProjects = projectsProvider.useLaunchedProjects()
|
||||
const selectedProject = launchedProjects.find(p => p.id === page) ?? null
|
||||
|
||||
if (isCloud && !isUserEnabled && localBackend != null) {
|
||||
setTimeout(() => {
|
||||
// This sets `BrowserRouter`, so it must not be set synchronously.
|
||||
setCategory(Category.local)
|
||||
})
|
||||
}
|
||||
|
||||
const setPage = projectsProvider.useSetPage()
|
||||
const openEditor = projectHooks.useOpenEditor()
|
||||
const openProject = projectHooks.useOpenProject()
|
||||
@ -168,6 +162,22 @@ function DashboardInner(props: DashboardProps) {
|
||||
}
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
window.projectManagementApi?.setOpenProjectHandler(project => {
|
||||
setCategory(Category.local)
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.openProject,
|
||||
backendType: backendModule.BackendType.local,
|
||||
id: localBackendModule.newProjectId(projectManager.UUID(project.id)),
|
||||
title: project.name,
|
||||
parentId: localBackendModule.newDirectoryId(backendModule.Path(project.parentDirectory)),
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
window.projectManagementApi?.setOpenProjectHandler(() => {})
|
||||
}
|
||||
}, [dispatchAssetListEvent, setCategory])
|
||||
|
||||
React.useEffect(
|
||||
() =>
|
||||
inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
||||
@ -246,23 +256,30 @@ function DashboardInner(props: DashboardProps) {
|
||||
|
||||
return (
|
||||
<Page hideInfoBar hideChat>
|
||||
<div className="flex text-xs text-primary">
|
||||
<div
|
||||
className="relative flex h-screen grow select-none flex-col container-size"
|
||||
className="flex text-xs text-primary"
|
||||
onContextMenu={event => {
|
||||
event.preventDefault()
|
||||
unsetModal()
|
||||
}}
|
||||
>
|
||||
<aria.Tabs
|
||||
className="relative flex h-screen grow select-none flex-col container-size"
|
||||
selectedKey={page}
|
||||
onSelectionChange={newPage => {
|
||||
const validated = projectsProvider.PAGES_SCHEMA.safeParse(newPage)
|
||||
if (validated.success) {
|
||||
setPage(validated.data)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex">
|
||||
<TabBar>
|
||||
<tabBar.Tab
|
||||
id={projectsProvider.TabType.drive}
|
||||
isActive={page === projectsProvider.TabType.drive}
|
||||
icon={DriveIcon}
|
||||
labelId="drivePageName"
|
||||
onPress={() => {
|
||||
setPage(projectsProvider.TabType.drive)
|
||||
}}
|
||||
>
|
||||
{getText('drivePageName')}
|
||||
</tabBar.Tab>
|
||||
@ -270,14 +287,12 @@ function DashboardInner(props: DashboardProps) {
|
||||
{launchedProjects.map(project => (
|
||||
<tabBar.Tab
|
||||
data-testid="editor-tab-button"
|
||||
id={project.id}
|
||||
project={project}
|
||||
key={project.id}
|
||||
isActive={page === project.id}
|
||||
icon={EditorIcon}
|
||||
labelId="editorPageName"
|
||||
onPress={() => {
|
||||
setPage(project.id)
|
||||
}}
|
||||
onClose={() => {
|
||||
closeProject(project)
|
||||
}}
|
||||
@ -289,21 +304,18 @@ function DashboardInner(props: DashboardProps) {
|
||||
</tabBar.Tab>
|
||||
))}
|
||||
|
||||
{page === projectsProvider.TabType.settings && (
|
||||
<tabBar.Tab
|
||||
isActive
|
||||
id={projectsProvider.TabType.settings}
|
||||
isHidden={page !== projectsProvider.TabType.settings}
|
||||
icon={SettingsIcon}
|
||||
labelId="settingsPageName"
|
||||
onPress={() => {
|
||||
setPage(projectsProvider.TabType.settings)
|
||||
}}
|
||||
onClose={() => {
|
||||
setPage(projectsProvider.TabType.drive)
|
||||
}}
|
||||
>
|
||||
{getText('settingsPageName')}
|
||||
</tabBar.Tab>
|
||||
)}
|
||||
</TabBar>
|
||||
|
||||
<UserBar
|
||||
@ -315,7 +327,11 @@ function DashboardInner(props: DashboardProps) {
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aria.TabPanel
|
||||
shouldForceMount
|
||||
id={projectsProvider.TabType.drive}
|
||||
className="flex grow [&[data-inert]]:hidden"
|
||||
>
|
||||
<Drive
|
||||
assetsManagementApiRef={assetManagementApiRef}
|
||||
category={category}
|
||||
@ -323,8 +339,14 @@ function DashboardInner(props: DashboardProps) {
|
||||
hidden={page !== projectsProvider.TabType.drive}
|
||||
initialProjectName={initialProjectName}
|
||||
/>
|
||||
|
||||
{launchedProjects.map(project => (
|
||||
</aria.TabPanel>
|
||||
{appRunner != null &&
|
||||
launchedProjects.map(project => (
|
||||
<aria.TabPanel
|
||||
shouldForceMount
|
||||
id={project.id}
|
||||
className="flex grow [&[data-inert]]:hidden"
|
||||
>
|
||||
<Editor
|
||||
key={project.id}
|
||||
hidden={page !== project.id}
|
||||
@ -340,9 +362,12 @@ function DashboardInner(props: DashboardProps) {
|
||||
renameProjectMutation.mutate({ newName, project })
|
||||
}}
|
||||
/>
|
||||
</aria.TabPanel>
|
||||
))}
|
||||
|
||||
{page === projectsProvider.TabType.settings && <Settings />}
|
||||
<aria.TabPanel id={projectsProvider.TabType.settings} className="flex grow">
|
||||
<Settings />
|
||||
</aria.TabPanel>
|
||||
</aria.Tabs>
|
||||
{process.env.ENSO_CLOUD_CHAT_URL != null ? (
|
||||
<Chat
|
||||
isOpen={isHelpChatOpen}
|
||||
@ -360,7 +385,6 @@ function DashboardInner(props: DashboardProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ declare module '#/utilities/LocalStorage' {
|
||||
}
|
||||
}
|
||||
|
||||
const PAGES_SCHEMA = z
|
||||
export const PAGES_SCHEMA = z
|
||||
.nativeEnum(TabType)
|
||||
.or(z.custom<projectHooks.ProjectId>(value => typeof value === 'string'))
|
||||
|
||||
|
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
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === File Browser API ===
|
||||
// ========================
|
||||
|
||||
/** `window.fileBrowserApi` exposes functionality related to the system's default file picker. */
|
||||
interface FileBrowserApi {
|
||||
readonly openFileBrowser: (
|
||||
kind: 'any' | 'directory' | 'file' | 'filePath',
|
||||
defaultPath?: string
|
||||
) => Promise<unknown>
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// === Project Management API ===
|
||||
// ==============================
|
||||
|
||||
/** Metadata for a newly imported project. */
|
||||
interface ProjectInfo {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly parentDirectory: string
|
||||
}
|
||||
|
||||
/** `window.projectManagementApi` exposes functionality related to system events related to
|
||||
* project management. */
|
||||
interface ProjectManagementApi {
|
||||
readonly setOpenProjectHandler: (handler: (projectInfo: ProjectInfo) => void) => void
|
||||
}
|
||||
|
||||
// ====================
|
||||
// === Version Info ===
|
||||
// ====================
|
||||
@ -120,6 +149,8 @@ declare global {
|
||||
readonly navigationApi: NavigationApi
|
||||
readonly menuApi: MenuApi
|
||||
readonly systemApi?: SystemApi
|
||||
readonly fileBrowserApi?: FileBrowserApi
|
||||
readonly projectManagementApi?: ProjectManagementApi
|
||||
readonly versionInfo?: VersionInfo
|
||||
toggleDevtools: () => void
|
||||
}
|
||||
|
@ -2738,7 +2738,9 @@ lazy val launcher = project
|
||||
.in(file("engine/launcher"))
|
||||
.configs(Test)
|
||||
.settings(
|
||||
frgaalJavaCompilerSetting,
|
||||
resolvers += Resolver.bintrayRepo("gn0s1s", "releases"),
|
||||
commands += WithDebugCommand.withDebug,
|
||||
libraryDependencies ++= Seq(
|
||||
"com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion,
|
||||
"org.apache.commons" % "commons-compress" % commonsCompressVersion,
|
||||
@ -2751,7 +2753,7 @@ lazy val launcher = project
|
||||
NativeImage.additionalCp := Seq.empty,
|
||||
rebuildNativeImage := NativeImage
|
||||
.buildNativeImage(
|
||||
"enso",
|
||||
"ensoup",
|
||||
staticOnLinux = true,
|
||||
additionalOptions = Seq(
|
||||
"-Dorg.apache.commons.logging.Log=org.apache.commons.logging.impl.NoOpLog",
|
||||
@ -2766,7 +2768,7 @@ lazy val launcher = project
|
||||
buildNativeImage := NativeImage
|
||||
.incrementalNativeImageBuild(
|
||||
rebuildNativeImage,
|
||||
"enso"
|
||||
"ensoup"
|
||||
)
|
||||
.value,
|
||||
assembly / test := {},
|
||||
|
@ -71,8 +71,7 @@ type Encoding
|
||||
default -> Encoding =
|
||||
# This factory method is used to publicly expose the `Default` constructor.
|
||||
# The constructor itself has to be private, because we want to make `Value` constructor private, but all constructors must have the same privacy.
|
||||
# ToDo: This is a workaround for performance issue.
|
||||
Encoding.utf_8
|
||||
Encoding.Default
|
||||
|
||||
## PRIVATE
|
||||
A default encoding that will try to guess the encoding based on some heuristics.
|
||||
|
@ -121,7 +121,7 @@ type File
|
||||
temp.delete_if_exists
|
||||
|
||||
## Attach a warning to the file that it is a dry run
|
||||
warning = Dry_Run_Operation.Warning "Only a dry run has occurred, with data written to a temporary file. Press the Run Workflow button ▶ to write the actual file."
|
||||
warning = Dry_Run_Operation.Warning "Only a dry run has occurred, with data written to a temporary file. Press the Write button ▶ to write the actual file."
|
||||
Warning.attach warning temp
|
||||
|
||||
## ALIAS current directory
|
||||
|
@ -51,7 +51,7 @@ create_table_implementation connection table_name structure primary_key temporar
|
||||
internal_create_table_structure connection effective_table_name structure primary_key effective_temporary on_problems
|
||||
if dry_run.not then connection.query (SQL_Query.Table_Name created_table_name) else
|
||||
created_table = connection.base_connection.internal_allocate_dry_run_table created_table_name
|
||||
warning = Dry_Run_Operation.Warning "Only a dry run of `create_table` has occurred, creating a temporary table ("+created_table_name.pretty+"). Run Workflow button ▶ to create the actual one."
|
||||
warning = Dry_Run_Operation.Warning "Only a dry run of `create_table` has occurred, creating a temporary table ("+created_table_name.pretty+"). Press the Write button ▶ to create the actual one."
|
||||
Warning.attach warning created_table
|
||||
|
||||
## PRIVATE
|
||||
@ -118,7 +118,7 @@ select_into_table_implementation source_table connection table_name primary_key
|
||||
connection.drop_table tmp_table_name if_exists=True
|
||||
internal_upload_table source_table connection tmp_table_name primary_key temporary=True on_problems=on_problems row_limit=dry_run_row_limit
|
||||
temporary_table = connection.base_connection.internal_allocate_dry_run_table table.name
|
||||
warning = Dry_Run_Operation.Warning "Only a dry run of `select_into_database_table` was performed - a temporary table ("+tmp_table_name+") was created, containing a sample of the data. Run Workflow button ▶ to write to the actual table."
|
||||
warning = Dry_Run_Operation.Warning "Only a dry run of `select_into_database_table` was performed - a temporary table ("+tmp_table_name+") was created, containing a sample of the data. Press the Write button ▶ to write to the actual table."
|
||||
Warning.attach warning temporary_table
|
||||
|
||||
## PRIVATE
|
||||
@ -345,7 +345,7 @@ common_update_table (source_table : DB_Table | Table) (target_table : DB_Table)
|
||||
above fails, the whole transaction will be rolled back.
|
||||
connection.drop_table tmp_table.name
|
||||
if dry_run.not then resulting_table else
|
||||
warning = Dry_Run_Operation.Warning "Only a dry run of `update_rows` was performed - the target table has been returned unchanged. Press the Run Workflow button ▶ to update the actual table."
|
||||
warning = Dry_Run_Operation.Warning "Only a dry run of `update_rows` was performed - the target table has been returned unchanged. Press the Write button ▶ to update the actual table."
|
||||
Warning.attach warning resulting_table
|
||||
|
||||
## PRIVATE
|
||||
@ -551,7 +551,7 @@ common_delete_rows target_table key_values_to_delete key_columns allow_duplicate
|
||||
source.drop_temporary_table connection
|
||||
if dry_run.not then affected_row_count else
|
||||
suffix = source.dry_run_message_suffix
|
||||
warning = Dry_Run_Operation.Warning "Only a dry run of `delete_rows` was performed - the target table has not been changed. Press the Run Workflow button ▶ to update the actual table."+suffix
|
||||
warning = Dry_Run_Operation.Warning "Only a dry run of `delete_rows` was performed - the target table has not been changed. Press the Write button ▶ to update the actual table."+suffix
|
||||
Warning.attach warning affected_row_count
|
||||
|
||||
## PRIVATE
|
||||
|
@ -175,7 +175,7 @@ In order to build and run Enso you will need the following tools:
|
||||
should be installed by default on most distributions.
|
||||
- On Windows, the `run` command must be run in the latest version of
|
||||
`Powershell` or in `cmd`.
|
||||
- If you want to be able to build the Launcher Native Image, you will need a
|
||||
- If you want to be able to build the `ensoup` Native Image, you will need a
|
||||
native C compiler for your platform as described in the
|
||||
[Native Image Prerequisites](https://www.graalvm.org/reference-manual/native-image/#prerequisites).
|
||||
On Linux that will be `gcc`, on macOS you may need `xcode` and on Windows you
|
||||
@ -294,17 +294,9 @@ shell will execute the appropriate thing. Furthermore we have `testOnly` and
|
||||
`benchOnly` that accept a glob pattern that delineates some subset of the tests
|
||||
or benchmarks to run (e.g. `testOnly *FunctionArguments*`).
|
||||
|
||||
#### Building the Launcher Native Binary
|
||||
#### Building the Updater Native Binary
|
||||
|
||||
If you want to build the native launcher binary, you need to ensure that the
|
||||
Native Image component is installed in your GraalVM distribution. To install it,
|
||||
run:
|
||||
|
||||
```bash
|
||||
<path-to-graal-home>/bin/gu install native-image
|
||||
```
|
||||
|
||||
Then, you can build the launcher using:
|
||||
Then, you can build the updater/launcher using:
|
||||
|
||||
```bash
|
||||
sbt launcher/buildNativeImage
|
||||
|
@ -15,7 +15,7 @@ any additional dependencies.
|
||||
<!-- MarkdownTOC levels="2,3" autolink="true" -->
|
||||
|
||||
- [Project Manager Bundle](#project-manager-bundle)
|
||||
- [Launcher Bundles](#launcher-bundles)
|
||||
- [`ensoup` Bundles](#ensoup-bundles)
|
||||
|
||||
<!-- /MarkdownTOC -->
|
||||
|
||||
@ -59,12 +59,12 @@ the case for example if the Project Manager bundle is packaged as part of IDE's
|
||||
AppImage package). In such situation, it will be impossible to uninstall the
|
||||
bundled components and a relevant error message will be returned.
|
||||
|
||||
## Launcher Bundles
|
||||
## `ensoup` Bundles
|
||||
|
||||
Bundles are also distributed for the launcher, but these are implemented using a
|
||||
different mechanism.
|
||||
Bundles are also distributed for the `ensoup` updater, but these are implemented
|
||||
using a different mechanism.
|
||||
|
||||
Since the launcher can run in
|
||||
Since the `ensoup` can run in
|
||||
[portable mode](distribution.md#portable-enso-distribution-layout), the bundled
|
||||
engine and runtime are simply included within its portable package. They can
|
||||
then be used from within this portable package or
|
||||
|
@ -32,12 +32,14 @@ run, or use the default version if none specified. It should also be able to
|
||||
launch other Enso components, provided as
|
||||
[plugins](./launcher.md#running-plugins).
|
||||
|
||||
<!--
|
||||
> This launcher is under development. Until it is in a ready-to-use state, the
|
||||
> Enso version packages provide simple launcher scripts in the `bin` directory
|
||||
> of that package. They are a temporary replacement for the launcher
|
||||
> functionality, so once the universal launcher matures, they will be removed.
|
||||
> The universal launcher will not call the components through these scripts, as
|
||||
> it must have full control over which JVM is chosen and its parameters.
|
||||
-->
|
||||
|
||||
## Enso Distribution Layout
|
||||
|
||||
@ -55,7 +57,7 @@ The directory structure is as follows:
|
||||
```
|
||||
extraction-location
|
||||
├── bin
|
||||
│ └── enso # The universal launcher, responsible for choosing the appropriate compiler version.
|
||||
│ └── ensoup # The universal launcher, responsible for choosing the appropriate compiler version.
|
||||
├── config
|
||||
│ └── global-config.yaml # Global user configuration.
|
||||
├── dist # Per-compiler-version distribution directories.
|
||||
@ -112,7 +114,7 @@ ENSO_CONFIG_DIRECTORY
|
||||
└── global-config.yaml # Global user configuration.
|
||||
|
||||
ENSO_BIN_DIRECTORY
|
||||
└── enso # The universal launcher, responsible for choosing the appropriate compiler version.
|
||||
└── ensoup # The universal launcher, responsible for choosing the appropriate compiler version.
|
||||
```
|
||||
|
||||
Where `ENSO_DATA_DIRECTORY`, `ENSO_CONFIG_DIRECTORY` and `ENSO_BIN_DIRECTORY`
|
||||
|
@ -6,7 +6,7 @@ tags: [distribution, launcher]
|
||||
order: 4
|
||||
---
|
||||
|
||||
# Enso Launcher
|
||||
# Enso Updater/Launcher
|
||||
|
||||
The launcher is used to run Enso commands (like the REPL, language server etc.)
|
||||
and seamlessly manage Enso versions. This document describes it's features. Its
|
||||
|
@ -14,10 +14,10 @@ Each release has an "Assets" section at the bottom. You can click on this to
|
||||
view the list of artifacts from which you can download the most appropriate
|
||||
version.
|
||||
|
||||
These assets contain bundles that include the Enso launcher, an engine version,
|
||||
and GraalVM, allowing you to get up and running immediately. Alternatively, you
|
||||
can download just the launcher, which will handle downloading and installing the
|
||||
required components for you.
|
||||
These assets contain bundles that include the `ensoup` updater, an engine
|
||||
version, and GraalVM, allowing you to get up and running immediately.
|
||||
Alternatively, you can download just the updater, which will handle downloading
|
||||
and installing the required components for you.
|
||||
|
||||
<!-- MarkdownTOC levels="2,3" autolink="true" -->
|
||||
|
||||
|
@ -14,7 +14,7 @@ up as follows:
|
||||
|
||||
- [**sbt:**](sbt.md) The build tools that are used for building the project.
|
||||
- [**Native Image:**](native-image.md) Description of the Native Image build
|
||||
used for building the launcher native binary.
|
||||
used for building the `ensoup` native binary.
|
||||
- [**Rust:**](rust.md) Description of integration of the Scala project with the
|
||||
Rust components.
|
||||
- [**Upgrading GraalVM:**](upgrading-graalvm.md) Description of steps that have
|
||||
|
@ -140,8 +140,8 @@ environmental variable but it depends on which component we are executing.
|
||||
server that collects logs (as defined in `logging-service.server` config key)
|
||||
and the logs output can be overwritten by `ENSO_LOGSERVER_APPENDER` env
|
||||
variable
|
||||
- `launcher` or `runner` - the default log output can be overwritten by defining
|
||||
the `ENSO_APPENDER_DEFAULT` env variable
|
||||
- `ensoup` or `enso` - the default log output can be overwritten by defining the
|
||||
`ENSO_APPENDER_DEFAULT` env variable
|
||||
|
||||
For example, for the project manager to output to `console` one simply executes
|
||||
|
||||
|
@ -21,7 +21,7 @@ Native Image is used for building the Launcher.
|
||||
- [Static Builds](#static-builds)
|
||||
- [No Cross-Compilation](#no-cross-compilation)
|
||||
- [Configuration](#configuration)
|
||||
- [Launcher Configuration](#launcher-configuration)
|
||||
- [`ensoup` Configuration](#ensoup-configuration)
|
||||
- [Project Manager Configuration](#project-manager-configuration)
|
||||
|
||||
<!-- /MarkdownTOC -->
|
||||
@ -63,7 +63,7 @@ replaced with absolute paths to the bundle location.
|
||||
The task is parametrized with `staticOnLinux` parameter which if set to `true`,
|
||||
will statically link the built binary, to ensure portability between Linux
|
||||
distributions. For Windows and MacOS, the binaries should generally be portable,
|
||||
as described in [Launcher Portability](../distribution/launcher.md#portability).
|
||||
as described in [`ensoup` Portability](../distribution/launcher.md#portability).
|
||||
|
||||
## No Cross-Compilation
|
||||
|
||||
@ -105,7 +105,7 @@ java \
|
||||
<application arguments>
|
||||
```
|
||||
|
||||
For example, to update settings for the Launcher:
|
||||
For example, to update settings for the Launcher project:
|
||||
|
||||
```bash
|
||||
java -agentlib:native-image-agent=config-merge-dir=engine/launcher/src/main/resources/META-INF/native-image/org/enso/launcher -jar launcher.jar <arguments>
|
||||
@ -139,7 +139,7 @@ After updating the Native Image configuration, make sure to clean it by running
|
||||
cd tools/native-image-config-cleanup && npm install && npm start
|
||||
```
|
||||
|
||||
### Launcher Configuration
|
||||
### `ensoup` Configuration
|
||||
|
||||
In case of the launcher, to gather the relevant reflective accesses one wants to
|
||||
test as many execution paths as possible, especially the ones that are likely to
|
||||
|
@ -18,13 +18,6 @@ take the following actions to be able to continue development after the upgrade:
|
||||
updating, removing `engine/runtime/build-cache` directory may help).
|
||||
3. Do a full clean (it may not _always_ be required, but not doing it often
|
||||
leads to problems so it is much safer to do it) by running `enso/clean`.
|
||||
4. To be able to build or run tests for the `launcher` project, Native Image for
|
||||
the new GraalVM version has to be installed, as it is not included by
|
||||
default. This can be done with
|
||||
`<path-to-graal-home>/bin/gu install native-image`.
|
||||
- If there are problems building the Native Image, removing
|
||||
`engine/launcher/build-cache` (which contains the downloaded `musl`
|
||||
package) may help.
|
||||
|
||||
## Upgrading the Build
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
package org.enso.launcher;
|
@ -4,6 +4,9 @@ import org.enso.semver.SemVer
|
||||
|
||||
object Constants {
|
||||
|
||||
/** Base name of the launcher executable */
|
||||
val name = "ensoup"
|
||||
|
||||
/** The engine version in which the uploads command has been introduced.
|
||||
*
|
||||
* It is used to check by the launcher if the engine can handle this command
|
||||
|
@ -729,9 +729,9 @@ object LauncherApplication {
|
||||
|
||||
val application: Application[Config] =
|
||||
Application(
|
||||
"enso",
|
||||
"ensoup",
|
||||
"Enso",
|
||||
"Enso Launcher",
|
||||
"Enso Updater",
|
||||
topLevelOpts,
|
||||
commands,
|
||||
PluginManager
|
||||
|
@ -21,6 +21,7 @@ import org.enso.launcher.distribution.{DefaultManagers, LauncherResourceManager}
|
||||
|
||||
import java.nio.file.{Files, Path}
|
||||
import scala.util.control.NonFatal
|
||||
import org.enso.launcher.Constants
|
||||
|
||||
/** Allows to [[uninstall]] an installed distribution.
|
||||
*
|
||||
@ -318,7 +319,8 @@ class DistributionUninstaller(
|
||||
private def partiallyUninstallExecutableWindows(): Path = {
|
||||
val currentPath = manager.env.getPathToRunningExecutable
|
||||
|
||||
val newPath = currentPath.getParent.resolve(OS.executableName("enso.old"))
|
||||
val newPath =
|
||||
currentPath.getParent.resolve(OS.executableName(Constants.name + ".old"))
|
||||
Files.move(currentPath, newPath)
|
||||
newPath
|
||||
}
|
||||
@ -338,7 +340,9 @@ class DistributionUninstaller(
|
||||
parentToRemove: Option[Path]
|
||||
): Nothing = {
|
||||
val temporaryLauncher =
|
||||
Files.createTempDirectory("enso-uninstall") / OS.executableName("enso")
|
||||
Files.createTempDirectory("enso-uninstall") / OS.executableName(
|
||||
Constants.name
|
||||
)
|
||||
val oldLauncher = myNewPath
|
||||
Files.copy(oldLauncher, temporaryLauncher)
|
||||
InternalOpts
|
||||
|
@ -255,7 +255,7 @@ class LauncherUpgrader(
|
||||
.iterateArchive(archivePath) { entry =>
|
||||
if (
|
||||
entry.relativePath.endsWith(
|
||||
Path.of("bin") / OS.executableName("enso")
|
||||
Path.of("bin") / OS.executableName(org.enso.launcher.Constants.name)
|
||||
)
|
||||
) {
|
||||
entryFound = true
|
||||
|
@ -0,0 +1 @@
|
||||
package org.enso.launcher;
|
@ -113,7 +113,7 @@ trait NativeTest
|
||||
* functionality.
|
||||
*/
|
||||
def baseLauncherLocation: Path =
|
||||
rootDirectory.resolve(OS.executableName("enso"))
|
||||
rootDirectory.resolve(OS.executableName(Constants.name))
|
||||
|
||||
/** Creates a copy of the tested launcher binary at the specified location.
|
||||
*
|
||||
|
@ -11,7 +11,7 @@ import org.enso.testkit.WithTemporaryDirectory
|
||||
class InstallerSpec extends NativeTest with WithTemporaryDirectory {
|
||||
def portableRoot = getTestDirectory / "portable"
|
||||
def portableLauncher =
|
||||
portableRoot / "bin" / OS.executableName("enso")
|
||||
portableRoot / "bin" / OS.executableName(Constants.name)
|
||||
|
||||
def preparePortableDistribution(): Unit = {
|
||||
copyLauncherTo(portableLauncher)
|
||||
@ -66,9 +66,13 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
|
||||
env
|
||||
)
|
||||
|
||||
(installedRoot / "bin" / OS.executableName("enso")).toFile should exist
|
||||
(installedRoot / "bin" / OS.executableName(
|
||||
Constants.name
|
||||
)).toFile should exist
|
||||
assert(
|
||||
Files.isExecutable(installedRoot / "bin" / OS.executableName("enso")),
|
||||
Files.isExecutable(
|
||||
installedRoot / "bin" / OS.executableName(Constants.name)
|
||||
),
|
||||
"The installed file should be executable."
|
||||
)
|
||||
|
||||
@ -97,7 +101,9 @@ class InstallerSpec extends NativeTest with WithTemporaryDirectory {
|
||||
env
|
||||
)
|
||||
|
||||
(installedRoot / "bin" / OS.executableName("enso")).toFile should exist
|
||||
(installedRoot / "bin" / OS.executableName(
|
||||
Constants.name
|
||||
)).toFile should exist
|
||||
portableLauncher.toFile should exist
|
||||
}
|
||||
|
||||
|
@ -33,7 +33,8 @@ class UninstallerSpec extends NativeTest with WithTemporaryDirectory {
|
||||
val dataDirectory = installedRoot
|
||||
val runDirectory = installedRoot
|
||||
val logDirectory = installedRoot / "log"
|
||||
val portableLauncher = binDirectory / OS.executableName("enso")
|
||||
val portableLauncher =
|
||||
binDirectory / OS.executableName(org.enso.launcher.Constants.name)
|
||||
copyLauncherTo(portableLauncher)
|
||||
Files.createDirectories(dataDirectory / "dist")
|
||||
Files.createDirectories(configDirectory)
|
||||
|
@ -36,7 +36,11 @@ class UpgradeSpec
|
||||
/** Location of the actual launcher executable that is wrapped by the shims.
|
||||
*/
|
||||
private val realLauncherLocation =
|
||||
Path.of(".").resolve(OS.executableName("enso")).toAbsolutePath.normalize
|
||||
Path
|
||||
.of(".")
|
||||
.resolve(OS.executableName(Constants.name))
|
||||
.toAbsolutePath
|
||||
.normalize
|
||||
|
||||
/** Path to a launcher shim that pretends to be `version`.
|
||||
*/
|
||||
@ -57,7 +61,7 @@ class UpgradeSpec
|
||||
Files.createDirectories(destinationDirectory)
|
||||
Files.copy(
|
||||
builtLauncherBinary(version),
|
||||
destinationDirectory / OS.executableName("enso"),
|
||||
destinationDirectory / OS.executableName(Constants.name),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
}
|
||||
@ -103,7 +107,7 @@ class UpgradeSpec
|
||||
/** Path to the launcher executable in the temporary distribution.
|
||||
*/
|
||||
private def launcherPath =
|
||||
getTestDirectory / "enso" / "bin" / OS.executableName("enso")
|
||||
getTestDirectory / "enso" / "bin" / OS.executableName(Constants.name)
|
||||
|
||||
/** Runs `enso version` to inspect the version reported by the launcher.
|
||||
* @return the reported version
|
||||
@ -274,7 +278,7 @@ class UpgradeSpec
|
||||
.listDirectory(binDirectory)
|
||||
.map(_.getFileName.toString)
|
||||
.filter(_.startsWith("enso"))
|
||||
leftOverExecutables shouldEqual Seq(OS.executableName("enso"))
|
||||
leftOverExecutables shouldEqual Seq(OS.executableName(Constants.name))
|
||||
}
|
||||
} finally {
|
||||
if (process.isAlive) {
|
||||
|
@ -103,9 +103,9 @@ public final class EnsoFile implements EnsoObject {
|
||||
return ArrayLikeHelpers.wrapStrings(MEMBERS);
|
||||
}
|
||||
|
||||
@TruffleBoundary
|
||||
@ExportMessage
|
||||
Object invokeMember(
|
||||
static Object invokeMember(
|
||||
EnsoOutputStream os,
|
||||
String name,
|
||||
Object[] args,
|
||||
@Cached ArrayLikeLengthNode lengthNode,
|
||||
@ -130,20 +130,28 @@ public final class EnsoFile implements EnsoObject {
|
||||
throw ArityException.create(1, 3, args.length);
|
||||
}
|
||||
}
|
||||
var buf = new byte[8192];
|
||||
var at = 0;
|
||||
for (long i = from; i < to; i++) {
|
||||
var elem = atNode.executeAt(args[0], i);
|
||||
var byt = iop.asInt(elem);
|
||||
os.write(byt);
|
||||
buf[at++] = iop.asByte(elem);
|
||||
if (at == buf.length) {
|
||||
os.write(buf, 0, buf.length);
|
||||
at = 0;
|
||||
}
|
||||
yield this;
|
||||
}
|
||||
if (at > 0) {
|
||||
os.write(buf, 0, at);
|
||||
}
|
||||
yield os;
|
||||
}
|
||||
case "flush" -> {
|
||||
os.flush();
|
||||
yield this;
|
||||
yield os;
|
||||
}
|
||||
case "close" -> {
|
||||
os.close();
|
||||
yield this;
|
||||
yield os;
|
||||
}
|
||||
default -> throw UnknownIdentifierException.create(name);
|
||||
};
|
||||
@ -155,6 +163,21 @@ public final class EnsoFile implements EnsoObject {
|
||||
}
|
||||
}
|
||||
|
||||
@TruffleBoundary
|
||||
final void write(byte[] buf, int offset, int length) throws IOException {
|
||||
os.write(buf, offset, length);
|
||||
}
|
||||
|
||||
@TruffleBoundary
|
||||
final void flush() throws IOException {
|
||||
os.flush();
|
||||
}
|
||||
|
||||
@TruffleBoundary
|
||||
final void close() throws IOException {
|
||||
os.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "EnsoOutputStream";
|
||||
|
@ -411,7 +411,7 @@ class DistributionManager(val env: Environment) {
|
||||
def irCacheDirectory: Path = this.cacheDirectory / "ir"
|
||||
|
||||
private def executableName: String =
|
||||
OS.executableName("enso")
|
||||
OS.executableName("ensoup")
|
||||
|
||||
/** The path where the binary executable of the installed distribution
|
||||
* should be placed by default.
|
||||
|
@ -11,6 +11,7 @@ import org.enso.projectmanager.control.core.syntax._
|
||||
import org.enso.projectmanager.control.effect.syntax._
|
||||
import org.enso.projectmanager.infrastructure.repository.ProjectRepositoryFactory
|
||||
import org.enso.projectmanager.service.ProjectService
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
import java.io.{File, InputStream}
|
||||
import java.nio.file.Files
|
||||
@ -21,13 +22,16 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
|
||||
projectRepositoryFactory: ProjectRepositoryFactory[F]
|
||||
) extends FileSystemServiceApi[F] {
|
||||
|
||||
private lazy val logger = LoggerFactory.getLogger(this.getClass)
|
||||
|
||||
/** @inheritdoc */
|
||||
override def exists(path: File): F[FileSystemServiceFailure, Boolean] =
|
||||
fileSystem
|
||||
.exists(path)
|
||||
.mapError(_ =>
|
||||
.mapError { error =>
|
||||
logger.warn("Failed to check if path exists", error)
|
||||
FileSystemServiceFailure.FileSystem("Failed to check if path exists")
|
||||
)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
override def list(
|
||||
@ -35,9 +39,10 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
|
||||
): F[FileSystemServiceFailure, Seq[FileSystemEntry]] =
|
||||
fileSystem
|
||||
.list(path)
|
||||
.mapError(_ =>
|
||||
.mapError { error =>
|
||||
logger.warn("Failed to list directories", error)
|
||||
FileSystemServiceFailure.FileSystem("Failed to list directories")
|
||||
)
|
||||
}
|
||||
.flatMap { files =>
|
||||
Traverse[List].traverse(files)(toFileSystemEntry).map(_.flatten)
|
||||
}
|
||||
@ -46,29 +51,37 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
|
||||
override def createDirectory(path: File): F[FileSystemServiceFailure, Unit] =
|
||||
fileSystem
|
||||
.createDir(path)
|
||||
.mapError(_ =>
|
||||
.mapError { error =>
|
||||
logger.warn("Failed to create directory", error)
|
||||
FileSystemServiceFailure.FileSystem("Failed to create directory")
|
||||
)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
override def delete(path: File): F[FileSystemServiceFailure, Unit] =
|
||||
fileSystem
|
||||
.remove(path)
|
||||
.mapError(_ =>
|
||||
.mapError { error =>
|
||||
logger.warn("Failed to delete path", error)
|
||||
FileSystemServiceFailure.FileSystem("Failed to delete path")
|
||||
)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
override def move(from: File, to: File): F[FileSystemServiceFailure, Unit] =
|
||||
fileSystem
|
||||
.move(from, to)
|
||||
.mapError(_ => FileSystemServiceFailure.FileSystem("Failed to move path"))
|
||||
.mapError { error =>
|
||||
logger.warn("Failed to list directories", error)
|
||||
FileSystemServiceFailure.FileSystem("Failed to move path")
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
override def copy(from: File, to: File): F[FileSystemServiceFailure, Unit] =
|
||||
fileSystem
|
||||
.copy(from, to)
|
||||
.mapError(_ => FileSystemServiceFailure.FileSystem("Failed to copy path"))
|
||||
.mapError { error =>
|
||||
logger.warn("Failed to copy path", error)
|
||||
FileSystemServiceFailure.FileSystem("Failed to copy path")
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
override def write(
|
||||
@ -77,9 +90,10 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel](
|
||||
): F[FileSystemServiceFailure, Unit] =
|
||||
fileSystem
|
||||
.writeFile(path, contents)
|
||||
.mapError(_ =>
|
||||
.mapError { error =>
|
||||
logger.warn("Failed to write path", error)
|
||||
FileSystemServiceFailure.FileSystem("Failed to write path")
|
||||
)
|
||||
}
|
||||
|
||||
private def toFileSystemEntry(
|
||||
path: File
|
||||
|
@ -414,7 +414,7 @@ object DistributionPackage {
|
||||
)
|
||||
|
||||
copyFilesIncremental(
|
||||
Seq(file(executableName("enso"))),
|
||||
Seq(file(executableName("ensoup"))),
|
||||
distributionRoot / "bin",
|
||||
cacheFactory.make("launcher-exe")
|
||||
)
|
||||
|
@ -211,7 +211,7 @@ add_specs suite_builder =
|
||||
|
||||
(Ordering.hash x0) . should_equal (Ordering.hash x1)
|
||||
|
||||
group_builder.specify "should compare correctly to Integer and Float" <|
|
||||
group_builder.specify "should compare correctly to Integer and Float" pending="https://github.com/enso-org/enso/issues/10163" <|
|
||||
values = []
|
||||
+ [[0.1, 0.1]]
|
||||
+ [["0.1", 0.1]]
|
||||
|
@ -117,7 +117,7 @@ add_specs suite_builder =
|
||||
'{"type":"Date_Time","constructor":"new","year":2023,"month":9,"day":29,"hour":11,"second":52}'.should_parse_as (JS_Object.from_pairs [["type", "Date_Time"], ["constructor", "new"], ["year", 2023], ["month", 9], ["day", 29], ["hour", 11], ["second", 52]])
|
||||
'{"type":"Date_Time","constructor":"new","year":2023,"month":9,"day":29,"hour":11,"minute":52,"nanosecond":572104300}'.should_parse_as (JS_Object.from_pairs [["type", "Date_Time"], ["constructor", "new"], ["year", 2023], ["month", 9], ["day", 29], ["hour", 11], ["minute", 52], ["nanosecond", 572104300]])
|
||||
|
||||
group_builder.specify "should be able to read a JSON file with a BOM indicating UTF-16 encoding" pending="Encoding.default turned off temporarily" <|
|
||||
group_builder.specify "should be able to read a JSON file with a BOM indicating UTF-16 encoding" <|
|
||||
utf_16_le_bom = [-1, -2]
|
||||
bytes = utf_16_le_bom + ("{}".bytes Encoding.utf_16_le)
|
||||
f = File.create_temporary_file "json-with-bom" ".json"
|
||||
|
@ -68,7 +68,7 @@ add_specs suite_builder =
|
||||
default_warning.should_equal invalid_ascii_out
|
||||
Problems.get_attached_warnings default_warning . should_contain_the_same_elements_as problems
|
||||
|
||||
suite_builder.group "Default Encoding" pending="Encoding.default turned off temporarily" group_builder->
|
||||
suite_builder.group "Default Encoding" group_builder->
|
||||
group_builder.specify "should try reading as UTF-8 by default" <|
|
||||
bytes = [65, -60, -123, -60, -103]
|
||||
# A ą ę
|
||||
|
@ -6,12 +6,25 @@ import Standard.Examples
|
||||
|
||||
options = Bench.options . set_warmup (Bench.phase_conf 2 5) . set_measure (Bench.phase_conf 2 5)
|
||||
|
||||
type Lazy_Data
|
||||
private Write ~table:Table
|
||||
|
||||
collect_benches = Bench.build builder->
|
||||
assert Examples.csv_2500_rows.exists "Expecting the file to exist at "+Examples.csv_2500_rows.path
|
||||
|
||||
write_data = Lazy_Data.Write (Examples.csv_2500_rows . read)
|
||||
|
||||
builder.group ("Read_csv_file") options group_builder->
|
||||
group_builder.specify "data_csv" <|
|
||||
table = Examples.csv_2500_rows . read
|
||||
assert (table.row_count == 2500) "Expecting two and half thousand rows, but got "+table.row_count.to_text
|
||||
|
||||
builder.group ("Write_csv_file") options group_builder->
|
||||
group_builder.specify "data_csv" <|
|
||||
file = File.create_temporary_file "data_csv"
|
||||
Panic.with_finalizer file.delete <|
|
||||
assert (file.size == 0) "File "+file.to_text+" shall be empty, size: "+file.size.to_text
|
||||
write_data.table . write file (..Delimited delimiter="," headers=False)
|
||||
assert (file.size > 111111) "File "+file.to_text+" exists now, size: "+file.size.to_text
|
||||
|
||||
main = collect_benches . run_main
|
||||
|
@ -475,7 +475,7 @@ add_specs suite_builder =
|
||||
Delimited_Format.Delimited ',' . with_line_endings Line_Ending_Style.Unix . should_equal (Delimited_Format.Delimited ',' line_endings=Line_Ending_Style.Unix)
|
||||
|
||||
utf_16_le_bom = [-1, -2]
|
||||
group_builder.specify "(in default mode) should detect UTF-16 encoding if BOM is present" pending="Encoding.default turned off temporarily" <|
|
||||
group_builder.specify "(in default mode) should detect UTF-16 encoding if BOM is present" <|
|
||||
bytes = utf_16_le_bom + ('a,b\n1,2'.bytes Encoding.utf_16_le)
|
||||
f = File.create_temporary_file "delimited-utf-16-bom" ".csv"
|
||||
bytes.write_bytes f . should_succeed
|
||||
@ -485,7 +485,7 @@ add_specs suite_builder =
|
||||
# No hidden BOM in the column name
|
||||
table.column_names.first.utf_8 . should_equal [97]
|
||||
|
||||
group_builder.specify "(in default mode) should skip UTF-8 BOM if it was present" pending="Encoding.default turned off temporarily" <|
|
||||
group_builder.specify "(in default mode) should skip UTF-8 BOM if it was present" <|
|
||||
utf_8_bom = [-17, -69, -65]
|
||||
bytes = utf_8_bom + ('a,b\n1,2'.bytes Encoding.utf_8)
|
||||
f = File.create_temporary_file "delimited-utf-8-bom" ".csv"
|
||||
@ -506,9 +506,10 @@ add_specs suite_builder =
|
||||
# The first column name now contains this invalid character, because it wasn't a BOM
|
||||
r.column_names.first . should_equal "a"
|
||||
|
||||
group_builder.specify "if UTF-16 encoding was selected but an inverted BOM is detected, a warning is issued (pt 2)" pending="Encoding.default turned off temporarily" <|
|
||||
group_builder.specify "if UTF-16 encoding was selected but an inverted BOM is detected, a warning is issued (pt 2)" <|
|
||||
bytes = utf_16_le_bom + ('a,b\n1,2'.bytes Encoding.utf_16_be)
|
||||
f = File.create_temporary_file "delimited-utf-16-inverted-bom" ".csv"
|
||||
bytes.write_bytes f . should_succeed
|
||||
|
||||
# If we read without specifying the encoding, we will infer UTF-16 LE encoding because of the BOM and get garbage:
|
||||
r2 = f.read
|
||||
@ -527,7 +528,7 @@ add_specs suite_builder =
|
||||
r.first_column.to_vector . should_equal ['\uFFFD']
|
||||
Problems.expect_only_warning Encoding_Error r
|
||||
|
||||
group_builder.specify "should fall back to Windows-1252 encoding if invalid UTF-8 characters are encountered in Default encoding" pending="Encoding.default turned off temporarily" <|
|
||||
group_builder.specify "should fall back to Windows-1252 encoding if invalid UTF-8 characters are encountered in Default encoding" <|
|
||||
f = File.create_temporary_file "delimited-invalid-utf-8" ".csv"
|
||||
# On the simple characters all three encodings (ASCII, UTF-8 and Win-1252) agree, so we can use ASCII bytes.
|
||||
bytes = ('A,B\n1,y'.bytes Encoding.ascii) + [-1] + ('z\n2,-'.bytes Encoding.ascii)
|
||||
|
@ -569,7 +569,7 @@ add_specs suite_builder =
|
||||
|
||||
## If the Delimited config has Encoding.default, the encoding for read will be determined by BOM and Win-1252 fallback heuristics.
|
||||
The same encoding should be used for writing, to ensure that when the resulting file is read, all content is correctly decoded.
|
||||
group_builder.specify "should use the same effective encoding for writing as the one that would be used for reading" pending="Encoding.default turned off temporarily" <|
|
||||
group_builder.specify "should use the same effective encoding for writing as the one that would be used for reading" <|
|
||||
f = File.create_temporary_file "append-detect" ".csv"
|
||||
Test.with_clue "UTF-16 detected by BOM: " <|
|
||||
bom = [-1, -2]
|
||||
|
Loading…
Reference in New Issue
Block a user