From e7668ebc3ae39a489149bf0d0a51e45b7ef7b427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Wawrzyniec=20Urba=C5=84czyk?= Date: Thu, 6 Apr 2023 15:26:37 +0200 Subject: [PATCH] Project Sharing (#6077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enso will now associate with two file extensions: * `.enso` — Enso source file. * If the source file belongs to a project under the Project Manager-managed directory, it will be opened. * If the source file belongs to a project located elsewhere, it will be imported into the PM-managed directory and opened; * Otherwise, opening the `.enseo` file will fail. (e.g., loose source file without any project) * `.enso-project` — Enso project bundle, i.e., `tar.gz` archive containing a compressed Enso project directory. * it will be imported under the PM-managed directory; a unique directory name shall be generated if needed. ### Important Notes On Windows, the NSIS installer is expected to handle the file associations. On macOS, the file associations are expected to be set up after the first time Enso is started, On Linux, the file associations are not supported yet. --- CHANGELOG.md | 3 + app/gui/src/config.rs | 74 ++++- app/gui/src/controller/ide/desktop.rs | 12 +- app/gui/src/ide/initializer.rs | 41 ++- app/gui/src/presenter/graph.rs | 4 +- app/gui/src/tests.rs | 5 +- app/ide-desktop/eslint.config.js | 2 +- .../lib/client/electron-builder-config.ts | 10 +- .../lib/client/file-associations.ts | 13 + app/ide-desktop/lib/client/package.json | 2 + .../lib/client/src/config/parser.ts | 9 +- .../lib/client/src/file-associations.ts | 151 +++++++++ app/ide-desktop/lib/client/src/index.ts | 54 +++- app/ide-desktop/lib/client/src/paths.ts | 3 + .../lib/client/src/project-management.ts | 294 ++++++++++++++++++ .../src/authentication/service.tsx | 2 +- app/ide-desktop/lib/types/globals.d.ts | 2 +- app/ide-desktop/package-lock.json | 181 ++++++++++- app/ide-desktop/utils.ts | 24 ++ build/build/src/ide/web.rs | 2 +- .../src/display/shape/compound/rectangle.rs | 7 +- lib/rust/ensogl/core/src/gui/component.rs | 1 - .../examples/instance-ordering/src/lib.rs | 2 +- 23 files changed, 832 insertions(+), 66 deletions(-) create mode 100644 app/ide-desktop/lib/client/file-associations.ts create mode 100644 app/ide-desktop/lib/client/src/file-associations.ts create mode 100644 app/ide-desktop/lib/client/src/project-management.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 19841a69b264..023553713040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,8 @@ eliminating the need for fully qualified names. - [Added tooltips to icon buttons][6035] for improved usability. Users can now quickly understand each button's function. +- [File associations are created on Windows and macOS][6077]. This allows + opening Enso files by double-clicking them in the file explorer. #### EnsoGL (rendering engine) @@ -570,6 +572,7 @@ [6153]: https://github.com/enso-org/enso/pull/6153 [6156]: https://github.com/enso-org/enso/pull/6156 [6204]: https://github.com/enso-org/enso/pull/6204 +[6077]: https://github.com/enso-org/enso/pull/6077 #### Enso Compiler diff --git a/app/gui/src/config.rs b/app/gui/src/config.rs index 5bd5cd4c5426..b3bfaccda853 100644 --- a/app/gui/src/config.rs +++ b/app/gui/src/config.rs @@ -4,9 +4,11 @@ use crate::prelude::*; use crate::constants; +use engine_protocol::project_manager::ProjectMetadata; use engine_protocol::project_manager::ProjectName; use enso_config::Args; use enso_config::ARGS; +use failure::ResultExt; @@ -110,26 +112,27 @@ impl BackendService { #[derive(Clone, Debug, Default)] pub struct Startup { /// The configuration of connection to the backend service. - pub backend: BackendService, + pub backend: BackendService, /// The project name we want to open on startup. - pub project_name: Option, + pub project_to_open: Option, /// Whether to open directly to the project view, skipping the welcome screen. - pub initial_view: InitialView, + pub initial_view: InitialView, /// Identifies the element to create the IDE's DOM nodes as children of. - pub dom_parent_id: Option, + pub dom_parent_id: Option, } impl Startup { /// Read configuration from the web arguments. See also [`web::Arguments`] documentation. pub fn from_web_arguments() -> FallibleResult { let backend = BackendService::from_web_arguments(&ARGS)?; - let project_name = ARGS.groups.startup.options.project.value.as_str(); - let no_project_name = project_name.is_empty(); + let project = ARGS.groups.startup.options.project.value.as_str(); + let no_project: bool = project.is_empty(); let initial_view = - if no_project_name { InitialView::WelcomeScreen } else { InitialView::Project }; - let project_name = (!no_project_name).as_some_from(|| project_name.to_owned().into()); + if no_project { InitialView::WelcomeScreen } else { InitialView::Project }; + let project_to_open = + (!no_project).as_some_from(|| ProjectToOpen::from_str(project)).transpose()?; let dom_parent_id = None; - Ok(Startup { backend, project_name, initial_view, dom_parent_id }) + Ok(Startup { backend, project_to_open, initial_view, dom_parent_id }) } /// Identifies the element to create the IDE's DOM nodes as children of. @@ -155,3 +158,56 @@ pub enum InitialView { /// Start to the Project View. Project, } + + +// === ProjectToOpen === + +/// The project to open on startup. +/// +/// We both support opening a project by name and by ID. This is because: +/// * names are more human-readable, but they are not guaranteed to be unique; +/// * IDs are guaranteed to be unique, but they are not human-readable. +#[derive(Clone, Debug)] +pub enum ProjectToOpen { + /// Open the project with the given name. + Name(ProjectName), + /// Open the project with the given ID. + Id(Uuid), +} + +impl ProjectToOpen { + /// Check if provided project metadata matches the requested project. + pub fn matches(&self, project_metadata: &ProjectMetadata) -> bool { + match self { + ProjectToOpen::Name(name) => name == &project_metadata.name, + ProjectToOpen::Id(id) => id == &project_metadata.id, + } + } +} + +impl FromStr for ProjectToOpen { + type Err = failure::Error; + + fn from_str(s: &str) -> Result { + // While in theory it is possible that Uuid string representation is a valid project name, + // in practice it is very unlikely. Additionally, Uuid representation used by us is + // hyphenated, which will never be the case for project name. This, we can use this as a + // heuristic to determine whether the provided string is a project name or a project ID. + if s.contains('-') { + let id = Uuid::from_str(s) + .context(format!("Failed to parse project ID from string: '{s}'."))?; + Ok(ProjectToOpen::Id(id)) + } else { + Ok(ProjectToOpen::Name(ProjectName::new_unchecked(s))) + } + } +} + +impl Display for ProjectToOpen { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ProjectToOpen::Name(name) => write!(f, "{name}"), + ProjectToOpen::Id(id) => write!(f, "{id}"), + } + } +} diff --git a/app/gui/src/controller/ide/desktop.rs b/app/gui/src/controller/ide/desktop.rs index 6f9cb48ec6a3..c84598e88a7f 100644 --- a/app/gui/src/controller/ide/desktop.rs +++ b/app/gui/src/controller/ide/desktop.rs @@ -4,6 +4,7 @@ use crate::prelude::*; +use crate::config::ProjectToOpen; use crate::controller::ide::ManagingProjectAPI; use crate::controller::ide::Notification; use crate::controller::ide::StatusNotificationPublisher; @@ -53,10 +54,11 @@ impl Handle { /// Screen. pub async fn new( project_manager: Rc, - project_name: Option, + project_to_open: Option, ) -> FallibleResult { - let project = match project_name { - Some(name) => Some(Self::init_project_model(project_manager.clone_ref(), name).await?), + let project = match project_to_open { + Some(project_to_open) => + Some(Self::init_project_model(project_manager.clone_ref(), project_to_open).await?), None => None, }; Ok(Self::new_with_project_model(project_manager, project)) @@ -86,12 +88,12 @@ impl Handle { /// Open project with provided name. async fn init_project_model( project_manager: Rc, - project_name: ProjectName, + project_to_open: ProjectToOpen, ) -> FallibleResult { // TODO[ao]: Reuse of initializer used in previous code design. It should be soon replaced // anyway, because we will soon resign from the "open or create" approach when opening // IDE. See https://github.com/enso-org/ide/issues/1492 for details. - let initializer = initializer::WithProjectManager::new(project_manager, project_name); + let initializer = initializer::WithProjectManager::new(project_manager, project_to_open); let model = initializer.initialize_project_model().await?; Ok(model) } diff --git a/app/gui/src/ide/initializer.rs b/app/gui/src/ide/initializer.rs index 9ed4c45e8d49..9251c4d3d8aa 100644 --- a/app/gui/src/ide/initializer.rs +++ b/app/gui/src/ide/initializer.rs @@ -3,6 +3,7 @@ use crate::prelude::*; use crate::config; +use crate::config::ProjectToOpen; use crate::ide::Ide; use crate::transport::web::WebSocket; use crate::FailedIde; @@ -40,9 +41,9 @@ const INITIALIZATION_RETRY_TIMES: &[Duration] = /// Error raised when project with given name was not found. #[derive(Clone, Debug, Fail)] -#[fail(display = "Project with the name {} was not found.", name)] +#[fail(display = "Project '{}' was not found.", name)] pub struct ProjectNotFound { - name: ProjectName, + name: ProjectToOpen, } @@ -129,8 +130,8 @@ impl Initializer { match &self.config.backend { ProjectManager { endpoint } => { let project_manager = self.setup_project_manager(endpoint).await?; - let project_name = self.config.project_name.clone(); - let controller = controller::ide::Desktop::new(project_manager, project_name); + let project_to_open = self.config.project_to_open.clone(); + let controller = controller::ide::Desktop::new(project_manager, project_to_open); Ok(Rc::new(controller.await?)) } LanguageServer { json_endpoint, binary_endpoint, namespace, project_name } => { @@ -186,13 +187,16 @@ impl Initializer { pub struct WithProjectManager { #[derivative(Debug = "ignore")] pub project_manager: Rc, - pub project_name: ProjectName, + pub project_to_open: ProjectToOpen, } impl WithProjectManager { /// Constructor. - pub fn new(project_manager: Rc, project_name: ProjectName) -> Self { - Self { project_manager, project_name } + pub fn new( + project_manager: Rc, + project_to_open: ProjectToOpen, + ) -> Self { + Self { project_manager, project_to_open } } /// Create and initialize a new Project Model, for a project with name passed in constructor. @@ -205,13 +209,12 @@ impl WithProjectManager { } /// Creates a new project and returns its id, so the newly connected project can be opened. - pub async fn create_project(&self) -> FallibleResult { + pub async fn create_project(&self, project_name: &ProjectName) -> FallibleResult { use project_manager::MissingComponentAction::Install; - info!("Creating a new project named '{}'.", self.project_name); + info!("Creating a new project named '{}'.", project_name); let version = &enso_config::ARGS.groups.engine.options.preferred_version.value; let version = (!version.is_empty()).as_some_from(|| version.clone()); - let name = &self.project_name; - let response = self.project_manager.create_project(name, &None, &version, &Install); + let response = self.project_manager.create_project(project_name, &None, &version, &Install); Ok(response.await?.project_id) } @@ -219,9 +222,9 @@ impl WithProjectManager { let response = self.project_manager.list_projects(&None).await?; let mut projects = response.projects.iter(); projects - .find(|project_metadata| project_metadata.name == self.project_name) + .find(|project_metadata| self.project_to_open.matches(project_metadata)) .map(|md| md.id) - .ok_or_else(|| ProjectNotFound { name: self.project_name.clone() }.into()) + .ok_or_else(|| ProjectNotFound { name: self.project_to_open.clone() }.into()) } /// Look for the project with the name specified when constructing this initializer, @@ -230,9 +233,14 @@ impl WithProjectManager { let project = self.lookup_project().await; if let Ok(project_id) = project { Ok(project_id) + } else if let ProjectToOpen::Name(name) = &self.project_to_open { + info!("Attempting to create {}", name); + self.create_project(name).await } else { - info!("Attempting to create {}", self.project_name); - self.create_project().await + // This can happen only if we are told to open project by id but it cannot be found. + // We cannot fallback to creating a new project in this case, as we cannot create a + // project with a given id. Thus, we simply propagate the lookup result. + project } } } @@ -305,7 +313,8 @@ mod test { expect_call!(mock_client.list_projects(count) => Ok(project_lists)); let project_manager = Rc::new(mock_client); - let initializer = WithProjectManager { project_manager, project_name }; + let project_to_open = ProjectToOpen::Name(project_name); + let initializer = WithProjectManager { project_manager, project_to_open }; let project = initializer.get_project_or_create_new().await; assert_eq!(expected_id, project.expect("Couldn't get project.")) } diff --git a/app/gui/src/presenter/graph.rs b/app/gui/src/presenter/graph.rs index a897fffbce86..ef483952fb26 100644 --- a/app/gui/src/presenter/graph.rs +++ b/app/gui/src/presenter/graph.rs @@ -2,8 +2,6 @@ //! about presenters in general. use crate::prelude::*; -use double_representation::context_switch::Context; -use double_representation::context_switch::ContextSwitch; use enso_web::traits::*; use crate::controller::graph::widget::Request as WidgetRequest; @@ -11,6 +9,8 @@ use crate::controller::upload::NodeFromDroppedFileHandler; use crate::executor::global::spawn_stream_handler; use crate::presenter::graph::state::State; +use double_representation::context_switch::Context; +use double_representation::context_switch::ContextSwitch; use double_representation::context_switch::ContextSwitchExpression; use engine_protocol::language_server::SuggestionId; use enso_frp as frp; diff --git a/app/gui/src/tests.rs b/app/gui/src/tests.rs index 8dcf7ab9193d..31f4de972528 100644 --- a/app/gui/src/tests.rs +++ b/app/gui/src/tests.rs @@ -1,5 +1,6 @@ use super::prelude::*; +use crate::config::ProjectToOpen; use crate::ide; use crate::transport::test_utils::TestWithMockedTransport; @@ -28,7 +29,9 @@ fn failure_to_open_project_is_reported() { let project_manager = Rc::new(project_manager::Client::new(transport)); executor::global::spawn(project_manager.runner()); let name = ProjectName::new_unchecked(crate::constants::DEFAULT_PROJECT_NAME.to_owned()); - let initializer = ide::initializer::WithProjectManager::new(project_manager, name); + let project_to_open = ProjectToOpen::Name(name); + let initializer = + ide::initializer::WithProjectManager::new(project_manager, project_to_open); let result = initializer.initialize_project_model().await; result.expect_err("Error should have been reported."); }); diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index a24bff2aed31..1972a77805d3 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -28,7 +28,7 @@ const DEFAULT_IMPORT_ONLY_MODULES = const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|react-hot-toast` const OUR_MODULES = 'enso-content-config|enso-common' const RELATIVE_MODULES = - 'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|index|ipc|naming|paths|preload|security' + 'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|file-associations|index|ipc|naming|paths|preload|security' const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)' const JSX = ':matches(JSXElement, JSXFragment)' const NOT_PASCAL_CASE = '/^(?!_?([A-Z][a-z0-9]*)+$)/' diff --git a/app/ide-desktop/lib/client/electron-builder-config.ts b/app/ide-desktop/lib/client/electron-builder-config.ts index f68262c27332..ddbee125f117 100644 --- a/app/ide-desktop/lib/client/electron-builder-config.ts +++ b/app/ide-desktop/lib/client/electron-builder-config.ts @@ -19,6 +19,7 @@ import * as common from 'enso-common' import * as paths from './paths.js' import signArchivesMacOs from './tasks/signArchivesMacOs.js' +import { BUNDLED_PROJECT_EXTENSION, SOURCE_FILE_EXTENSION } from './file-associations.js' import BUILD_INFO from '../../build.json' assert { type: 'json' } @@ -156,8 +157,13 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil ], fileAssociations: [ { - ext: 'enso', - name: 'Enso Source File', + ext: SOURCE_FILE_EXTENSION, + name: `${common.PRODUCT_NAME} Source File`, + role: 'Editor', + }, + { + ext: BUNDLED_PROJECT_EXTENSION, + name: `${common.PRODUCT_NAME} Project Bundle`, role: 'Editor', }, ], diff --git a/app/ide-desktop/lib/client/file-associations.ts b/app/ide-desktop/lib/client/file-associations.ts new file mode 100644 index 000000000000..5c3989862239 --- /dev/null +++ b/app/ide-desktop/lib/client/file-associations.ts @@ -0,0 +1,13 @@ +/** @file File associations for client application. */ + +/** The extension for the source file, without the leading period character. */ +export const SOURCE_FILE_EXTENSION = 'enso' + +/** The extension for the project bundle, without the leading period character. */ +export const BUNDLED_PROJECT_EXTENSION = 'enso-project' + +/** The filename suffix for the source file, including the leading period character. */ +export const SOURCE_FILE_SUFFIX = `.${SOURCE_FILE_EXTENSION}` + +/** The filename suffix for the project bundle, including the leading period character. */ +export const BUNDLED_PROJECT_SUFFIX = `.${BUNDLED_PROJECT_EXTENSION}` diff --git a/app/ide-desktop/lib/client/package.json b/app/ide-desktop/lib/client/package.json index afd084173af7..89201c8b875c 100644 --- a/app/ide-desktop/lib/client/package.json +++ b/app/ide-desktop/lib/client/package.json @@ -25,6 +25,8 @@ "mime-types": "^2.1.35", "opener": "^1.5.2", "string-length": "^5.0.1", + "@types/tar": "^6.1.4", + "tar": "^6.1.13", "yargs": "17.6.2" }, "comments": { diff --git a/app/ide-desktop/lib/client/src/config/parser.ts b/app/ide-desktop/lib/client/src/config/parser.ts index 1aa357c09e57..f782784cf56b 100644 --- a/app/ide-desktop/lib/client/src/config/parser.ts +++ b/app/ide-desktop/lib/client/src/config/parser.ts @@ -3,15 +3,14 @@ import chalk from 'chalk' import stringLength from 'string-length' -import * as yargsHelpers from 'yargs/helpers' import yargs from 'yargs/yargs' import yargsModule from 'yargs' import * as contentConfig from 'enso-content-config' import * as config from 'config' +import * as fileAssociations from 'file-associations' import * as naming from 'naming' - import BUILD_INFO from '../../../../build.json' assert { type: 'json' } const logger = contentConfig.logger @@ -267,11 +266,9 @@ function argvAndChromeOptions(processArgs: string[]): ArgvAndChromeOptions { // ===================== /** Parses command line arguments. */ -export function parseArgs() { +export function parseArgs(clientArgs: string[] = fileAssociations.CLIENT_ARGUMENTS) { const args = config.CONFIG - const { argv, chromeOptions } = argvAndChromeOptions( - fixArgvNoPrefix(yargsHelpers.hideBin(process.argv)) - ) + const { argv, chromeOptions } = argvAndChromeOptions(fixArgvNoPrefix(clientArgs)) const yargsOptions = args .optionsRecursive() .reduce((opts: Record, option) => { diff --git a/app/ide-desktop/lib/client/src/file-associations.ts b/app/ide-desktop/lib/client/src/file-associations.ts new file mode 100644 index 000000000000..ae759ab51dd2 --- /dev/null +++ b/app/ide-desktop/lib/client/src/file-associations.ts @@ -0,0 +1,151 @@ +/** @file + * This module provides functionality for handling file opening events in the Enso IDE. + * + * 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' + +import * as electron from 'electron' +import electronIsDev from 'electron-is-dev' + +import * as common from 'enso-common' +import * as config from 'enso-content-config' +import * as fileAssociations from '../file-associations' +import * as project from './project-management' + +const logger = config.logger + +// ================= +// === Reexports === +// ================= + +export const BUNDLED_PROJECT_EXTENSION = fileAssociations.BUNDLED_PROJECT_EXTENSION +export const SOURCE_FILE_EXTENSION = fileAssociations.SOURCE_FILE_EXTENSION +export const BUNDLED_PROJECT_SUFFIX = fileAssociations.BUNDLED_PROJECT_SUFFIX +export const SOURCE_FILE_SUFFIX = fileAssociations.SOURCE_FILE_SUFFIX + +// ========================== +// === Arguments Handling === +// ========================== + +/** + * Check if the given list of application startup arguments denotes an attempt to open a file. + * + * For example, this happens when the user double-clicks on a file in the file explorer and the + * application is launched with the file path as an argument. + * + * @param clientArgs - A list of arguments passed to the application, stripped from the initial + * executable name and any electron dev mode arguments. + * @returns The path to the file to open, or `null` if no file was specified. + */ +export function argsDenoteFileOpenAttempt(clientArgs: 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 + // assume that we have been launched with a file to open. In this case, we must translate this + // path to the actual argument that'd open the project containing this file. + if (clientArgs.length === 1 && typeof arg !== 'undefined') { + try { + fsSync.accessSync(arg, fsSync.constants.R_OK) + result = arg + } catch (e) { + logger.log(`The single argument '${arg}' does not denote a readable file: ${String(e)}`) + } + } + return result +} + +/** Get the arguments, excluding the initial program name and any electron dev mode arguments. */ +export const CLIENT_ARGUMENTS = getClientArguments() + +/** Decide what are client arguments, @see {@link CLIENT_ARGUMENTS}. */ +function getClientArguments(): 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 notFoundIndexPlaceholder = -1 + if (separatorIndex === notFoundIndexPlaceholder) { + // If there is no separator, client gets no arguments. + return [] + } else { + // Drop everything before the separator. + return process.argv.slice(separatorIndex + 1) + } + } else { + // Drop the leading executable name. + return process.argv.slice(1) + } +} + +// ========================= +// === File Associations === +// ========================= + +/* Check if the given path looks like a file that we can open. */ +export function isFileOpenable(path: string): boolean { + const extension = pathModule.extname(path).toLowerCase() + return ( + extension === fileAssociations.BUNDLED_PROJECT_EXTENSION || + extension === fileAssociations.SOURCE_FILE_EXTENSION + ) +} + +/* On macOS when Enso-associated file is opened, the application is first started and then it + * receives the `open-file` event. However, if there is already an instance of Enso running, + * it receives the `open-file` event (and no new instance is created for us). In this case, + * we manually start a new instance of the application and pass the file path to it (using the + * Windows-style command). + */ +export function onFileOpened(event: Event, path: string) { + if (isFileOpenable(path)) { + // 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}'.`) + // eslint-disable-next-line no-restricted-syntax + return handleOpenFile(path) + } else { + // We need to start another copy of the application, as the first one is already running. + 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() + } + } +} + +/** Handle the case where IDE is invoked with a file to open. + * + * Imports project if necessary. Returns the ID of the project to open. In case of an error, displays an error message and rethrows the error. + * + * @throws An `Error`, if the project from the file cannot be opened or imported. */ +export function handleOpenFile(openedFile: string): string { + try { + return project.importProjectFromPath(openedFile) + } catch (e: unknown) { + // Since the user has explicitly asked us to open a file, in case of an error, we should + // display a message box with the error details. + let message = `Cannot open file '${openedFile}'.` + message += `\n\nReason:\n${e?.toString() ?? 'Unknown error'}` + if (e instanceof Error && typeof e.stack !== 'undefined') { + message += `\n\nDetails:\n${e.stack}` + } + logger.error(e) + electron.dialog.showErrorBox(common.PRODUCT_NAME, message) + throw e + } +} diff --git a/app/ide-desktop/lib/client/src/index.ts b/app/ide-desktop/lib/client/src/index.ts index ac8583e68242..e9e74d3bf093 100644 --- a/app/ide-desktop/lib/client/src/index.ts +++ b/app/ide-desktop/lib/client/src/index.ts @@ -12,28 +12,25 @@ import process from 'node:process' import * as electron from 'electron' +import * as common from 'enso-common' import * as contentConfig from 'enso-content-config' import * as authentication from 'authentication' import * as config from 'config' import * as configParser from 'config/parser' import * as debug from 'debug' +// eslint-disable-next-line no-restricted-syntax +import * as fileAssociations from 'file-associations' import * as ipc from 'ipc' import * as naming from 'naming' import * as paths from 'paths' import * as projectManager from 'bin/project-manager' import * as security from 'security' import * as server from 'bin/server' +import * as utils from '../../../utils' const logger = contentConfig.logger -// ================= -// === Constants === -// ================= - -/** Indent size for outputting JSON. */ -const INDENT_SIZE = 4 - // =========== // === App === // =========== @@ -47,16 +44,30 @@ class App { isQuitting = false async run() { - const { args, windowSize, chromeOptions } = configParser.parseArgs() - this.args = args + // Register file associations for macOS. + electron.app.on('open-file', fileAssociations.onFileOpened) + + const { windowSize, chromeOptions, fileToOpen } = this.processArguments() + if (fileToOpen != null) { + try { + // This makes the IDE open the relevant project. Also, this prevents us from using this + // method after IDE has been fully set up, as the initializing code would have already + // read the value of this argument. + this.args.groups.startup.options.project.value = + fileAssociations.handleOpenFile(fileToOpen) + } 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. + } + } if (this.args.options.version.value) { await this.printVersion() - process.exit() + electron.app.quit() } else if (this.args.groups.debug.options.info.value) { await electron.app.whenReady().then(async () => { await debug.printInfo() + electron.app.quit() }) - process.exit() } else { this.setChromeOptions(chromeOptions) security.enableAll() @@ -74,6 +85,19 @@ class App { } } + processArguments() { + // 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 + ) + // If we are opening a file (i.e. we were spawned with just a path of the file to open as + // the argument), 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 ? [] : fileAssociations.CLIENT_ARGUMENTS + return { ...configParser.parseArgs(argsToParse), fileToOpen } + } + /** Set Chrome options based on the app configuration. For comprehensive list of available * Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. */ setChromeOptions(chromeOptions: configParser.ChromeOption[]) { @@ -292,7 +316,7 @@ class App { } printVersion(): Promise { - const indent = ' '.repeat(INDENT_SIZE) + const indent = ' '.repeat(utils.INDENT_SIZE) let maxNameLen = 0 for (const name in debug.VERSION_INFO) { maxNameLen = Math.max(maxNameLen, name.length) @@ -355,5 +379,11 @@ class App { // === App startup === // =================== +process.on('uncaughtException', (err, origin) => { + console.error(`Uncaught exception: ${String(err)}\nException origin: ${origin}`) + electron.dialog.showErrorBox(common.PRODUCT_NAME, err.stack ?? err.toString()) + electron.app.exit(1) +}) + const APP = new App() void APP.run() diff --git a/app/ide-desktop/lib/client/src/paths.ts b/app/ide-desktop/lib/client/src/paths.ts index 0f23f5f9f5c6..fc12a701d74e 100644 --- a/app/ide-desktop/lib/client/src/paths.ts +++ b/app/ide-desktop/lib/client/src/paths.ts @@ -35,3 +35,6 @@ export const PROJECT_MANAGER_PATH = path.join( // Placeholder for a bundler-provided define. PROJECT_MANAGER_IN_BUNDLE_PATH ) + +/** Relative path of Enso Project PM metadata relative to project's root. */ +export const PROJECT_METADATA_RELATIVE = path.join('.enso', 'project.json') diff --git a/app/ide-desktop/lib/client/src/project-management.ts b/app/ide-desktop/lib/client/src/project-management.ts new file mode 100644 index 000000000000..79ba0c51e808 --- /dev/null +++ b/app/ide-desktop/lib/client/src/project-management.ts @@ -0,0 +1,294 @@ +/** @file This module contains functions for importing projects into the Project Manager. + * + * Eventually this module should be replaced with a new Project Manager API that supports importing projects. + * For now, we basically do the following: + * - if the project is already in the Project Manager's location, we just open it; + * - if the project is in a different location, we copy it to the Project Manager's location and open it. + * - if the project is a bundle, we extract it to the Project Manager's location and open it. + */ + +import * as crypto from 'node:crypto' +import * as fsSync from 'node:fs' +import * as fss from 'node:fs' +import * as pathModule from 'node:path' + +import * as electron from 'electron' +import * as tar from 'tar' + +import * as common from 'enso-common' +import * as config from 'enso-content-config' +import * as fileAssociations from '../file-associations' +import * as paths from './paths' +import * as utils from '../../../utils' + +const logger = config.logger + +// ====================== +// === Project Import === +// ====================== + +/** Open a project from the given path. Path can be either a source file under the project root, or the project + * bundle. If needed, the project will be imported into the Project Manager-enabled location. + * + * @returns Project ID (from Project Manager's metadata) identifying the imported project. + * @throws `Error` if the path does not belong to a valid project. + */ +export function importProjectFromPath(openedPath: string): string { + if (pathModule.extname(openedPath).endsWith(fileAssociations.BUNDLED_PROJECT_EXTENSION)) { + // The second part of condition is for the case when someone names a directory like `my-project.enso-project` + // and stores the project there. Not the most fortunate move, but... + if (isProjectRoot(openedPath)) { + return importDirectory(openedPath) + } else { + // Project bundle was provided, so we need to extract it first. + return importBundle(openedPath) + } + } else { + logger.log(`Opening file: '${openedPath}'.`) + const rootPath = getProjectRoot(openedPath) + // Check if the project root is under the projects directory. If it is, we can open it. + // Otherwise, we need to install it first. + if (rootPath == null) { + const message = `File '${openedPath}' does not belong to the ${common.PRODUCT_NAME} project.` + throw new Error(message) + } + return importDirectory(rootPath) + } +} + +/** Import the project from a bundle. + * + * @returns Project ID (from Project Manager's metadata) identifying the imported project. + */ +export function importBundle(bundlePath: string): string { + // The bundle is a tarball, so we just need to extract it to the right location. + const bundleRoot = directoryWithinBundle(bundlePath) + const targetDirectory = generateDirectoryName(bundleRoot ?? bundlePath) + fss.mkdirSync(targetDirectory, { recursive: true }) + // To be more resilient against different ways that user might attempt to create a bundle, we try to support + // both archives that: + // * contain a single directory with the project files - that directory name will be used to generate a new target + // directory name; + // * contain the project files directly - in this case, the archive filename will be used to generate a new target + // directory name. + // We try to tell apart these two cases by looking at the common prefix of the paths of the files in the archive. + // If there is any, everything is under a single directory, and we need to strip it. + tar.x({ + file: bundlePath, + cwd: targetDirectory, + sync: true, + strip: bundleRoot != null ? 1 : 0, + }) + return updateId(targetDirectory) +} + +/** Import the project, so it becomes visible to Project Manager. + * + * @param rootPath - The path to the project root. + * @returns Project ID (from Project Manager's metadata) identifying the imported project. + * @throws `Error` if there occurs race-condition when generating a unique project directory name. + */ +export function importDirectory(rootPath: string): string { + if (isProjectInstalled(rootPath)) { + // Project is already visible to Project Manager, so we can just return its ID. + logger.log(`Project already installed: '${rootPath}'.`) + return getProjectId(rootPath) + } else { + logger.log(`Importing a project copy from: '${rootPath}'.`) + const targetDirectory = generateDirectoryName(rootPath) + if (fsSync.existsSync(targetDirectory)) { + const message = `Project directory already exists: ${targetDirectory}.` + throw new Error(message) + } + + logger.log(`Copying: '${rootPath}' -> '${targetDirectory}'.`) + fsSync.cpSync(rootPath, targetDirectory, { recursive: true }) + // Update the project ID, so we are certain that it is unique. This would be violated, if we imported the same + // project multiple times. + return updateId(targetDirectory) + } +} + +// ================ +// === Metadata === +// ================ + +/** The Project Manager's metadata associated with a project. + * + * The property list is not exhaustive, it only contains the properties that we need. + */ +interface ProjectMetadata { + /** The ID of the project. It is only used in communication with project manager, it has no semantic meaning. */ + id: string +} + +/** + * Type guard function to check if an object conforms to the ProjectMetadata interface. + * + * This function checks if the input object has the required properties and correct types + * to match the ProjectMetadata interface. It can be used at runtime to validate that + * a given object has the expected shape. + * + * @param value - The object to check against the ProjectMetadata interface. + * @returns A boolean value indicating whether the object matches the ProjectMetadata interface. + */ +function isProjectMetadata(value: unknown): value is ProjectMetadata { + return ( + typeof value === 'object' && value != null && 'id' in value && typeof value.id === 'string' + ) +} + +/** Get the ID from the project metadata. */ +export function getProjectId(projectRoot: string): string { + return getMetadata(projectRoot).id +} + +/** Retrieve the project's metadata. + * + * @throws `Error` if the metadata file is missing or ill-formed. */ +export function getMetadata(projectRoot: string): ProjectMetadata { + const metadataPath = pathModule.join(projectRoot, paths.PROJECT_METADATA_RELATIVE) + const jsonText = fss.readFileSync(metadataPath, 'utf8') + const metadata: unknown = JSON.parse(jsonText) + if (isProjectMetadata(metadata)) { + return metadata + } else { + throw new Error('Invalid project metadata') + } +} + +/** Write the project's metadata. */ +export function writeMetadata(projectRoot: string, metadata: ProjectMetadata): void { + const metadataPath = pathModule.join(projectRoot, paths.PROJECT_METADATA_RELATIVE) + fss.writeFileSync(metadataPath, JSON.stringify(metadata, null, utils.INDENT_SIZE)) +} + +/** Update project's metadata. If the provided updater does not return anything, the metadata file is left intact. + * + * The updater function-returned metadata is passed over. + */ +export function updateMetadata( + projectRoot: string, + updater: (initialMetadata: ProjectMetadata) => ProjectMetadata +): ProjectMetadata { + const metadata = getMetadata(projectRoot) + const updatedMetadata = updater(metadata) + writeMetadata(projectRoot, updatedMetadata) + return updatedMetadata +} + +// ========================= +// === Project Directory === +// ========================= + +/* Check if the given path represents the root of an Enso project. This is decided by the presence + * of Project Manager's metadata. */ +export function isProjectRoot(candidatePath: string): boolean { + const projectJsonPath = pathModule.join(candidatePath, paths.PROJECT_METADATA_RELATIVE) + let isRoot = false + try { + fss.accessSync(projectJsonPath, fss.constants.R_OK) + isRoot = true + } catch (e) { + // No need to do anything, isRoot is already set to false + } + return isRoot +} + +/** Check if this bundle is a compressed directory (rather than directly containing the project files). If it is, we + * return the name of the directory. Otherwise, we return `null`. */ +export function directoryWithinBundle(bundlePath: string): string | null { + // We need to look up the root directory among the tarball entries. + let commonPrefix: string | null = null + tar.list({ + file: bundlePath, + sync: true, + onentry: entry => { + // We normalize to get rid of leading `.` (if any). + let path = entry.path.normalize() + commonPrefix = commonPrefix == null ? path : utils.getCommonPrefix(commonPrefix, path) + }, + }) + // ESLint doesn't understand that `commonPrefix` can be not `null` here due to the `onentry` callback. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return commonPrefix ? pathModule.basename(commonPrefix) : null +} + +/** Generate a name for project using given base string. Suffixes are added if there's a collision. + * + * For example 'Name' will become 'Name_1' if there's already a directory named 'Name'. + * If given a name like 'Name_1' it will become 'Name_2' if there's already a directory named 'Name_1'. + * If a path containing multiple components is given, only the last component is used for the name. */ +export function generateDirectoryName(name: string): string { + // Use only the last path component. + name = pathModule.parse(name).name + + // If the name already consists a suffix, reuse it. + const matches = name.match(/^(.*)_(\d+)$/) + let suffix = 0 + // Matches start with the whole match, so we need to skip it. Then come our two capture groups. + const [matchedName, matchedSuffix] = matches?.slice(1) ?? [] + if (typeof matchedName !== 'undefined' && typeof matchedSuffix !== 'undefined') { + name = matchedName + suffix = parseInt(matchedSuffix) + } + + const projectsDirectory = getProjectsDirectory() + for (; ; suffix++) { + let candidatePath = pathModule.join( + projectsDirectory, + `${name}${suffix === 0 ? '' : `_${suffix}`}` + ) + if (!fss.existsSync(candidatePath)) { + // eslint-disable-next-line no-restricted-syntax + return candidatePath + } + } + // Unreachable. +} + +/** Takes a path to a file, presumably located in a project's subtree. Returns the path to the project's root directory + * or `null` if the file is not located in a project. */ +export function getProjectRoot(subtreePath: string): string | null { + let currentPath = subtreePath + while (!isProjectRoot(currentPath)) { + const parent = pathModule.dirname(currentPath) + if (parent === currentPath) { + // eslint-disable-next-line no-restricted-syntax + return null + } + currentPath = parent + } + return currentPath +} + +/** Get the directory that stores Enso projects. */ +export function getProjectsDirectory(): string { + return pathModule.join(electron.app.getPath('home'), 'enso', 'projects') +} + +/** Check if the given project is installed, i.e. can be opened with the Project Manager. */ +export function isProjectInstalled(projectRoot: string): boolean { + // Project can be opened by project manager only if its root directory is directly under the projects directory. + const projectsDirectory = getProjectsDirectory() + const projectRootParent = pathModule.dirname(projectRoot) + // Should resolve symlinks and relative paths. Normalize before comparison. + return pathModule.resolve(projectRootParent) === pathModule.resolve(projectsDirectory) +} + +// ================== +// === Project ID === +// ================== + +/** Generates a unique UUID for a project. */ +export function generateId(): string { + return crypto.randomUUID() +} + +/** Update the project's ID to a new, unique value. */ +export function updateId(projectRoot: string): string { + return updateMetadata(projectRoot, metadata => ({ + ...metadata, + id: generateId(), + })).id +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx index fc6d605daa0c..5c2ac40a2985 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx @@ -208,7 +208,7 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin navigate(app.LOGIN_PATH) break /** If the user is being redirected from a password reset email, then we need to navigate to - * the password reset page, with the verification code and email passed in the URL so they can + * the password reset page, with the verification code and email passed in the URL s-o they can * be filled in automatically. */ case app.RESET_PASSWORD_PATH: { const resetPasswordRedirectUrl = `${app.RESET_PASSWORD_PATH}${parsedUrl.search}` diff --git a/app/ide-desktop/lib/types/globals.d.ts b/app/ide-desktop/lib/types/globals.d.ts index face54c28838..01b2e9d1ba93 100644 --- a/app/ide-desktop/lib/types/globals.d.ts +++ b/app/ide-desktop/lib/types/globals.d.ts @@ -60,7 +60,7 @@ declare global { const BUNDLED_ENGINE_VERSION: string const BUILD_INFO: BuildInfo // eslint-disable-next-line no-restricted-syntax - const PROJECT_MANAGER_IN_BUNDLE_PATH: string | undefined + const PROJECT_MANAGER_IN_BUNDLE_PATH: string const IS_DEV_MODE: boolean /* eslint-disable @typescript-eslint/naming-convention */ } diff --git a/app/ide-desktop/package-lock.json b/app/ide-desktop/package-lock.json index b384a73b73bb..13059399c42c 100644 --- a/app/ide-desktop/package-lock.json +++ b/app/ide-desktop/package-lock.json @@ -31,12 +31,14 @@ "dependencies": { "@types/mime-types": "^2.1.1", "@types/opener": "^1.4.0", + "@types/tar": "^6.1.4", "chalk": "^5.2.0", "create-servers": "^3.2.0", "electron-is-dev": "^2.0.0", "mime-types": "^2.1.35", "opener": "^1.5.2", "string-length": "^5.0.1", + "tar": "^6.1.13", "yargs": "17.6.2" }, "devDependencies": { @@ -4688,6 +4690,15 @@ "license": "MIT", "peer": true }, + "node_modules/@types/tar": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.4.tgz", + "integrity": "sha512-Cp4oxpfIzWt7mr2pbhHT2OTXGMAL0szYCzuf8lRWyIMCgsx6/Hfc3ubztuhvzXHXgraTQxyOCmmg7TDGIMIJJQ==", + "dependencies": { + "@types/node": "*", + "minipass": "^4.0.0" + } + }, "node_modules/@types/to-ico": { "version": "1.1.1", "dev": true, @@ -8404,6 +8415,28 @@ "node": ">=10" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "license": "ISC" @@ -11420,6 +11453,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.5.tgz", + "integrity": "sha512-+yQl7SX3bIT83Lhb4BVorMAHVuqsskxRdlmO9kTpyukp8vsm2Sn/fUOV9xlnG8/a5JsypJzap21lz/y3FBMJ8Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/mixin-deep": { "version": "1.3.2", "license": "MIT", @@ -14560,6 +14624,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^4.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tar-fs": { "version": "2.1.1", "dev": true, @@ -14586,6 +14666,25 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/temp": { "version": "0.8.3", "engines": [ @@ -15601,7 +15700,6 @@ }, "node_modules/yallist": { "version": "4.0.0", - "dev": true, "license": "ISC" }, "node_modules/yaml": { @@ -18574,6 +18672,15 @@ "version": "2.0.1", "peer": true }, + "@types/tar": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.4.tgz", + "integrity": "sha512-Cp4oxpfIzWt7mr2pbhHT2OTXGMAL0szYCzuf8lRWyIMCgsx6/Hfc3ubztuhvzXHXgraTQxyOCmmg7TDGIMIJJQ==", + "requires": { + "@types/node": "*", + "minipass": "^4.0.0" + } + }, "@types/to-ico": { "version": "1.1.1", "dev": true, @@ -20121,6 +20228,7 @@ "@esbuild/windows-x64": "^0.17.0", "@types/mime-types": "^2.1.1", "@types/opener": "^1.4.0", + "@types/tar": "^6.1.4", "chalk": "^5.2.0", "create-servers": "^3.2.0", "crypto-js": "4.1.1", @@ -20137,6 +20245,7 @@ "opener": "^1.5.2", "portfinder": "^1.0.32", "string-length": "^5.0.1", + "tar": "^6.1.13", "tsx": "^3.12.6", "yargs": "17.6.2" }, @@ -21327,6 +21436,24 @@ "universalify": "^2.0.0" } }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "requires": { + "yallist": "^4.0.0" + } + } + } + }, "fs.realpath": { "version": "1.0.0" }, @@ -23376,6 +23503,30 @@ "minimist": { "version": "1.2.7" }, + "minipass": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.5.tgz", + "integrity": "sha512-+yQl7SX3bIT83Lhb4BVorMAHVuqsskxRdlmO9kTpyukp8vsm2Sn/fUOV9xlnG8/a5JsypJzap21lz/y3FBMJ8Q==" + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "requires": { + "yallist": "^4.0.0" + } + } + } + }, "mixin-deep": { "version": "1.3.2", "peer": true, @@ -25394,6 +25545,31 @@ } } }, + "tar": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^4.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + } + } + }, "tar-fs": { "version": "2.1.1", "dev": true, @@ -26100,8 +26276,7 @@ "version": "5.0.8" }, "yallist": { - "version": "4.0.0", - "dev": true + "version": "4.0.0" }, "yaml": { "version": "1.10.2", diff --git a/app/ide-desktop/utils.ts b/app/ide-desktop/utils.ts index 9f296a15e3f9..c3944223b267 100644 --- a/app/ide-desktop/utils.ts +++ b/app/ide-desktop/utils.ts @@ -3,6 +3,17 @@ import * as fs from 'node:fs' import * as path from 'node:path' import process from 'node:process' +// ================= +// === Constants === +// ================= + +/** Indent size for outputting JSON. */ +export const INDENT_SIZE = 4 + +// =================== +// === Environment === +// =================== + /** * Get the environment variable value. * @@ -45,3 +56,16 @@ export function requireEnvPathExist(name: string) { throw Error(`File with path ${value} read from environment variable ${name} is missing.`) } } + +// ====================== +// === String Helpers === +// ====================== + +/** Get the common prefix of the two strings. */ +export function getCommonPrefix(a: string, b: string): string { + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) { + i++ + } + return a.slice(0, i) +} diff --git a/build/build/src/ide/web.rs b/build/build/src/ide/web.rs index 16a8e1e7924d..de6511226d70 100644 --- a/build/build/src/ide/web.rs +++ b/build/build/src/ide/web.rs @@ -393,7 +393,7 @@ impl IdeDesktop { let icons_build = self.build_icons(&icons_dist); let (icons, _content) = try_join(icons_build, client_build).await?; - let python_path = if TARGET_OS == OS::MacOS { + let python_path = if TARGET_OS == OS::MacOS && !env::PYTHON_PATH.is_set() { // On macOS electron-builder will fail during DMG creation if there is no python2 // installed. It is looked for in `/usr/bin/python` which is not valid place on newer // MacOS versions. diff --git a/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs b/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs index 38c67ed93f6b..4e3461067b25 100644 --- a/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs +++ b/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs @@ -9,10 +9,9 @@ use crate::data::color; use crate::display; - -// =============== -// === Exports === -// =============== +// ============== +// === Export === +// ============== pub use shape::Shape; diff --git a/lib/rust/ensogl/core/src/gui/component.rs b/lib/rust/ensogl/core/src/gui/component.rs index ff546fcb2f18..e952430fd22c 100644 --- a/lib/rust/ensogl/core/src/gui/component.rs +++ b/lib/rust/ensogl/core/src/gui/component.rs @@ -18,7 +18,6 @@ use crate::display::Sprite; use crate::frp; - // ============== // === Export === // ============== diff --git a/lib/rust/ensogl/examples/instance-ordering/src/lib.rs b/lib/rust/ensogl/examples/instance-ordering/src/lib.rs index efd7e75df43c..271b271c0394 100644 --- a/lib/rust/ensogl/examples/instance-ordering/src/lib.rs +++ b/lib/rust/ensogl/examples/instance-ordering/src/lib.rs @@ -6,11 +6,11 @@ #![allow(clippy::bool_to_int_with_if)] #![allow(clippy::let_and_return)] -use ensogl_core::display; use ensogl_core::display::world::*; use ensogl_core::prelude::*; use ensogl_core::data::color; +use ensogl_core::display; use ensogl_core::display::navigation::navigator::Navigator; use ensogl_core::display::object::ObjectOps; use ensogl_core::display::shape::compound::rectangle;