Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Project Sharing #6077

Merged
merged 23 commits into from
Apr 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

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>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if you realized (the docs here weren't helpful TBH), but the option not always is "project to open" - in cloud (i.e. when we receive LS endpoints instead of PM endpoint) this is a name of project we connect to - so not project to open (as it is, technically, already opened), but project name.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed on the call, no change needed here,

/// 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
4 changes: 2 additions & 2 deletions app/gui/src/presenter/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
//! 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;
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;
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