Skip to content

Commit

Permalink
Project Sharing (#6077)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mwu-tow authored and MichaelMauderer committed Apr 12, 2023
1 parent 11049ff commit 954c5e0
Show file tree
Hide file tree
Showing 19 changed files with 826 additions and 58 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@
project][6130].
- [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)

Expand Down Expand Up @@ -573,6 +575,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

Expand Down
74 changes: 65 additions & 9 deletions app/gui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;



Expand Down Expand Up @@ -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<ProjectName>,
pub project_to_open: Option<ProjectToOpen>,
/// 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<String>,
pub dom_parent_id: Option<String>,
}

impl Startup {
/// Read configuration from the web arguments. See also [`web::Arguments`] documentation.
pub fn from_web_arguments() -> FallibleResult<Startup> {
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.
Expand All @@ -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<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}"),
}
}
}
12 changes: 7 additions & 5 deletions app/gui/src/controller/ide/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,10 +54,11 @@ impl Handle {
/// Screen.
pub async fn new(
project_manager: Rc<dyn project_manager::API>,
project_name: Option<ProjectName>,
project_to_open: Option<ProjectToOpen>,
) -> FallibleResult<Self> {
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))
Expand Down Expand Up @@ -86,12 +88,12 @@ impl Handle {
/// Open project with provided name.
async fn init_project_model(
project_manager: Rc<dyn project_manager::API>,
project_name: ProjectName,
project_to_open: ProjectToOpen,
) -> FallibleResult<model::Project> {
// 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)
}
Expand Down
41 changes: 25 additions & 16 deletions app/gui/src/ide/initializer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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 } => {
Expand Down Expand Up @@ -186,13 +187,16 @@ impl Initializer {
pub struct WithProjectManager {
#[derivative(Debug = "ignore")]
pub project_manager: Rc<dyn project_manager::API>,
pub project_name: ProjectName,
pub project_to_open: ProjectToOpen,
}

impl WithProjectManager {
/// Constructor.
pub fn new(project_manager: Rc<dyn project_manager::API>, project_name: ProjectName) -> Self {
Self { project_manager, project_name }
pub fn new(
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.
Expand All @@ -205,23 +209,22 @@ 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<Uuid> {
pub async fn create_project(&self, project_name: &ProjectName) -> FallibleResult<Uuid> {
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)
}

async fn lookup_project(&self) -> FallibleResult<Uuid> {
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,
Expand All @@ -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
}
}
}
Expand Down Expand Up @@ -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."))
}
Expand Down
5 changes: 4 additions & 1 deletion app/gui/src/tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use super::prelude::*;

use crate::config::ProjectToOpen;
use crate::ide;
use crate::transport::test_utils::TestWithMockedTransport;

Expand Down Expand Up @@ -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.");
});
Expand Down
2 changes: 1 addition & 1 deletion app/ide-desktop/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]*)+$)/'
Expand Down
10 changes: 8 additions & 2 deletions app/ide-desktop/lib/client/electron-builder-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }

Expand Down Expand Up @@ -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',
},
],
Expand Down
13 changes: 13 additions & 0 deletions app/ide-desktop/lib/client/file-associations.ts
Original file line number Diff line number Diff line change
@@ -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}`
2 changes: 2 additions & 0 deletions app/ide-desktop/lib/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 3 additions & 6 deletions app/ide-desktop/lib/client/src/config/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, yargsModule.Options>, option) => {
Expand Down
Loading

0 comments on commit 954c5e0

Please sign in to comment.