Project Sharing (#6077)

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.
This commit is contained in:
Michał Wawrzyniec Urbańczyk 2023-04-06 15:26:37 +02:00 committed by GitHub
parent f5db35af07
commit e7668ebc3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 832 additions and 66 deletions

View File

@ -128,6 +128,8 @@
eliminating the need for fully qualified names. eliminating the need for fully qualified names.
- [Added tooltips to icon buttons][6035] for improved usability. Users can now - [Added tooltips to icon buttons][6035] for improved usability. Users can now
quickly understand each button's function. 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) #### EnsoGL (rendering engine)
@ -570,6 +572,7 @@
[6153]: https://github.com/enso-org/enso/pull/6153 [6153]: https://github.com/enso-org/enso/pull/6153
[6156]: https://github.com/enso-org/enso/pull/6156 [6156]: https://github.com/enso-org/enso/pull/6156
[6204]: https://github.com/enso-org/enso/pull/6204 [6204]: https://github.com/enso-org/enso/pull/6204
[6077]: https://github.com/enso-org/enso/pull/6077
#### Enso Compiler #### Enso Compiler

View File

@ -4,9 +4,11 @@ use crate::prelude::*;
use crate::constants; use crate::constants;
use engine_protocol::project_manager::ProjectMetadata;
use engine_protocol::project_manager::ProjectName; use engine_protocol::project_manager::ProjectName;
use enso_config::Args; use enso_config::Args;
use enso_config::ARGS; use enso_config::ARGS;
use failure::ResultExt;
@ -112,7 +114,7 @@ pub struct Startup {
/// The configuration of connection to the backend service. /// The configuration of connection to the backend service.
pub backend: BackendService, pub backend: BackendService,
/// The project name we want to open on startup. /// The project name we want to open on startup.
pub project_name: Option<ProjectName>, pub project_to_open: Option<ProjectToOpen>,
/// Whether to open directly to the project view, skipping the welcome screen. /// 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. /// Identifies the element to create the IDE's DOM nodes as children of.
@ -123,13 +125,14 @@ impl Startup {
/// Read configuration from the web arguments. See also [`web::Arguments`] documentation. /// Read configuration from the web arguments. See also [`web::Arguments`] documentation.
pub fn from_web_arguments() -> FallibleResult<Startup> { pub fn from_web_arguments() -> FallibleResult<Startup> {
let backend = BackendService::from_web_arguments(&ARGS)?; let backend = BackendService::from_web_arguments(&ARGS)?;
let project_name = ARGS.groups.startup.options.project.value.as_str(); let project = ARGS.groups.startup.options.project.value.as_str();
let no_project_name = project_name.is_empty(); let no_project: bool = project.is_empty();
let initial_view = let initial_view =
if no_project_name { InitialView::WelcomeScreen } else { InitialView::Project }; if no_project { InitialView::WelcomeScreen } else { InitialView::Project };
let project_name = (!no_project_name).as_some_from(|| project_name.to_owned().into()); let project_to_open =
(!no_project).as_some_from(|| ProjectToOpen::from_str(project)).transpose()?;
let dom_parent_id = None; 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. /// 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. /// Start to the Project View.
Project, 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<Self, Self::Err> {
// 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}"),
}
}
}

View File

@ -4,6 +4,7 @@
use crate::prelude::*; use crate::prelude::*;
use crate::config::ProjectToOpen;
use crate::controller::ide::ManagingProjectAPI; use crate::controller::ide::ManagingProjectAPI;
use crate::controller::ide::Notification; use crate::controller::ide::Notification;
use crate::controller::ide::StatusNotificationPublisher; use crate::controller::ide::StatusNotificationPublisher;
@ -53,10 +54,11 @@ impl Handle {
/// Screen. /// Screen.
pub async fn new( pub async fn new(
project_manager: Rc<dyn project_manager::API>, project_manager: Rc<dyn project_manager::API>,
project_name: Option<ProjectName>, project_to_open: Option<ProjectToOpen>,
) -> FallibleResult<Self> { ) -> FallibleResult<Self> {
let project = match project_name { let project = match project_to_open {
Some(name) => Some(Self::init_project_model(project_manager.clone_ref(), name).await?), Some(project_to_open) =>
Some(Self::init_project_model(project_manager.clone_ref(), project_to_open).await?),
None => None, None => None,
}; };
Ok(Self::new_with_project_model(project_manager, project)) Ok(Self::new_with_project_model(project_manager, project))
@ -86,12 +88,12 @@ impl Handle {
/// Open project with provided name. /// Open project with provided name.
async fn init_project_model( async fn init_project_model(
project_manager: Rc<dyn project_manager::API>, project_manager: Rc<dyn project_manager::API>,
project_name: ProjectName, project_to_open: ProjectToOpen,
) -> FallibleResult<model::Project> { ) -> FallibleResult<model::Project> {
// TODO[ao]: Reuse of initializer used in previous code design. It should be soon replaced // 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 // 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. // 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?; let model = initializer.initialize_project_model().await?;
Ok(model) Ok(model)
} }

View File

@ -3,6 +3,7 @@
use crate::prelude::*; use crate::prelude::*;
use crate::config; use crate::config;
use crate::config::ProjectToOpen;
use crate::ide::Ide; use crate::ide::Ide;
use crate::transport::web::WebSocket; use crate::transport::web::WebSocket;
use crate::FailedIde; use crate::FailedIde;
@ -40,9 +41,9 @@ const INITIALIZATION_RETRY_TIMES: &[Duration] =
/// Error raised when project with given name was not found. /// Error raised when project with given name was not found.
#[derive(Clone, Debug, Fail)] #[derive(Clone, Debug, Fail)]
#[fail(display = "Project with the name {} was not found.", name)] #[fail(display = "Project '{}' was not found.", name)]
pub struct ProjectNotFound { pub struct ProjectNotFound {
name: ProjectName, name: ProjectToOpen,
} }
@ -129,8 +130,8 @@ impl Initializer {
match &self.config.backend { match &self.config.backend {
ProjectManager { endpoint } => { ProjectManager { endpoint } => {
let project_manager = self.setup_project_manager(endpoint).await?; let project_manager = self.setup_project_manager(endpoint).await?;
let project_name = self.config.project_name.clone(); let project_to_open = self.config.project_to_open.clone();
let controller = controller::ide::Desktop::new(project_manager, project_name); let controller = controller::ide::Desktop::new(project_manager, project_to_open);
Ok(Rc::new(controller.await?)) Ok(Rc::new(controller.await?))
} }
LanguageServer { json_endpoint, binary_endpoint, namespace, project_name } => { LanguageServer { json_endpoint, binary_endpoint, namespace, project_name } => {
@ -186,13 +187,16 @@ impl Initializer {
pub struct WithProjectManager { pub struct WithProjectManager {
#[derivative(Debug = "ignore")] #[derivative(Debug = "ignore")]
pub project_manager: Rc<dyn project_manager::API>, pub project_manager: Rc<dyn project_manager::API>,
pub project_name: ProjectName, pub project_to_open: ProjectToOpen,
} }
impl WithProjectManager { impl WithProjectManager {
/// Constructor. /// Constructor.
pub fn new(project_manager: Rc<dyn project_manager::API>, project_name: ProjectName) -> Self { pub fn new(
Self { project_manager, project_name } project_manager: Rc<dyn project_manager::API>,
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. /// 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. /// Creates a new project and returns its id, so the newly connected project can be opened.
pub async fn create_project(&self) -> FallibleResult<Uuid> { pub async fn create_project(&self, project_name: &ProjectName) -> FallibleResult<Uuid> {
use project_manager::MissingComponentAction::Install; 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 = &enso_config::ARGS.groups.engine.options.preferred_version.value;
let version = (!version.is_empty()).as_some_from(|| version.clone()); let version = (!version.is_empty()).as_some_from(|| version.clone());
let name = &self.project_name; let response = self.project_manager.create_project(project_name, &None, &version, &Install);
let response = self.project_manager.create_project(name, &None, &version, &Install);
Ok(response.await?.project_id) Ok(response.await?.project_id)
} }
@ -219,9 +222,9 @@ impl WithProjectManager {
let response = self.project_manager.list_projects(&None).await?; let response = self.project_manager.list_projects(&None).await?;
let mut projects = response.projects.iter(); let mut projects = response.projects.iter();
projects projects
.find(|project_metadata| project_metadata.name == self.project_name) .find(|project_metadata| self.project_to_open.matches(project_metadata))
.map(|md| md.id) .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, /// Look for the project with the name specified when constructing this initializer,
@ -230,9 +233,14 @@ impl WithProjectManager {
let project = self.lookup_project().await; let project = self.lookup_project().await;
if let Ok(project_id) = project { if let Ok(project_id) = project {
Ok(project_id) Ok(project_id)
} else if let ProjectToOpen::Name(name) = &self.project_to_open {
info!("Attempting to create {}", name);
self.create_project(name).await
} else { } else {
info!("Attempting to create {}", self.project_name); // This can happen only if we are told to open project by id but it cannot be found.
self.create_project().await // 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)); expect_call!(mock_client.list_projects(count) => Ok(project_lists));
let project_manager = Rc::new(mock_client); 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; let project = initializer.get_project_or_create_new().await;
assert_eq!(expected_id, project.expect("Couldn't get project.")) assert_eq!(expected_id, project.expect("Couldn't get project."))
} }

View File

@ -2,8 +2,6 @@
//! about presenters in general. //! about presenters in general.
use crate::prelude::*; use crate::prelude::*;
use double_representation::context_switch::Context;
use double_representation::context_switch::ContextSwitch;
use enso_web::traits::*; use enso_web::traits::*;
use crate::controller::graph::widget::Request as WidgetRequest; 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::executor::global::spawn_stream_handler;
use crate::presenter::graph::state::State; 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 double_representation::context_switch::ContextSwitchExpression;
use engine_protocol::language_server::SuggestionId; use engine_protocol::language_server::SuggestionId;
use enso_frp as frp; use enso_frp as frp;

View File

@ -1,5 +1,6 @@
use super::prelude::*; use super::prelude::*;
use crate::config::ProjectToOpen;
use crate::ide; use crate::ide;
use crate::transport::test_utils::TestWithMockedTransport; 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)); let project_manager = Rc::new(project_manager::Client::new(transport));
executor::global::spawn(project_manager.runner()); executor::global::spawn(project_manager.runner());
let name = ProjectName::new_unchecked(crate::constants::DEFAULT_PROJECT_NAME.to_owned()); 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; let result = initializer.initialize_project_model().await;
result.expect_err("Error should have been reported."); result.expect_err("Error should have been reported.");
}); });

View File

@ -28,7 +28,7 @@ const DEFAULT_IMPORT_ONLY_MODULES =
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|react-hot-toast` const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|react-hot-toast`
const OUR_MODULES = 'enso-content-config|enso-common' const OUR_MODULES = 'enso-content-config|enso-common'
const RELATIVE_MODULES = 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 STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
const JSX = ':matches(JSXElement, JSXFragment)' const JSX = ':matches(JSXElement, JSXFragment)'
const NOT_PASCAL_CASE = '/^(?!_?([A-Z][a-z0-9]*)+$)/' const NOT_PASCAL_CASE = '/^(?!_?([A-Z][a-z0-9]*)+$)/'

View File

@ -19,6 +19,7 @@ import * as common from 'enso-common'
import * as paths from './paths.js' import * as paths from './paths.js'
import signArchivesMacOs from './tasks/signArchivesMacOs.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' } import BUILD_INFO from '../../build.json' assert { type: 'json' }
@ -156,8 +157,13 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
], ],
fileAssociations: [ fileAssociations: [
{ {
ext: 'enso', ext: SOURCE_FILE_EXTENSION,
name: 'Enso Source File', name: `${common.PRODUCT_NAME} Source File`,
role: 'Editor',
},
{
ext: BUNDLED_PROJECT_EXTENSION,
name: `${common.PRODUCT_NAME} Project Bundle`,
role: 'Editor', role: 'Editor',
}, },
], ],

View File

@ -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}`

View File

@ -25,6 +25,8 @@
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"opener": "^1.5.2", "opener": "^1.5.2",
"string-length": "^5.0.1", "string-length": "^5.0.1",
"@types/tar": "^6.1.4",
"tar": "^6.1.13",
"yargs": "17.6.2" "yargs": "17.6.2"
}, },
"comments": { "comments": {

View File

@ -3,15 +3,14 @@
import chalk from 'chalk' import chalk from 'chalk'
import stringLength from 'string-length' import stringLength from 'string-length'
import * as yargsHelpers from 'yargs/helpers'
import yargs from 'yargs/yargs' import yargs from 'yargs/yargs'
import yargsModule from 'yargs' import yargsModule from 'yargs'
import * as contentConfig from 'enso-content-config' import * as contentConfig from 'enso-content-config'
import * as config from 'config' import * as config from 'config'
import * as fileAssociations from 'file-associations'
import * as naming from 'naming' import * as naming from 'naming'
import BUILD_INFO from '../../../../build.json' assert { type: 'json' } import BUILD_INFO from '../../../../build.json' assert { type: 'json' }
const logger = contentConfig.logger const logger = contentConfig.logger
@ -267,11 +266,9 @@ function argvAndChromeOptions(processArgs: string[]): ArgvAndChromeOptions {
// ===================== // =====================
/** Parses command line arguments. */ /** Parses command line arguments. */
export function parseArgs() { export function parseArgs(clientArgs: string[] = fileAssociations.CLIENT_ARGUMENTS) {
const args = config.CONFIG const args = config.CONFIG
const { argv, chromeOptions } = argvAndChromeOptions( const { argv, chromeOptions } = argvAndChromeOptions(fixArgvNoPrefix(clientArgs))
fixArgvNoPrefix(yargsHelpers.hideBin(process.argv))
)
const yargsOptions = args const yargsOptions = args
.optionsRecursive() .optionsRecursive()
.reduce((opts: Record<string, yargsModule.Options>, option) => { .reduce((opts: Record<string, yargsModule.Options>, option) => {

View File

@ -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
}
}

View File

@ -12,28 +12,25 @@ import process from 'node:process'
import * as electron from 'electron' import * as electron from 'electron'
import * as common from 'enso-common'
import * as contentConfig from 'enso-content-config' import * as contentConfig from 'enso-content-config'
import * as authentication from 'authentication' import * as authentication from 'authentication'
import * as config from 'config' import * as config from 'config'
import * as configParser from 'config/parser' import * as configParser from 'config/parser'
import * as debug from 'debug' 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 ipc from 'ipc'
import * as naming from 'naming' import * as naming from 'naming'
import * as paths from 'paths' import * as paths from 'paths'
import * as projectManager from 'bin/project-manager' import * as projectManager from 'bin/project-manager'
import * as security from 'security' import * as security from 'security'
import * as server from 'bin/server' import * as server from 'bin/server'
import * as utils from '../../../utils'
const logger = contentConfig.logger const logger = contentConfig.logger
// =================
// === Constants ===
// =================
/** Indent size for outputting JSON. */
const INDENT_SIZE = 4
// =========== // ===========
// === App === // === App ===
// =========== // ===========
@ -47,16 +44,30 @@ class App {
isQuitting = false isQuitting = false
async run() { async run() {
const { args, windowSize, chromeOptions } = configParser.parseArgs() // Register file associations for macOS.
this.args = args 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) { if (this.args.options.version.value) {
await this.printVersion() await this.printVersion()
process.exit() electron.app.quit()
} else if (this.args.groups.debug.options.info.value) { } else if (this.args.groups.debug.options.info.value) {
await electron.app.whenReady().then(async () => { await electron.app.whenReady().then(async () => {
await debug.printInfo() await debug.printInfo()
electron.app.quit()
}) })
process.exit()
} else { } else {
this.setChromeOptions(chromeOptions) this.setChromeOptions(chromeOptions)
security.enableAll() 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 /** 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. */ * Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. */
setChromeOptions(chromeOptions: configParser.ChromeOption[]) { setChromeOptions(chromeOptions: configParser.ChromeOption[]) {
@ -292,7 +316,7 @@ class App {
} }
printVersion(): Promise<void> { printVersion(): Promise<void> {
const indent = ' '.repeat(INDENT_SIZE) const indent = ' '.repeat(utils.INDENT_SIZE)
let maxNameLen = 0 let maxNameLen = 0
for (const name in debug.VERSION_INFO) { for (const name in debug.VERSION_INFO) {
maxNameLen = Math.max(maxNameLen, name.length) maxNameLen = Math.max(maxNameLen, name.length)
@ -355,5 +379,11 @@ class App {
// === App startup === // === 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() const APP = new App()
void APP.run() void APP.run()

View File

@ -35,3 +35,6 @@ export const PROJECT_MANAGER_PATH = path.join(
// Placeholder for a bundler-provided define. // Placeholder for a bundler-provided define.
PROJECT_MANAGER_IN_BUNDLE_PATH 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')

View File

@ -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
}

View File

@ -208,7 +208,7 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin
navigate(app.LOGIN_PATH) navigate(app.LOGIN_PATH)
break break
/** If the user is being redirected from a password reset email, then we need to navigate to /** 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. */ * be filled in automatically. */
case app.RESET_PASSWORD_PATH: { case app.RESET_PASSWORD_PATH: {
const resetPasswordRedirectUrl = `${app.RESET_PASSWORD_PATH}${parsedUrl.search}` const resetPasswordRedirectUrl = `${app.RESET_PASSWORD_PATH}${parsedUrl.search}`

View File

@ -60,7 +60,7 @@ declare global {
const BUNDLED_ENGINE_VERSION: string const BUNDLED_ENGINE_VERSION: string
const BUILD_INFO: BuildInfo const BUILD_INFO: BuildInfo
// eslint-disable-next-line no-restricted-syntax // 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 const IS_DEV_MODE: boolean
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
} }

View File

@ -31,12 +31,14 @@
"dependencies": { "dependencies": {
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/opener": "^1.4.0", "@types/opener": "^1.4.0",
"@types/tar": "^6.1.4",
"chalk": "^5.2.0", "chalk": "^5.2.0",
"create-servers": "^3.2.0", "create-servers": "^3.2.0",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"opener": "^1.5.2", "opener": "^1.5.2",
"string-length": "^5.0.1", "string-length": "^5.0.1",
"tar": "^6.1.13",
"yargs": "17.6.2" "yargs": "17.6.2"
}, },
"devDependencies": { "devDependencies": {
@ -4688,6 +4690,15 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/@types/to-ico": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
@ -8404,6 +8415,28 @@
"node": ">=10" "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": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"license": "ISC" "license": "ISC"
@ -11420,6 +11453,37 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/mixin-deep": {
"version": "1.3.2", "version": "1.3.2",
"license": "MIT", "license": "MIT",
@ -14560,6 +14624,22 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tar-fs": {
"version": "2.1.1", "version": "2.1.1",
"dev": true, "dev": true,
@ -14586,6 +14666,25 @@
"node": ">=6" "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": { "node_modules/temp": {
"version": "0.8.3", "version": "0.8.3",
"engines": [ "engines": [
@ -15601,7 +15700,6 @@
}, },
"node_modules/yallist": { "node_modules/yallist": {
"version": "4.0.0", "version": "4.0.0",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": { "node_modules/yaml": {
@ -18574,6 +18672,15 @@
"version": "2.0.1", "version": "2.0.1",
"peer": true "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": { "@types/to-ico": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
@ -20121,6 +20228,7 @@
"@esbuild/windows-x64": "^0.17.0", "@esbuild/windows-x64": "^0.17.0",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/opener": "^1.4.0", "@types/opener": "^1.4.0",
"@types/tar": "^6.1.4",
"chalk": "^5.2.0", "chalk": "^5.2.0",
"create-servers": "^3.2.0", "create-servers": "^3.2.0",
"crypto-js": "4.1.1", "crypto-js": "4.1.1",
@ -20137,6 +20245,7 @@
"opener": "^1.5.2", "opener": "^1.5.2",
"portfinder": "^1.0.32", "portfinder": "^1.0.32",
"string-length": "^5.0.1", "string-length": "^5.0.1",
"tar": "^6.1.13",
"tsx": "^3.12.6", "tsx": "^3.12.6",
"yargs": "17.6.2" "yargs": "17.6.2"
}, },
@ -21327,6 +21436,24 @@
"universalify": "^2.0.0" "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": { "fs.realpath": {
"version": "1.0.0" "version": "1.0.0"
}, },
@ -23376,6 +23503,30 @@
"minimist": { "minimist": {
"version": "1.2.7" "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": { "mixin-deep": {
"version": "1.3.2", "version": "1.3.2",
"peer": true, "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": { "tar-fs": {
"version": "2.1.1", "version": "2.1.1",
"dev": true, "dev": true,
@ -26100,8 +26276,7 @@
"version": "5.0.8" "version": "5.0.8"
}, },
"yallist": { "yallist": {
"version": "4.0.0", "version": "4.0.0"
"dev": true
}, },
"yaml": { "yaml": {
"version": "1.10.2", "version": "1.10.2",

View File

@ -3,6 +3,17 @@ import * as fs from 'node:fs'
import * as path from 'node:path' import * as path from 'node:path'
import process from 'node:process' import process from 'node:process'
// =================
// === Constants ===
// =================
/** Indent size for outputting JSON. */
export const INDENT_SIZE = 4
// ===================
// === Environment ===
// ===================
/** /**
* Get the environment variable value. * 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.`) 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)
}

View File

@ -393,7 +393,7 @@ impl IdeDesktop {
let icons_build = self.build_icons(&icons_dist); let icons_build = self.build_icons(&icons_dist);
let (icons, _content) = try_join(icons_build, client_build).await?; 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 // 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 // installed. It is looked for in `/usr/bin/python` which is not valid place on newer
// MacOS versions. // MacOS versions.

View File

@ -9,10 +9,9 @@ use crate::data::color;
use crate::display; use crate::display;
// ==============
// =============== // === Export ===
// === Exports === // ==============
// ===============
pub use shape::Shape; pub use shape::Shape;

View File

@ -18,7 +18,6 @@ use crate::display::Sprite;
use crate::frp; use crate::frp;
// ============== // ==============
// === Export === // === Export ===
// ============== // ==============

View File

@ -6,11 +6,11 @@
#![allow(clippy::bool_to_int_with_if)] #![allow(clippy::bool_to_int_with_if)]
#![allow(clippy::let_and_return)] #![allow(clippy::let_and_return)]
use ensogl_core::display;
use ensogl_core::display::world::*; use ensogl_core::display::world::*;
use ensogl_core::prelude::*; use ensogl_core::prelude::*;
use ensogl_core::data::color; use ensogl_core::data::color;
use ensogl_core::display;
use ensogl_core::display::navigation::navigator::Navigator; use ensogl_core::display::navigation::navigator::Navigator;
use ensogl_core::display::object::ObjectOps; use ensogl_core::display::object::ObjectOps;
use ensogl_core::display::shape::compound::rectangle; use ensogl_core::display::shape::compound::rectangle;