diff --git a/CHANGELOG.md b/CHANGELOG.md index b5087cd5807c..d8338898abab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,13 @@ it is now referenced via the `Main` namespace instead of the project namespace,][6719] e.g. `Main.func1` instead of `MyProject.func1`. This makes it robust against project name changes. +- [Added a button to return from an opened project back to the project + dashboard.][6474] +- [Keyboard shortcuts for graph editing are now disabled when the full-screen + visualization is active.][6844] +- [A loading animation is now shown when opening and creating projects][6827], + as the previous behaviour of showing a blank screen while the project was + being loaded was potentially confusing to users. [6279]: https://github.com/enso-org/enso/pull/6279 [6421]: https://github.com/enso-org/enso/pull/6421 @@ -181,6 +188,9 @@ [6663]: https://github.com/enso-org/enso/pull/6663 [6752]: https://github.com/enso-org/enso/pull/6752 [6719]: https://github.com/enso-org/enso/pull/6719 +[6474]: https://github.com/enso-org/enso/pull/6474 +[6844]: https://github.com/enso-org/enso/pull/6844 +[6827]: https://github.com/enso-org/enso/pull/6827 #### EnsoGL (rendering engine) @@ -234,6 +244,8 @@ - [ToggleButtons can now have tooltips][6035]. - [Rendering of tooltips was improved.][6097] Their text is now more vertically centered and the delay before showing them was extended. +- [Accurate GPU performance measurements have been implemented][6595]. It is + possible now to track both the time spent on both the CPU and the GPU sides. [3857]: https://github.com/enso-org/enso/pull/3857 [3985]: https://github.com/enso-org/enso/pull/3985 @@ -246,6 +258,7 @@ [6366]: https://github.com/enso-org/enso/pull/6366 [6341]: https://github.com/enso-org/enso/pull/6341 [6470]: https://github.com/enso-org/enso/pull/6470 +[6595]: https://github.com/enso-org/enso/pull/6595 [6487]: https://github.com/enso-org/enso/pull/6487 [6512]: https://github.com/enso-org/enso/pull/6512 @@ -781,6 +794,7 @@ - [Limit number of reported warnings per value][6577] - [Suggestions are updated only when the type of the expression changes][6755] - [Add project creation time to project metadata][6780] +- [Upgrade GraalVM to 22.3.1 JDK17][6750] [3227]: https://github.com/enso-org/enso/pull/3227 [3248]: https://github.com/enso-org/enso/pull/3248 @@ -891,6 +905,7 @@ [6372]: https://github.com/enso-org/enso/pull/6372 [6352]: https://github.com/enso-org/enso/pull/6352 [6577]: https://github.com/enso-org/enso/pull/6577 +[6750]: https://github.com/enso-org/enso/pull/6750 [6755]: https://github.com/enso-org/enso/pull/6755 [6780]: https://github.com/enso-org/enso/pull/6780 diff --git a/Cargo.lock b/Cargo.lock index 187cc016e902..7fe5ea817628 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3265,6 +3265,7 @@ version = "0.1.0" dependencies = [ "enso-frp", "ensogl-core", + "ensogl-hardcoded-theme", ] [[package]] diff --git a/app/gui/language/span-tree/src/generate.rs b/app/gui/language/span-tree/src/generate.rs index b9ec8d3607db..8d6f21101f5f 100644 --- a/app/gui/language/span-tree/src/generate.rs +++ b/app/gui/language/span-tree/src/generate.rs @@ -811,7 +811,6 @@ fn tree_generate_node( context: &impl Context, ast_id: Option, ) -> FallibleResult> { - let parenthesized = matches!(tree.type_info, ast::TreeType::Group); let mut children = vec![]; let size; if let Some(leaf_info) = &tree.leaf_info { @@ -873,7 +872,9 @@ fn tree_generate_node( } size = parent_offset; } - Ok(Node { kind, parenthesized, size, children, ast_id, ..default() }) + + let tree_type = Some(tree.type_info.clone()); + Ok(Node { kind, tree_type, size, children, ast_id, ..default() }) } diff --git a/app/gui/language/span-tree/src/lib.rs b/app/gui/language/span-tree/src/lib.rs index d71fe8b4401e..6be37dc900c5 100644 --- a/app/gui/language/span-tree/src/lib.rs +++ b/app/gui/language/span-tree/src/lib.rs @@ -332,6 +332,10 @@ impl SpanTree { write!(buffer, " ext_id={ext_id:?}").unwrap(); } + if let Some(tt) = node.tree_type.as_ref() { + write!(buffer, " tt={tt:?}").unwrap(); + } + buffer.push('\n'); let num_children = node.children.len(); diff --git a/app/gui/language/span-tree/src/node.rs b/app/gui/language/span-tree/src/node.rs index c01e63c734ca..121ce6355897 100644 --- a/app/gui/language/span-tree/src/node.rs +++ b/app/gui/language/span-tree/src/node.rs @@ -44,7 +44,9 @@ pub struct Node { /// span-tree, but not in AST), this field will contain the AST ID of the expression it extends /// (e.g. the AST of a function call with missing arguments, extended with expected arguments). pub extended_ast_id: Option, - pub parenthesized: bool, + /// A tree type of the associated AST node. Only present when the AST node was a + /// [`ast::Shape::Tree`]. + pub tree_type: Option, pub payload: T, } @@ -74,13 +76,13 @@ impl Node { /// Payload mapping utility. pub fn map(self, f: impl Copy + Fn(T) -> S) -> Node { let kind = self.kind; - let parenthesized = self.parenthesized; + let tree_type = self.tree_type; let size = self.size; let children = self.children.into_iter().map(|t| t.map(f)).collect_vec(); let ast_id = self.ast_id; let extended_ast_id = self.extended_ast_id; let payload = f(self.payload); - Node { kind, parenthesized, size, children, ast_id, extended_ast_id, payload } + Node { kind, tree_type, size, children, ast_id, extended_ast_id, payload } } } @@ -88,9 +90,6 @@ impl Node { #[allow(missing_docs)] impl Node { - pub fn parenthesized(&self) -> bool { - self.parenthesized - } pub fn is_root(&self) -> bool { self.kind.is_root() } diff --git a/app/gui/src/controller/ide.rs b/app/gui/src/controller/ide.rs index 8bfe4937c3c2..4c234461cd3d 100644 --- a/app/gui/src/controller/ide.rs +++ b/app/gui/src/controller/ide.rs @@ -5,6 +5,8 @@ use crate::prelude::*; +use crate::config::ProjectToOpen; + use double_representation::name::project; use mockall::automock; use parser::Parser; @@ -102,9 +104,7 @@ impl StatusNotificationPublisher { /// used internally in code. #[derive(Copy, Clone, Debug)] pub enum Notification { - /// User created a new project. The new project is opened in IDE. - NewProjectCreated, - /// User opened an existing project. + /// User opened a new or existing project. ProjectOpened, /// User closed the project. ProjectClosed, @@ -118,10 +118,12 @@ pub enum Notification { // === Errors === -#[allow(missing_docs)] +/// Error raised when a project with given name or ID was not found. #[derive(Clone, Debug, Fail)] -#[fail(display = "Project with name \"{}\" not found.", 0)] -struct ProjectNotFound(String); +#[fail(display = "Project '{}' was not found.", project)] +pub struct ProjectNotFound { + project: ProjectToOpen, +} // === Managing API === @@ -131,11 +133,16 @@ struct ProjectNotFound(String); /// It is a separate trait, because those methods are not supported in some environments (see also /// [`API::manage_projects`]). pub trait ManagingProjectAPI { - /// Create a new unnamed project and open it in the IDE. + /// Create a new project and open it in the IDE. /// + /// `name` is an optional project name. It overrides the name of the template if given. /// `template` is an optional project template name. Available template names are defined in /// `lib/scala/pkg/src/main/scala/org/enso/pkg/Template.scala`. - fn create_new_project(&self, template: Option) -> BoxFuture; + fn create_new_project( + &self, + name: Option, + template: Option, + ) -> BoxFuture; /// Return a list of existing projects. fn list_projects(&self) -> BoxFuture>>; @@ -150,18 +157,44 @@ pub trait ManagingProjectAPI { /// and then for the project opening. fn open_project_by_name(&self, name: String) -> BoxFuture { async move { - let projects = self.list_projects().await?; - let mut projects = projects.into_iter(); - let project = projects.find(|project| project.name.as_ref() == name); - let uuid = project.map(|project| project.id); - if let Some(uuid) = uuid { - self.open_project(uuid).await - } else { - Err(ProjectNotFound(name).into()) + let project_id = self.find_project(&ProjectToOpen::Name(name.into())).await?; + self.open_project(project_id).await + } + .boxed_local() + } + + /// Open a project by name or ID. If no project with the given name exists, it will be created. + fn open_or_create_project(&self, project_to_open: ProjectToOpen) -> BoxFuture { + async move { + match self.find_project(&project_to_open).await { + Ok(project_id) => self.open_project(project_id).await, + Err(error) => + if let ProjectToOpen::Name(name) = project_to_open { + info!("Attempting to create project with name '{name}'."); + self.create_new_project(Some(name.to_string()), None).await + } else { + Err(error) + }, } } .boxed_local() } + + /// Find a project by name or ID. + fn find_project<'a: 'c, 'b: 'c, 'c>( + &'a self, + project_to_open: &'b ProjectToOpen, + ) -> BoxFuture<'c, FallibleResult> { + async move { + self.list_projects() + .await? + .into_iter() + .find(|project_metadata| project_to_open.matches(project_metadata)) + .map(|metadata| metadata.id) + .ok_or_else(|| ProjectNotFound { project: project_to_open.clone() }.into()) + } + .boxed_local() + } } @@ -193,6 +226,11 @@ pub trait API: Debug { #[allow(clippy::needless_lifetimes)] fn manage_projects<'a>(&'a self) -> FallibleResult<&'a dyn ManagingProjectAPI>; + /// Returns whether the Managing Project API is available. + fn can_manage_projects(&self) -> bool { + self.manage_projects().is_ok() + } + /// Return whether private entries should be visible in the component browser. fn are_component_browser_private_entries_visible(&self) -> bool; diff --git a/app/gui/src/controller/ide/desktop.rs b/app/gui/src/controller/ide/desktop.rs index c84598e88a7f..14cc7f0c7b86 100644 --- a/app/gui/src/controller/ide/desktop.rs +++ b/app/gui/src/controller/ide/desktop.rs @@ -4,12 +4,10 @@ use crate::prelude::*; -use crate::config::ProjectToOpen; use crate::controller::ide::ManagingProjectAPI; use crate::controller::ide::Notification; use crate::controller::ide::StatusNotificationPublisher; use crate::controller::ide::API; -use crate::ide::initializer; use double_representation::name::project; use engine_protocol::project_manager; @@ -49,53 +47,16 @@ pub struct Handle { } impl Handle { - /// Create IDE controller. If `maybe_project_name` is `Some`, a project with provided name will - /// be opened. Otherwise controller will be used for project manager operations by Welcome - /// Screen. - pub async fn new( - project_manager: Rc, - project_to_open: Option, - ) -> FallibleResult { - 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)) - } - - /// Create IDE controller with prepared project model. If `project` is `None`, - /// `API::current_project` returns `None` as well. - pub fn new_with_project_model( - project_manager: Rc, - project: Option, - ) -> Self { - let current_project = Rc::new(CloneCell::new(project)); - let status_notifications = default(); - let parser = Parser::new(); - let notifications = default(); - let component_browser_private_entries_visibility_flag = default(); - Self { - current_project, + /// Create IDE controller. + pub fn new(project_manager: Rc) -> FallibleResult { + Ok(Self { + current_project: default(), project_manager, - status_notifications, - parser, - notifications, - component_browser_private_entries_visibility_flag, - } - } - - /// Open project with provided name. - async fn init_project_model( - project_manager: Rc, - project_to_open: ProjectToOpen, - ) -> FallibleResult { - // TODO[ao]: Reuse of initializer used in previous code design. It should be soon replaced - // anyway, because we will soon resign from the "open or create" approach when opening - // IDE. See https://github.com/enso-org/ide/issues/1492 for details. - let initializer = initializer::WithProjectManager::new(project_manager, project_to_open); - let model = initializer.initialize_project_model().await?; - Ok(model) + status_notifications: default(), + parser: default(), + notifications: default(), + component_browser_private_entries_visibility_flag: default(), + }) } } @@ -133,14 +94,16 @@ impl API for Handle { impl ManagingProjectAPI for Handle { #[profile(Objective)] - fn create_new_project(&self, template: Option) -> BoxFuture { + fn create_new_project( + &self, + name: Option, + template: Option, + ) -> BoxFuture { async move { - use model::project::Synchronized as Project; - let list = self.project_manager.list_projects(&None).await?; let existing_names: HashSet<_> = list.projects.into_iter().map(|p| p.name.into()).collect(); - let name = make_project_name(&template); + let name = name.unwrap_or_else(|| make_project_name(&template)); let name = choose_unique_project_name(&existing_names, &name); let name = ProjectName::new_unchecked(name); let version = &enso_config::ARGS.groups.engine.options.preferred_version.value; @@ -151,12 +114,7 @@ impl ManagingProjectAPI for Handle { .project_manager .create_project(&name, &template.map(|t| t.into()), &version, &action) .await?; - let new_project_id = create_result.project_id; - let project_mgr = self.project_manager.clone_ref(); - let new_project = Project::new_opened(project_mgr, new_project_id); - self.current_project.set(Some(new_project.await?)); - self.notifications.notify(Notification::NewProjectCreated); - Ok(()) + self.open_project(create_result.project_id).await } .boxed_local() } diff --git a/app/gui/src/controller/searcher.rs b/app/gui/src/controller/searcher.rs index 4c4a8ef5bd10..a8a42c0c6521 100644 --- a/app/gui/src/controller/searcher.rs +++ b/app/gui/src/controller/searcher.rs @@ -694,33 +694,6 @@ impl Searcher { Mode::NewNode { .. } => self.add_example(&example).map(Some), _ => Err(CannotExecuteWhenEditingNode.into()), }, - Action::ProjectManagement(action) => { - match self.ide.manage_projects() { - Ok(_) => { - let ide = self.ide.clone_ref(); - executor::global::spawn(async move { - // We checked that manage_projects returns Some just a moment ago, so - // unwrapping is safe. - let manage_projects = ide.manage_projects().unwrap(); - let result = match action { - action::ProjectManagement::CreateNewProject => - manage_projects.create_new_project(None), - action::ProjectManagement::OpenProject { id, .. } => - manage_projects.open_project(*id), - }; - if let Err(err) = result.await { - error!("Error when creating new project: {err}"); - } - }); - Ok(None) - } - Err(err) => Err(NotSupported { - action_label: Action::ProjectManagement(action).to_string(), - reason: err, - } - .into()), - } - } } } @@ -1017,12 +990,6 @@ impl Searcher { let mut actions = action::ListWithSearchResultBuilder::new(); let (libraries_icon, default_icon) = action::hardcoded::ICONS.with(|i| (i.libraries.clone_ref(), i.default.clone_ref())); - if should_add_additional_entries && self.ide.manage_projects().is_ok() { - let mut root_cat = actions.add_root_category("Projects", default_icon.clone_ref()); - let category = root_cat.add_category("Projects", default_icon.clone_ref()); - let create_project = action::ProjectManagement::CreateNewProject; - category.add_action(Action::ProjectManagement(create_project)); - } let mut libraries_root_cat = actions.add_root_category("Libraries", libraries_icon.clone_ref()); if should_add_additional_entries { diff --git a/app/gui/src/controller/searcher/action.rs b/app/gui/src/controller/searcher/action.rs index f496d75e4123..bd24a47471d2 100644 --- a/app/gui/src/controller/searcher/action.rs +++ b/app/gui/src/controller/searcher/action.rs @@ -67,14 +67,6 @@ impl Suggestion { /// Action of adding example code. pub type Example = Rc; -/// A variants of project management actions. See also [`Action`]. -#[allow(missing_docs)] -#[derive(Clone, CloneRef, Debug, Eq, PartialEq)] -pub enum ProjectManagement { - CreateNewProject, - OpenProject { id: Immutable, name: ImString }, -} - /// A single action on the Searcher list. See also `controller::searcher::Searcher` docs. #[derive(Clone, CloneRef, Debug, PartialEq)] pub enum Action { @@ -85,8 +77,6 @@ pub enum Action { /// Add to the current module a new function with example code, and a new node in /// current scene calling that function. Example(Example), - /// The project management operation: creating or opening, projects. - ProjectManagement(ProjectManagement), // In the future, other action types will be added (like module/method management, etc.). } @@ -102,10 +92,6 @@ impl Display for Action { Self::Suggestion(Suggestion::Hardcoded(suggestion)) => Display::fmt(&suggestion.name, f), Self::Example(example) => write!(f, "Example: {}", example.name), - Self::ProjectManagement(ProjectManagement::CreateNewProject) => - write!(f, "New Project"), - Self::ProjectManagement(ProjectManagement::OpenProject { name, .. }) => - Display::fmt(name, f), } } } diff --git a/app/gui/src/ide.rs b/app/gui/src/ide.rs index 4ff2a3bc5e48..291220b8783e 100644 --- a/app/gui/src/ide.rs +++ b/app/gui/src/ide.rs @@ -2,6 +2,7 @@ use crate::prelude::*; +use crate::config::ProjectToOpen; use crate::presenter::Presenter; use analytics::AnonymousData; @@ -90,6 +91,11 @@ impl Ide { } } } + + /// Open a project by name or ID. If no project with the given name exists, it will be created. + pub fn open_or_create_project(&self, project: ProjectToOpen) { + self.presenter.open_or_create_project(project) + } } /// A reduced version of [`Ide`] structure, representing an application which failed to initialize. @@ -101,7 +107,6 @@ pub struct FailedIde { pub view: ide_view::root::View, } - /// The Path of the module initially opened after opening project in IDE. pub fn initial_module_path(project: &model::Project) -> model::module::Path { project.main_module_path() diff --git a/app/gui/src/ide/initializer.rs b/app/gui/src/ide/initializer.rs index 9251c4d3d8aa..1f320877670f 100644 --- a/app/gui/src/ide/initializer.rs +++ b/app/gui/src/ide/initializer.rs @@ -3,17 +3,14 @@ use crate::prelude::*; use crate::config; -use crate::config::ProjectToOpen; use crate::ide::Ide; use crate::transport::web::WebSocket; use crate::FailedIde; use engine_protocol::project_manager; -use engine_protocol::project_manager::ProjectName; use enso_web::sleep; use ensogl::application::Application; use std::time::Duration; -use uuid::Uuid; @@ -35,19 +32,6 @@ const INITIALIZATION_RETRY_TIMES: &[Duration] = -// ============== -// === Errors === -// ============== - -/// Error raised when project with given name was not found. -#[derive(Clone, Debug, Fail)] -#[fail(display = "Project '{}' was not found.", name)] -pub struct ProjectNotFound { - name: ProjectToOpen, -} - - - // =================== // === Initializer === // =================== @@ -94,26 +78,39 @@ impl Initializer { // issues to user, such information should be properly passed // in case of setup failure. + match self.initialize_ide_controller_with_retries().await { + Ok(controller) => { + let can_manage_projects = controller.can_manage_projects(); + let ide = Ide::new(ensogl_app, view.clone_ref(), controller); + if can_manage_projects { + if let Some(project) = &self.config.project_to_open { + ide.open_or_create_project(project.clone()); + } + } + info!("IDE was successfully initialized."); + Ok(ide) + } + Err(error) => { + let message = format!("Failed to initialize application: {error}"); + status_bar.add_event(ide_view::status_bar::event::Label::new(message)); + Err(FailedIde { view }) + } + } + } + + async fn initialize_ide_controller_with_retries(&self) -> FallibleResult { let mut retry_after = INITIALIZATION_RETRY_TIMES.iter(); loop { match self.initialize_ide_controller().await { - Ok(controller) => { - let ide = Ide::new(ensogl_app, view.clone_ref(), controller); - info!("Setup done."); - break Ok(ide); - } + Ok(controller) => break Ok(controller), Err(error) => { - let message = format!("Failed to initialize application: {error}"); - error!("{message}"); + error!("Failed to initialize controller: {error}"); match retry_after.next() { Some(time) => { error!("Retrying after {} seconds", time.as_secs_f32()); sleep(*time).await; } - None => { - status_bar.add_event(ide_view::status_bar::event::Label::new(message)); - break Err(FailedIde { view }); - } + None => break Err(error), } } } @@ -130,9 +127,8 @@ impl Initializer { match &self.config.backend { ProjectManager { endpoint } => { let project_manager = self.setup_project_manager(endpoint).await?; - 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?)) + let controller = controller::ide::Desktop::new(project_manager)?; + Ok(Rc::new(controller)) } LanguageServer { json_endpoint, binary_endpoint, namespace, project_name } => { let json_endpoint = json_endpoint.clone(); @@ -172,81 +168,6 @@ impl Initializer { -// ========================== -// === WithProjectManager === -// ========================== - -/// Ide Initializer with project manager. -/// -/// This structure do the specific initialization part when we are connected to Project Manager, -/// like list projects, find the one we want to open, open it, or create new one if it does not -/// exist. -#[allow(missing_docs)] -#[derive(Clone, Derivative)] -#[derivative(Debug)] -pub struct WithProjectManager { - #[derivative(Debug = "ignore")] - pub project_manager: Rc, - pub project_to_open: ProjectToOpen, -} - -impl WithProjectManager { - /// Constructor. - pub fn new( - project_manager: Rc, - project_to_open: ProjectToOpen, - ) -> Self { - Self { project_manager, project_to_open } - } - - /// Create and initialize a new Project Model, for a project with name passed in constructor. - /// - /// If the project with given name does not exist yet, it will be created. - pub async fn initialize_project_model(self) -> FallibleResult { - let project_id = self.get_project_or_create_new().await?; - let project_manager = self.project_manager; - model::project::Synchronized::new_opened(project_manager, project_id).await - } - - /// Creates a new project and returns its id, so the newly connected project can be opened. - pub async fn create_project(&self, project_name: &ProjectName) -> FallibleResult { - use project_manager::MissingComponentAction::Install; - 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 response = self.project_manager.create_project(project_name, &None, &version, &Install); - Ok(response.await?.project_id) - } - - async fn lookup_project(&self) -> FallibleResult { - let response = self.project_manager.list_projects(&None).await?; - let mut projects = response.projects.iter(); - projects - .find(|project_metadata| self.project_to_open.matches(project_metadata)) - .map(|md| md.id) - .ok_or_else(|| ProjectNotFound { name: self.project_to_open.clone() }.into()) - } - - /// Look for the project with the name specified when constructing this initializer, - /// or, if it does not exist, create it. The id of found/created project is returned. - pub async fn get_project_or_create_new(&self) -> FallibleResult { - 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 { - // 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 - } - } -} - - - // ============= // === Utils === // ============= @@ -276,7 +197,8 @@ pub fn register_views(app: &Application) { app.views.register::>(); if enso_config::ARGS.groups.startup.options.platform.value == "web" { - app.views.register::(); + app.views + .register::(); } } @@ -290,6 +212,10 @@ pub fn register_views(app: &Application) { mod test { use super::*; + use crate::config::ProjectToOpen; + use crate::controller::ide::ManagingProjectAPI; + use crate::engine_protocol::project_manager::ProjectName; + use json_rpc::expect_call; use wasm_bindgen_test::wasm_bindgen_test; @@ -313,9 +239,10 @@ mod test { expect_call!(mock_client.list_projects(count) => Ok(project_lists)); let project_manager = Rc::new(mock_client); + let ide_controller = controller::ide::Desktop::new(project_manager).unwrap(); 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.")) + let project_id = + ide_controller.find_project(&project_to_open).await.expect("Couldn't get project."); + assert_eq!(project_id, expected_id); } } diff --git a/app/gui/src/integration_test.rs b/app/gui/src/integration_test.rs index 377f2cee66f3..fb6de04f3076 100644 --- a/app/gui/src/integration_test.rs +++ b/app/gui/src/integration_test.rs @@ -83,7 +83,10 @@ impl Fixture { let project_management = controller.manage_projects().expect("Cannot access Managing Project API"); - project_management.create_new_project(None).await.expect("Failed to create new project"); + project_management + .create_new_project(None, None) + .await + .expect("Failed to create new project"); } /// After returning, the IDE is in a state with the project opened and ready to work diff --git a/app/gui/src/presenter.rs b/app/gui/src/presenter.rs index 8a3b2bac2867..83e845185ffb 100644 --- a/app/gui/src/presenter.rs +++ b/app/gui/src/presenter.rs @@ -4,13 +4,16 @@ use crate::prelude::*; +use crate::config::ProjectToOpen; use crate::controller::ide::StatusNotification; use crate::executor::global::spawn_stream_handler; use crate::presenter; use enso_frp as frp; +use ensogl::system::js; use ide_view as view; use ide_view::graph_editor::SharedHashMap; +use std::time::Duration; // ============== @@ -29,6 +32,19 @@ pub use searcher::Searcher; +// ================= +// === Constants === +// ================= + +/// We don't know how long opening the project will take, but we still want to show a fake +/// progress indicator for the user. This constant represents how long the spinner will run for in +/// milliseconds. +const OPEN_PROJECT_SPINNER_TIME_MS: u64 = 5_000; +/// The interval in milliseconds at which we should increase the spinner +const OPEN_PROJECT_SPINNER_UPDATE_PERIOD_MS: u64 = 10; + + + // ============= // === Model === // ============= @@ -94,15 +110,15 @@ impl Model { #[profile(Task)] pub fn open_project(&self, project_name: String) { let controller = self.controller.clone_ref(); - crate::executor::global::spawn(async move { + crate::executor::global::spawn(with_progress_indicator(|| async move { if let Ok(managing_api) = controller.manage_projects() { if let Err(err) = managing_api.open_project_by_name(project_name).await { error!("Cannot open project by name: {err}."); } } else { - warn!("Project opening failed: no ProjectManagingAPI available."); + warn!("Project Manager API not available, cannot open project."); } - }); + })); } /// Create a new project. `template` is an optional name of the project template passed to the @@ -113,9 +129,10 @@ impl Model { if let Ok(template) = template.map(double_representation::name::project::Template::from_text).transpose() { - crate::executor::global::spawn(async move { + crate::executor::global::spawn(with_progress_indicator(|| async move { if let Ok(managing_api) = controller.manage_projects() { - if let Err(err) = managing_api.create_new_project(template.clone()).await { + if let Err(err) = managing_api.create_new_project(None, template.clone()).await + { if let Some(template) = template { error!("Could not create new project from template {template}: {err}."); } else { @@ -123,13 +140,66 @@ impl Model { } } } else { - warn!("Project creation failed: no ProjectManagingAPI available."); + warn!("Project Manager API not available, cannot create project."); } - }) + })) } else if let Some(template) = template { error!("Invalid project template name: {template}"); }; } + + /// Open a project by name or ID. If no project with the given name exists, it will be created. + #[profile(Task)] + fn open_or_create_project(&self, project: ProjectToOpen) { + let controller = self.controller.clone_ref(); + crate::executor::global::spawn(with_progress_indicator(|| async move { + if let Ok(managing_api) = controller.manage_projects() { + if let Err(error) = managing_api.open_or_create_project(project).await { + error!("Cannot open or create project. {error}"); + } + } else { + warn!("Project Manager API not available, cannot open or create project."); + } + })); + } +} + +/// Show a full-screen spinner for the exact duration of the specified function. +async fn with_progress_indicator(f: F) +where + F: FnOnce() -> T, + T: Future, { + // TODO[ss]: Use a safer variant of getting the JS app. This one gets a variable from JS, casts + // it to a type, etc. Somewhere in EnsoGL we might already have some logic for getting the JS + // app and throwing an error if it's not defined. + let Ok(app) = js::app() else { return error!("Failed to get JavaScript EnsoGL app.") }; + app.show_progress_indicator(0.0); + + let (finished_tx, finished_rx) = futures::channel::oneshot::channel(); + let spinner_progress = futures::stream::unfold(0, |time| async move { + enso_web::sleep(Duration::from_millis(OPEN_PROJECT_SPINNER_UPDATE_PERIOD_MS)).await; + let new_time = time + OPEN_PROJECT_SPINNER_UPDATE_PERIOD_MS; + if new_time < OPEN_PROJECT_SPINNER_TIME_MS { + let progress = new_time as f32 / OPEN_PROJECT_SPINNER_TIME_MS as f32; + Some((progress, new_time)) + } else { + None + } + }) + .take_until(finished_rx); + executor::global::spawn(spinner_progress.for_each(|progress| async move { + let Ok(app) = js::app() else { return error!("Failed to get JavaScript EnsoGL app.") }; + app.show_progress_indicator(progress); + })); + + f().await; + + // This fails when the spinner progressed until the end before the function got completed + // and therefore the receiver got dropped, so we'll ignore the result. + let _ = finished_tx.send(()); + + let Ok(app) = js::app() else { return error!("Failed to get JavaScript EnsoGL app.") }; + app.hide_progress_indicator(); } @@ -165,6 +235,13 @@ impl Presenter { let root_frp = &model.view.frp; root_frp.switch_view_to_project <+ welcome_view_frp.create_project.constant(()); root_frp.switch_view_to_project <+ welcome_view_frp.open_project.constant(()); + + eval root_frp.selected_project ([model] (project) { + if let Some(project) = project { + model.close_project(); + model.open_project(project.name.to_string()); + } + }); } Self { model, network }.init() @@ -174,8 +251,8 @@ impl Presenter { fn init(self) -> Self { self.setup_status_bar_notification_handler(); self.setup_controller_notification_handler(); - self.model.clone_ref().setup_and_display_new_project(); executor::global::spawn(self.clone_ref().set_projects_list_on_welcome_screen()); + self.model.clone_ref().setup_and_display_new_project(); self } @@ -214,8 +291,7 @@ impl Presenter { let weak = Rc::downgrade(&self.model); spawn_stream_handler(weak, stream, move |notification, model| { match notification { - controller::ide::Notification::NewProjectCreated - | controller::ide::Notification::ProjectOpened => + controller::ide::Notification::ProjectOpened => model.setup_and_display_new_project(), controller::ide::Notification::ProjectClosed => { model.close_project(); @@ -239,6 +315,11 @@ impl Presenter { } } } + + /// Open a project by name or ID. If no project with the given name exists, it will be created. + pub fn open_or_create_project(&self, project: ProjectToOpen) { + self.model.open_or_create_project(project) + } } diff --git a/app/gui/src/presenter/graph.rs b/app/gui/src/presenter/graph.rs index c0841c40663d..0ea09b352163 100644 --- a/app/gui/src/presenter/graph.rs +++ b/app/gui/src/presenter/graph.rs @@ -448,6 +448,7 @@ impl Model { } /// Look through all graph's nodes in AST and set position where it is missing. + #[profile(Debug)] fn initialize_nodes_positions(&self, default_gap_between_nodes: f32) { match self.controller.graph().nodes() { Ok(nodes) => { @@ -562,6 +563,7 @@ struct ViewUpdate { impl ViewUpdate { /// Create ViewUpdate information from Graph Presenter's model. + #[profile(Debug)] fn new(model: &Model) -> FallibleResult { let state = model.state.clone_ref(); let nodes = model.controller.graph().nodes()?; @@ -572,11 +574,13 @@ impl ViewUpdate { } /// Remove nodes from the state and return node views to be removed. + #[profile(Debug)] fn remove_nodes(&self) -> Vec { self.state.update_from_controller().retain_nodes(&self.node_ids().collect()) } /// Returns number of nodes view should create. + #[profile(Debug)] fn count_nodes_to_add(&self) -> usize { self.node_ids().filter(|n| self.state.view_id_of_ast_node(*n).is_none()).count() } @@ -616,6 +620,7 @@ impl ViewUpdate { /// input for nodes where position changed. /// /// The nodes not having views are also updated in the state. + #[profile(Debug)] fn set_node_positions(&self) -> Vec<(ViewNodeId, Vector2)> { self.nodes .iter() @@ -629,6 +634,7 @@ impl ViewUpdate { .collect() } + #[profile(Debug)] fn set_node_visualizations(&self) -> Vec<(ViewNodeId, Option)> { self.nodes .iter() @@ -640,11 +646,13 @@ impl ViewUpdate { } /// Remove connections from the state and return views to be removed. + #[profile(Debug)] fn remove_connections(&self) -> Vec { self.state.update_from_controller().retain_connections(&self.connections) } /// Add connections to the state and return endpoints of connections to be created in views. + #[profile(Debug)] fn add_connections(&self) -> Vec<(EdgeEndpoint, EdgeEndpoint)> { let ast_conns = self.connections.iter(); ast_conns @@ -654,6 +662,7 @@ impl ViewUpdate { .collect() } + #[profile(Debug)] fn node_ids(&self) -> impl Iterator + '_ { self.nodes.iter().map(controller::graph::Node::id) } @@ -699,10 +708,11 @@ impl Graph { frp::extend! { network update_view <- source::<()>(); + update_view_once <- update_view.debounce(); // Position initialization should go before emitting `update_data` event. - update_with_gap <- view.default_y_gap_between_nodes.sample(&update_view); + update_with_gap <- view.default_y_gap_between_nodes.sample(&update_view_once); eval update_with_gap ((gap) model.initialize_nodes_positions(*gap)); - update_data <- update_view.map(f_!([model] match ViewUpdate::new(&model) { + update_data <- update_view_once.map(f_!([model] match ViewUpdate::new(&model) { Ok(update) => Rc::new(update), Err(err) => { error!("Failed to update view: {err:?}"); diff --git a/app/gui/src/presenter/project.rs b/app/gui/src/presenter/project.rs index 01ed7c3efddb..bbb27dcb360e 100644 --- a/app/gui/src/presenter/project.rs +++ b/app/gui/src/presenter/project.rs @@ -9,8 +9,8 @@ use crate::presenter; use crate::presenter::graph::ViewNodeId; use engine_protocol::language_server::ExecutionEnvironment; +use engine_protocol::project_manager::ProjectMetadata; use enso_frp as frp; -use ensogl::system::js; use ide_view as view; use ide_view::project::SearcherParams; use model::module::NotificationKind; @@ -19,16 +19,6 @@ use model::project::VcsStatus; -// ================= -// === Constants === -// ================= - -/// We don't know how long the project opening will take, but we still want to show a fake progress -/// indicator for the user. This constant represents a progress percentage that will be displayed. -const OPEN_PROJECT_SPINNER_PROGRESS: f32 = 0.8; - - - // ============= // === Model === // ============= @@ -46,7 +36,7 @@ struct Model { graph: presenter::Graph, code: presenter::Code, searcher: RefCell>, - available_projects: Rc>>, + available_projects: Rc>>, } impl Model { @@ -268,8 +258,6 @@ impl Model { executor::global::spawn(async move { if let Ok(api) = controller.manage_projects() { if let Ok(projects) = api.list_projects().await { - let projects = projects.into_iter(); - let projects = projects.map(|p| (p.name.clone().into(), p.id)).collect_vec(); *projects_list.borrow_mut() = projects; project_list_ready.emit(()); } @@ -277,36 +265,6 @@ impl Model { }) } - /// User clicked a project in the Open Project dialog. Open it. - fn open_project(&self, id_in_list: &usize) { - let controller = self.ide_controller.clone_ref(); - let projects_list = self.available_projects.clone_ref(); - let view = self.view.clone_ref(); - let status_bar = self.status_bar.clone_ref(); - let id = *id_in_list; - executor::global::spawn(async move { - let app = js::app_or_panic(); - app.show_progress_indicator(OPEN_PROJECT_SPINNER_PROGRESS); - view.hide_graph_editor(); - if let Ok(api) = controller.manage_projects() { - api.close_project(); - let uuid = projects_list.borrow().get(id).map(|(_name, uuid)| *uuid); - if let Some(uuid) = uuid { - if let Err(error) = api.open_project(uuid).await { - error!("Error opening project: {error}."); - status_bar.add_event(format!("Error opening project: {error}.")); - } - } else { - error!("Project with id {id} not found."); - } - } else { - error!("Project Manager API not available, cannot open project."); - } - app.hide_progress_indicator(); - view.show_graph_editor(); - }) - } - fn execution_environment_changed( &self, execution_environment: ide_view::execution_environment_selector::ExecutionEnvironment, @@ -329,6 +287,16 @@ impl Model { } }); } + + fn show_dashboard(&self) { + match enso_web::Event::new("show-dashboard") { + Ok(event) => + if let Err(error) = enso_web::document.dispatch_event(&event) { + error!("Failed to dispatch event to show the dashboard. {error:?}"); + }, + Err(error) => error!("Failed to create event to show the dashboard. {error:?}"), + } + } } @@ -370,28 +338,15 @@ impl Project { let view = &model.view.frp; let breadcrumbs = &model.view.graph().model.breadcrumbs; let graph_view = &model.view.graph().frp; - let project_list = &model.view.project_list(); + let project_list = &model.view.project_list().frp; frp::extend! { network project_list_ready <- source_(); - - project_list.grid.reset_entries <+ project_list_ready.map(f_!([model]{ - let cols = 1; - let rows = model.available_projects.borrow().len(); - (rows, cols) - })); - entry_model <- project_list.grid.model_for_entry_needed.map(f!([model]((row, col)) { - let projects = model.available_projects.borrow(); - let project = projects.get(*row); - project.map(|(name, _)| (*row, *col, name.clone_ref())) - })).filter_map(|t| t.clone()); - project_list.grid.model_for_entry <+ entry_model; - + project_list.project_list <+ project_list_ready.map( + f_!(model.available_projects.borrow().clone()) + ); open_project_list <- view.project_list_shown.on_true(); - eval_ open_project_list(model.project_list_opened(project_list_ready.clone_ref())); - selected_project <- project_list.grid.entry_selected.filter_map(|e| *e); - eval selected_project(((row, _col)) model.open_project(row)); - project_list.grid.select_entry <+ selected_project.constant(None); + eval_ open_project_list (model.project_list_opened(project_list_ready.clone_ref())); eval view.searcher ([model](params) { if let Some(params) = params { @@ -429,6 +384,8 @@ impl Project { view.set_read_only <+ view.toggle_read_only.map(f_!(model.toggle_read_only())); eval graph_view.execution_environment((env) model.execution_environment_changed(*env)); eval_ graph_view.execution_environment_play_button_pressed( model.trigger_clean_live_execution()); + + eval_ view.go_to_dashboard_button_pressed (model.show_dashboard()); } let graph_controller = self.model.graph_controller.clone_ref(); diff --git a/app/gui/src/presenter/searcher/provider.rs b/app/gui/src/presenter/searcher/provider.rs index bf5ea8ab628b..e28132c1c8a4 100644 --- a/app/gui/src/presenter/searcher/provider.rs +++ b/app/gui/src/presenter/searcher/provider.rs @@ -133,7 +133,6 @@ impl ide_view::searcher::DocumentationProvider for Action { Some(doc.unwrap_or_else(|| Self::doc_placeholder_for(&suggestion))) } Action::Example(example) => Some(example.documentation_html.clone()), - Action::ProjectManagement(_) => None, } } } diff --git a/app/gui/src/tests.rs b/app/gui/src/tests.rs index 31f4de972528..ca4777c97950 100644 --- a/app/gui/src/tests.rs +++ b/app/gui/src/tests.rs @@ -1,11 +1,10 @@ use super::prelude::*; -use crate::config::ProjectToOpen; -use crate::ide; +use crate::controller::ide; +use crate::controller::ide::ManagingProjectAPI; use crate::transport::test_utils::TestWithMockedTransport; use engine_protocol::project_manager; -use engine_protocol::project_manager::ProjectName; use json_rpc::test_util::transport::mock::MockTransport; use serde_json::json; use wasm_bindgen_test::wasm_bindgen_test; @@ -28,11 +27,8 @@ fn failure_to_open_project_is_reported() { fixture.run_test(async move { 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 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 ide_controller = ide::Desktop::new(project_manager).unwrap(); + let result = ide_controller.create_new_project(None, None).await; result.expect_err("Error should have been reported."); }); fixture.when_stalled_send_response(json!({ diff --git a/app/gui/view/graph-editor/src/component/node/action_bar.rs b/app/gui/view/graph-editor/src/component/node/action_bar.rs index 30662563950a..dd4e14f535a9 100644 --- a/app/gui/view/graph-editor/src/component/node/action_bar.rs +++ b/app/gui/view/graph-editor/src/component/node/action_bar.rs @@ -12,7 +12,8 @@ use ensogl::display; use ensogl_component::toggle_button; use ensogl_component::toggle_button::ColorableShape; use ensogl_component::toggle_button::ToggleButton; -use ensogl_hardcoded_theme as theme; +use ensogl_hardcoded_theme::graph_editor::node::actions as theme; + // ============== @@ -135,13 +136,6 @@ impl Icons { self.freeze.frp.set_read_only(read_only); self.skip.frp.set_read_only(read_only); } - - fn set_color_scheme(&self, color_scheme: &toggle_button::ColorScheme) { - self.visibility.frp.set_color_scheme(color_scheme); - self.context_switch.set_color_scheme(color_scheme); - self.freeze.frp.set_color_scheme(color_scheme); - self.skip.frp.set_color_scheme(color_scheme); - } } impl display::Object for Icons { @@ -378,10 +372,10 @@ impl ActionBar { pub fn new(app: &Application) -> Self { let model = Rc::new(Model::new(app)); let frp = Frp::new(); - ActionBar { frp, model }.init_frp() + ActionBar { frp, model }.init_frp(app) } - fn init_frp(self) -> Self { + fn init_frp(self, app: &Application) -> Self { let network = &self.frp.network; let frp = &self.frp; let model = &self.model; @@ -459,20 +453,11 @@ impl ActionBar { ); } - use theme::graph_editor::node::actions; - let color_scheme = toggle_button::ColorScheme { - non_toggled: Some(model.styles.get_color(actions::button::non_toggled).into()), - toggled: Some(model.styles.get_color(actions::button::toggled).into()), - hovered: Some(model.styles.get_color(actions::button::hovered).into()), - ..default() - }; + let scene = &app.display.default_scene; let context_switch_color_scheme = toggle_button::ColorScheme { - non_toggled: Some(model.styles.get_color(actions::context_switch::non_toggled).into()), - toggled: Some(model.styles.get_color(actions::context_switch::toggled).into()), - hovered: Some(model.styles.get_color(actions::context_switch::hovered).into()), - ..default() + toggled: Some(model.styles.get_color(theme::context_switch::toggled).into()), + ..toggle_button::default_color_scheme(&scene.style_sheet) }; - model.icons.set_color_scheme(&color_scheme); model.icons.context_switch.set_color_scheme(&context_switch_color_scheme); frp.show_on_hover.emit(true); diff --git a/app/gui/view/graph-editor/src/component/node/input/port.rs b/app/gui/view/graph-editor/src/component/node/input/port.rs index bdfd7f642e5b..fffd2c5b4be1 100644 --- a/app/gui/view/graph-editor/src/component/node/input/port.rs +++ b/app/gui/view/graph-editor/src/component/node/input/port.rs @@ -6,7 +6,6 @@ use crate::component::node::input::widget::ConfigContext; use crate::component::node::input::widget::DynConfig; use crate::component::node::input::widget::DynWidget; use crate::component::node::input::widget::EdgeData; -use crate::component::node::input::widget::SpanWidget; use crate::component::node::input::widget::WidgetsFrp; use enso_frp as frp; diff --git a/app/gui/view/graph-editor/src/component/node/input/widget.rs b/app/gui/view/graph-editor/src/component/node/input/widget.rs index 78b428275141..7a7e41eb88c4 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget.rs @@ -60,7 +60,19 @@ use span_tree::node::Ref as SpanRef; use span_tree::TagValue; use text::index::Byte; - +pub(super) mod prelude { + pub use super::ConfigContext; + pub use super::Configuration; + pub use super::Entry; + pub use super::IdentityBase; + pub use super::NodeInfo; + pub use super::Score; + pub use super::SpanWidget; + pub use super::TransferRequest; + pub use super::TreeNode; + pub use super::WidgetIdentity; + pub use super::WidgetsFrp; +} // ================= // === Constants === @@ -129,6 +141,16 @@ pub struct OverrideKey { pub trait SpanWidget { /// Configuration associated with specific widget variant. type Config: Debug + Clone + PartialEq; + /// Score how well a widget kind matches current [`ConfigContext`], e.g. checking if the span + /// node or declaration type match specific patterns. When this method returns + /// [`Score::Mismatch`], this widget kind will not be used, even if it was requested by an + /// override. The override will be ignored and another best scoring widget with default + /// configuration will be used. + fn match_node(ctx: &ConfigContext) -> Score; + /// After a widget has been matched to a node, this method is used to determine its + /// automatically derived configuration. It is not called for widgets that have a configuration + /// provided externally or by a parent widget. + fn default_config(ctx: &ConfigContext) -> Configuration; /// Root display object of a widget. It is returned to the parent widget for positioning. fn root_object(&self) -> &display::object::Instance; /// Create a new widget with given configuration. @@ -146,6 +168,25 @@ pub trait SpanWidget { } } +/// Description of how well a widget matches given node. Used to determine which widget should be +/// used, or whether the applied widget override is valid in given context. +#[derive(Debug, Default, Clone, Copy, PartialOrd, Ord, PartialEq, Eq)] +pub enum Score { + /// This widget kind cannot accept the node. It will never be used, even if it was explicitly + /// requested using an override. + Mismatch, + /// A bad, but syntactically valid match. Matching widget kind will only be used if it was + /// explicitly requested using an override. Should be the default choice for cases where + /// the node is syntactically valid in this widget's context, but no sensible defaults can + /// be inferred from context. + #[default] + OnlyOverride, + /// A good match, but there might be a better one. one. This widget will be used if there is no + /// better option. + Good, + /// Widget matches perfectly and can be used outright, without checking other kinds. + Perfect, +} /// Generate implementation for [`DynWidget`] enum and its associated [`Config`] enum. Those enums /// are used to represent any possible widget kind and its configuration. @@ -182,7 +223,7 @@ macro_rules! define_widget_modules( bitflags::bitflags!{ /// A set of flags that determine the widget kind. - #[derive(Debug, Default, Clone, Copy)] + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct DynKindFlags: u32 { $( #[allow(missing_docs, non_upper_case_globals)] @@ -191,7 +232,34 @@ macro_rules! define_widget_modules( } } + impl DynKindFlags { + /// Check whether the widget kind matching this flag is able to receive given span node. + /// When more than one flag is set, [`Score::Mismatch`] will be returned. + fn match_node(&self, ctx: &ConfigContext) -> Score { + match self { + $(&DynKindFlags::$name => $module::Widget::match_node(ctx),)* + _ => Score::Mismatch, + } + } + + /// Create default configuration of the widget kind contained within this flag. + fn default_config(&self, ctx: &ConfigContext) -> Configuration { + match self { + $(&DynKindFlags::$name => { + let config = $module::Widget::default_config(ctx); + Configuration { + display: config.display, + has_port: config.has_port, + kind: config.kind.into(), + } + },)* + _ => panic!("No widget kind specified.") + } + } + } + impl DynConfig { + /// Return a single flag that determines the used widget kind. fn flag(&self) -> DynKindFlags { match self { $(DynConfig::$name(_) => DynKindFlags::$name,)* @@ -205,17 +273,11 @@ macro_rules! define_widget_modules( Self::$name(config) } } - - impl const From<$module::Widget> for DynWidget { - fn from(config: $module::Widget) -> Self { - Self::$name(config) - } - } )* - impl SpanWidget for DynWidget { - type Config = DynConfig; - fn root_object(&self) -> &display::object::Instance { + impl DynWidget { + #[allow(missing_docs)] + pub fn root_object(&self) -> &display::object::Instance { match self { $(DynWidget::$name(inner) => inner.root_object(),)* } @@ -227,13 +289,13 @@ macro_rules! define_widget_modules( } } - fn configure(&mut self, config: &DynConfig, ctx: ConfigContext) { + pub(super) fn configure(&mut self, config: &DynConfig, ctx: ConfigContext) { match (self, config) { $((DynWidget::$name(model), DynConfig::$name(config)) => { SpanWidget::configure(model, config, ctx); },)* (this, _) => { - *this = SpanWidget::new(config, &ctx); + *this = Self::new(config, &ctx); this.configure(config, ctx) }, } @@ -252,16 +314,16 @@ macro_rules! define_widget_modules( ); define_widget_modules! { - /// Default widget that only displays text. - Label label, - /// Empty widget that does not display anything, used for empty insertion points. - InsertionPoint insertion_point, /// A widget for selecting a single value from a list of available options. SingleChoice single_choice, /// A widget for managing a list of values - adding, removing or reordering them. ListEditor list_editor, + /// Empty widget that does not display anything, used for empty insertion points. + InsertionPoint insertion_point, /// Default span tree traversal widget. Hierarchy hierarchy, + /// Default widget that only displays text. + Label label, } // ===================== @@ -273,8 +335,7 @@ define_widget_modules! { /// that declare themselves as having a port will be able to handle edge connections and visually /// indicate that they are connected. #[derive(Debug, Clone, PartialEq)] -#[allow(missing_docs)] -pub struct Configuration { +pub struct Configuration { /// Display mode of the widget: determines whether or not the widget should be displayed /// depending on current tree display mode. pub display: Display, @@ -283,7 +344,7 @@ pub struct Configuration { /// declare themselves as wanting a port, only one of them will actually have one. pub has_port: bool, /// Configuration specific to given widget kind. - pub kind: DynConfig, + pub kind: KindConfig, } impl Configuration { @@ -293,136 +354,44 @@ impl Configuration { /// /// Will never return any configuration kind specified in `disallow` parameter, except for /// [`DynConfig::Label`] as an option of last resort. - fn from_node( - span_node: &SpanRef, - info: &NodeInfo, - expression: &str, - disallow: DynKindFlags, - ) -> Self { - use span_tree::node::Kind; - - let kind = &span_node.kind; - let has_children = !span_node.children.is_empty(); - let allow = move |kind: DynKindFlags| !disallow.contains(kind); - use DynKindFlags as F; - - let node_expr = &expression[span_node.span()]; - let looks_like_vector = node_expr.starts_with('[') && node_expr.ends_with(']'); - let is_expected_arg = kind.is_expected_argument(); - - let usage_type = info.usage_type.as_ref().map(|t| t.as_str()); - let decl_type = kind.tp().map(|t| t.as_str()); - let decl_or_usage = decl_type.or(usage_type); - - let first_decl_is_vector = || { - decl_type - .map_or(false, |t| t.trim_start_matches('(').starts_with(list_editor::VECTOR_TYPE)) - }; - let type_may_be_vector = || { - decl_type.map_or(false, |t| t.contains(list_editor::VECTOR_TYPE)) - || usage_type.map_or(false, |t| t.contains(list_editor::VECTOR_TYPE)) - }; - let allows_list = allow(F::ListEditor) - && info.connection.is_none() - && (looks_like_vector || (is_expected_arg && type_may_be_vector())); - let prefer_list = allows_list && first_decl_is_vector(); - let tags = kind.tag_values().filter(|tags| !tags.is_empty()); - let first_tag = tags.and_then(|t| t.first()); - - let mut config = - match (kind, tags) { - (Kind::Argument(_) | Kind::InsertionPoint(_), Some(tags)) - if (allow(F::SingleChoice) || prefer_list) => - Self::static_dropdown(kind.name().as_ref().map(Into::into), tags) - .into_list_item_if(prefer_list, decl_or_usage, first_tag), - - (Kind::Root | Kind::Argument(_), _) if allows_list => - Self::list_editor(None, decl_or_usage, first_tag), - - (Kind::InsertionPoint(p), _) - if p.kind.is_expected_argument() && (allow(F::Label) || allows_list) => - Self::always(label::Config::default()).into_list_item_if( - allows_list, - decl_or_usage, - first_tag, - ), - - _ if allow(F::Hierarchy) && has_children => Self::always(hierarchy::Config), - - (Kind::Token | Kind::Operation, _) if allow(F::Label) => - Self::inert(label::Config::default()), - - (Kind::InsertionPoint(_), _) if allow(F::InsertionPoint) => - Self::inert(insertion_point::Config), - - _ => { - // Option of last resort, label is allowed in this case. Skip assert. - return Self::always(label::Config::default()); + fn infer_from_context(ctx: &ConfigContext, disallowed: DynKindFlags) -> Self { + let allowed = !disallowed; + let mut best_match = None; + for kind in allowed { + let score = kind.match_node(ctx); + let current_score = best_match.map(|(_, score)| score).unwrap_or(Score::Mismatch); + if score > current_score { + best_match = Some((kind, score)); + if score == Score::Perfect { + break; } - }; + } + } - config.has_port = config.has_port || info.connection.is_some(); - let allowed = allow(config.kind.flag()); - assert!(allowed, "Created widget configuration of a kind that was disallowed."); + let matched_kind = best_match.map_or(DynKindFlags::Label, |(kind, _)| kind); + let mut config = matched_kind.default_config(ctx); + config.has_port = config.has_port || ctx.info.connection.is_some(); config } - const fn maybe_with_port(kind: C, has_port: bool) -> Self - where C: ~const Into { - Self { display: Display::Always, kind: kind.into(), has_port } - } - - const fn always(kind: C) -> Self - where C: ~const Into { - Self::maybe_with_port(kind, true) - } - - const fn inert(kind: C) -> Self - where C: ~const Into { - Self::maybe_with_port(kind, false) + /// An insertion point that always has a port. + pub fn active_insertion_point() -> Self { + Self::always(insertion_point::Config.into()) } +} - /// Widget configuration for static dropdown, based on the tag values provided by suggestion - /// database. - fn static_dropdown(label: Option, tag_values: &[TagValue]) -> Configuration { - let entries = Rc::new(tag_values.iter().map(Entry::from).collect()); - Self::always(single_choice::Config { label, entries }) - } - fn into_list_item_if( - self, - condition: bool, - typename: Option<&str>, - default_tag: Option<&TagValue>, - ) -> Self { - if condition { - self.into_list_item(typename, default_tag) - } else { - self - } +impl Configuration { + fn maybe_with_port(kind: KindConfig, has_port: bool) -> Self { + Self { display: Display::Always, kind, has_port } } - fn into_list_item(self, typename: Option<&str>, default_tag: Option<&TagValue>) -> Self { - Self::list_editor(Some(Rc::new(self)), typename, default_tag) + fn always(kind: KindConfig) -> Self { + Self::maybe_with_port(kind, true) } - /// An insertion point that always has a port. - pub const fn active_insertion_point() -> Self { - Self::always(insertion_point::Config) - } - - fn list_editor( - item_widget: Option>, - typename: Option<&str>, - default_tag: Option<&TagValue>, - ) -> Self { - let item_default = match default_tag { - Some(tag) => list_editor::DefaultValue::Tag(tag.clone()), - None => list_editor::DefaultValue::StaticExpression( - list_editor::infer_default_value_from_type(typename), - ), - }; - Self::always(list_editor::Config { item_widget, item_default }) + fn inert(kind: KindConfig) -> Self { + Self::maybe_with_port(kind, false) } } @@ -765,7 +734,7 @@ struct TreeEntry { /// port, its widget and all its descendants through `connection` and `subtree_connection` fields /// of [`NodeState`]. #[derive(Debug, Clone, Copy, PartialEq)] -pub(super) struct EdgeData { +pub struct EdgeData { /// Color of an edge connected to the port. pub color: color::Lcha, /// Span tree depth at which the connection is made. @@ -1007,7 +976,7 @@ impl TreeModel { /// State of a node in the widget tree. Provides additional information about the node's current /// state, such as its depth in the widget tree, if it's connected, disabled, etc. #[derive(Debug, Clone, PartialEq)] -pub(super) struct NodeInfo { +pub struct NodeInfo { /// Unique identifier of this node within this widget tree. pub identity: WidgetIdentity, /// Index of node in the widget tree, in insertion order. @@ -1049,7 +1018,7 @@ struct NodeSettings { pub struct ConfigContext<'a, 'b> { builder: &'a mut TreeBuilder<'b>, /// The span tree node corresponding to the widget being configured. - pub(super) span_node: span_tree::node::Ref<'a>, + pub(super) span_node: SpanRef<'a>, /// Additional state associated with configured widget tree node, such as its depth, connection /// status or parent node information. pub(super) info: NodeInfo, @@ -1069,8 +1038,12 @@ impl<'a, 'b> ConfigContext<'a, 'b> { &self.builder.frp } - /// Get the code expression fragment represented by the given byte range. Can be combined with - /// [`span_tree::node::Ref`]'s `span` method to get the expression of a given span tree node. + /// Get the code expression fragment represented by current span node. + pub fn span_expression(&self) -> &str { + self.expression_at(self.span_node.span()) + } + + /// Get the code expression fragment represented by the given byte range. pub fn expression_at(&self, range: text::Range) -> &str { &self.builder.node_expression[range] } @@ -1220,7 +1193,7 @@ pub enum IdentityBase { } impl StableSpanIdentity { - fn from_node(node: &span_tree::node::Ref) -> Self { + fn from_node(node: &SpanRef) -> Self { let (base, base_idx) = if let Some(ast_id) = node.ast_id { (IdentityBase::AstNode(ast_id), node.crumbs.len()) } else if let Some(ext_id) = node.extended_ast_id { @@ -1372,11 +1345,7 @@ impl<'a> TreeBuilder<'a> { /// [`display::object::InstanceDef`], which will only perform hierarchy updates if the children /// list has been actually modified. #[must_use] - pub fn child_widget( - &mut self, - span_node: span_tree::node::Ref<'_>, - nesting_level: NestingLevel, - ) -> Child { + pub fn child_widget(&mut self, span_node: SpanRef<'_>, nesting_level: NestingLevel) -> Child { self.child_widget_of_type(span_node, nesting_level, None) } @@ -1393,7 +1362,7 @@ impl<'a> TreeBuilder<'a> { /// list has been actually modified. pub fn child_widget_of_type( &mut self, - span_node: span_tree::node::Ref<'_>, + span_node: SpanRef<'_>, nesting_level: NestingLevel, configuration: Option<&Configuration>, ) -> Child { @@ -1448,50 +1417,51 @@ impl<'a> TreeBuilder<'a> { // set by an external source, e.g. based on language server. // 3. The default configuration for the widget, which is determined based on the node kind, // usage type and whether it has children. - let kind = &span_node.kind; let disallowed_configs = ptr_usage.used_configs; + let parent_extensions_len = self.extensions.len(); + + let ctx = ConfigContext { + builder: &mut *self, + span_node, + info: info.clone(), + parent_extensions_len, + }; let config_override = || { - self.override_map + let kind = &ctx.span_node.kind; + ctx.builder + .override_map .get(&OverrideKey { call_id: kind.call_id()?, argument_name: kind.argument_name()?.into(), }) - .filter(|cfg| !disallowed_configs.contains(cfg.kind.flag())) + .filter(|cfg| { + let flag = cfg.kind.flag(); + !disallowed_configs.contains(flag) + && !matches!(flag.match_node(&ctx), Score::Mismatch) + }) }; - let inferred_config; let configuration = match configuration.or_else(config_override) { Some(config) => config, None => { - let expr = &self.node_expression; - inferred_config = - Configuration::from_node(&span_node, &info, expr, disallowed_configs); + inferred_config = Configuration::infer_from_context(&ctx, disallowed_configs); &inferred_config } }; + let this = &mut *ctx.builder; + let ptr_usage = this.pointer_usage.entry(main_ptr).or_default(); ptr_usage.used_configs |= configuration.kind.flag(); let widget_has_port = ptr_usage.request_port(&widget_id, configuration.has_port && !is_extended_ast); - let old_node = self.old_nodes.remove(&widget_id).map(|e| e.node); - - let parent_info = std::mem::replace(&mut self.parent_info, Some(info.clone())); + let port_pad = this.node_settings.custom_port_hover_padding; + let old_node = this.old_nodes.remove(&widget_id).map(|e| e.node); + let parent_info = std::mem::replace(&mut this.parent_info, Some(info.clone())); + let saved_node_settings = std::mem::take(&mut this.node_settings); - let port_pad = self.node_settings.custom_port_hover_padding; - let saved_node_settings = std::mem::take(&mut self.node_settings); - let parent_extensions_len = self.extensions.len(); - - let ctx = ConfigContext { - builder: &mut *self, - span_node, - info: info.clone(), - parent_extensions_len, - }; - let app = ctx.app(); - let frp = ctx.frp(); // Widget creation/update can recurse into the builder. All borrows must be dropped // at this point. The `configure` calls on the widgets are allowed to call back into the @@ -1500,6 +1470,8 @@ impl<'a> TreeBuilder<'a> { // `configure` call has been done, so that the next sibling node will receive correct parent // data. let child_node = if widget_has_port { + let app = ctx.app(); + let frp = ctx.frp(); let mut port = match old_node { Some(TreeNode::Port(port)) => port, Some(TreeNode::Widget(widget)) => Port::new(widget, app, frp), diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/hierarchy.rs b/app/gui/view/graph-editor/src/component/node/input/widget/hierarchy.rs index 861769a4e905..a94506099a71 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget/hierarchy.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget/hierarchy.rs @@ -1,6 +1,7 @@ //! Definition of default hierarchy widget. This widget expands each child of its span tree into //! a new widget. +use crate::component::node::input::widget::prelude::*; use crate::prelude::*; use ensogl::display::object; @@ -33,21 +34,33 @@ pub struct Widget { display_object: object::Instance, } -impl super::SpanWidget for Widget { +impl SpanWidget for Widget { type Config = Config; + fn match_node(ctx: &ConfigContext) -> Score { + match ctx.span_node.children.is_empty() { + false => Score::Good, + true => Score::Mismatch, + } + } + + fn default_config(ctx: &ConfigContext) -> Configuration { + let has_port = !ctx.span_node.kind.is_named_argument(); + Configuration::maybe_with_port(default(), has_port) + } + fn root_object(&self) -> &object::Instance { &self.display_object } - fn new(_: &Config, _: &super::ConfigContext) -> Self { + fn new(_: &Config, _: &ConfigContext) -> Self { let display_object = object::Instance::new_named("widget::Hierarchy"); display_object.use_auto_layout(); display_object.set_children_alignment_left_center().justify_content_center_y(); Self { display_object } } - fn configure(&mut self, _: &Config, ctx: super::ConfigContext) { + fn configure(&mut self, _: &Config, ctx: ConfigContext) { let child_level = ctx.info.nesting_level.next_if(ctx.span_node.is_argument()); let children_iter = ctx.span_node.children_iter(); let children = diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/insertion_point.rs b/app/gui/view/graph-editor/src/component/node/input/widget/insertion_point.rs index c524bab92ffc..25540dc7de07 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget/insertion_point.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget/insertion_point.rs @@ -5,6 +5,7 @@ //! //! See also [`span_tree::node::InsertionPoint`]. +use crate::component::node::input::widget::prelude::*; use crate::prelude::*; use ensogl::display::object; @@ -26,18 +27,26 @@ pub struct Widget { root: object::Instance, } -impl super::SpanWidget for Widget { +impl SpanWidget for Widget { type Config = Config; + fn match_node(ctx: &ConfigContext) -> Score { + ctx.span_node.is_positional_insertion_point().then_val_or_default(Score::Perfect) + } + + fn default_config(_: &ConfigContext) -> Configuration { + Configuration::inert(default()) + } + fn root_object(&self) -> &object::Instance { &self.root } - fn new(_: &Config, _: &super::ConfigContext) -> Self { + fn new(_: &Config, _: &ConfigContext) -> Self { let root = object::Instance::new_named("widget::InsertionPoint"); root.set_size(Vector2::::zero()); Self { root } } - fn configure(&mut self, _: &Config, _: super::ConfigContext) {} + fn configure(&mut self, _: &Config, _: ConfigContext) {} } diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/label.rs b/app/gui/view/graph-editor/src/component/node/input/widget/label.rs index 98631b2f719e..c701c3ab871d 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget/label.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget/label.rs @@ -1,5 +1,6 @@ //! Definition of static text label widget. +use crate::component::node::input::widget::prelude::*; use crate::prelude::*; use crate::component::node::input::area::TEXT_SIZE; @@ -38,14 +39,24 @@ pub struct Widget { label: text::Text, } -impl super::SpanWidget for Widget { +impl SpanWidget for Widget { type Config = Config; + fn match_node(_: &ConfigContext) -> Score { + Score::Good + } + + fn default_config(ctx: &ConfigContext) -> Configuration { + use span_tree::node::Kind; + let has_port = !matches!(ctx.span_node.kind, Kind::Token | Kind::NamedArgument); + Configuration::maybe_with_port(default(), has_port) + } + fn root_object(&self) -> &object::Instance { &self.root } - fn new(_: &Config, ctx: &super::ConfigContext) -> Self { + fn new(_: &Config, ctx: &ConfigContext) -> Self { // Embed the label in a vertically centered fixed height container, so that the label's // baseline is properly aligned to center and lines up with other labels in the line. let app = ctx.app(); @@ -90,13 +101,13 @@ impl super::SpanWidget for Widget { Self { frp, root, label } } - fn configure(&mut self, _: &Config, ctx: super::ConfigContext) { + fn configure(&mut self, _: &Config, ctx: ConfigContext) { let is_placeholder = ctx.span_node.is_expected_argument(); let content = if is_placeholder { ctx.span_node.kind.argument_name().unwrap_or_default() } else { - ctx.expression_at(ctx.span_node.span()) + ctx.span_expression() }; let is_connected = ctx.info.subtree_connection.is_some(); diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs b/app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs index 446b754ffc03..282c7ca33a2c 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs @@ -3,19 +3,16 @@ // FIXME[ao]: This code miss important documentation (e.g. for `Element`, `DragData` and `ListItem`) // and may be unreadable at some places. It should be improved in several next debugging PRs. +use crate::component::node::input::widget::prelude::*; use crate::prelude::*; use crate::component::node::input::area::TEXT_SIZE; -use crate::component::node::input::widget::Configuration; -use crate::component::node::input::widget::IdentityBase; -use crate::component::node::input::widget::TransferRequest; -use crate::component::node::input::widget::TreeNode; -use crate::component::node::input::widget::WidgetIdentity; use ensogl::control::io::mouse; use ensogl::display; use ensogl::display::object; use ensogl::display::world::with_context; +use ensogl::Animation; use ensogl_component::list_editor::ListEditor; use span_tree::node::Kind; use std::collections::hash_map::Entry; @@ -27,7 +24,7 @@ use std::collections::hash_map::Entry; // ================= /// The type name that enables the list editor widget. -pub const VECTOR_TYPE: &str = "Standard.Base.Data.Vector.Vector"; +const VECTOR_TYPE: &str = "Standard.Base.Data.Vector.Vector"; /// Extra space to the left and right of the list that will respond to mouse events. The list /// insertion points will only be shown within the list bounding box extended by this margin. @@ -151,11 +148,12 @@ impl display::Object for Element { pub struct Widget { display_object: object::Instance, network: frp::Network, + reconfigured: frp::Any, model: Rc>, } impl Widget { - fn init_list_updates(self, ctx: &super::ConfigContext) -> Self { + fn init_list_updates(self, ctx: &ConfigContext) -> Self { let widgets_frp = ctx.frp(); let network = &self.network; let model = &self.model; @@ -163,6 +161,7 @@ impl Widget { let scene = scene(); let on_down = scene.on_event::(); let on_up = scene.on_event::(); + let reconfigured = self.reconfigured.clone_ref(); frp::extend! { network init <- source_(); @@ -214,6 +213,15 @@ impl Widget { list.enable_all_insertion_points <+ enable_insertion; list.enable_last_insertion_point <+ enable_insertion; list.enable_dragging <+ enable_interaction; + + // Animate margins around the list that are added when it has elements. + let margin_anim = Animation::new(network); + has_elements <- reconfigured.map(f_!([list] !list.is_empty())); + margin_anim.target <+ has_elements + .map(|has| has.then_val_or_default(INSERTION_OFFSET)); + + update_margin <- all(margin_anim.value, reconfigured)._0().debounce(); + eval update_margin((margin) model.borrow().set_side_margin(*margin)); } init.emit(()); @@ -224,21 +232,24 @@ impl Widget { #[derive(Debug)] struct Model { - self_id: WidgetIdentity, - list: ListEditor, + self_id: WidgetIdentity, + list: ListEditor, + /// Root element of the last child widget with [`span_tree::node::Kind::InsertionPoint`] node. + /// It is stored in the model to allow animating its margins within the FRP network. + append_insertion_point: Option, #[allow(dead_code)] - background: display::shape::Rectangle, - elements: HashMap, - default_value: DefaultValue, - expression: String, - crumbs: span_tree::Crumbs, - drag_data_rc: Rc>>, - insertion_indices: Vec, - insert_with_brackets: bool, + background: display::shape::Rectangle, + elements: HashMap, + default_value: DefaultValue, + expression: String, + crumbs: span_tree::Crumbs, + drag_data_rc: Rc>>, + insertion_indices: Vec, + insert_with_brackets: bool, } impl Model { - fn new(ctx: &super::ConfigContext, display_object: &object::Instance) -> Self { + fn new(ctx: &ConfigContext, display_object: &object::Instance) -> Self { let list = ListEditor::new(&ctx.app().cursor); list.gap(ITEMS_GAP); list.set_size_hug_y(TEXT_SIZE).allow_grow_y(); @@ -253,6 +264,7 @@ impl Model { self_id: ctx.info.identity, list, background, + append_insertion_point: default(), elements: default(), default_value: default(), expression: default(), @@ -263,10 +275,10 @@ impl Model { } } - fn configure(&mut self, root: &object::Instance, cfg: &Config, mut ctx: super::ConfigContext) { + fn configure(&mut self, root: &object::Instance, cfg: &Config, mut ctx: ConfigContext) { self.expression.clear(); self.default_value = cfg.item_default.clone(); - self.expression.push_str(ctx.expression_at(ctx.span_node.span())); + self.expression.push_str(ctx.span_expression()); self.crumbs = ctx.span_node.crumbs.clone(); @@ -288,19 +300,16 @@ impl Model { } } - fn configure_insertion_point(&mut self, root: &object::Instance, ctx: super::ConfigContext) { + fn configure_insertion_point(&mut self, root: &object::Instance, ctx: ConfigContext) { self.elements.clear(); + self.list.clear(); + self.append_insertion_point = None; let insertion_point = ctx.builder.child_widget(ctx.span_node, ctx.info.nesting_level); root.replace_children(&[&*insertion_point, self.list.display_object()]); set_margins(self.list.display_object(), 0.0, 0.0); } - fn configure_vector( - &mut self, - root: &object::Instance, - cfg: &Config, - ctx: super::ConfigContext, - ) { + fn configure_vector(&mut self, root: &object::Instance, cfg: &Config, ctx: ConfigContext) { ctx.builder.manage_child_margins(); let nest = ctx.info.nesting_level.next(); let child_config = cfg.item_widget.as_deref(); @@ -379,7 +388,6 @@ impl Model { } } - let has_elements = !list_items.is_empty(); let new_items_range = new_items_range.unwrap_or(0..0); let mut non_assigned_items = list_items[new_items_range].iter_mut().filter(|c| !c.assigned); @@ -395,21 +403,20 @@ impl Model { self.insertion_indices.extend(last_insert_crumb); self.insert_with_brackets = open_bracket.is_none() && close_bracket.is_none(); - let border_margins = has_elements.then_val_or_default(INSERTION_OFFSET); - let append_insert = last_insert_crumb.map(|index| { - let insert = build_child_widget(index, insert_config, INSERT_HOVER_MARGIN).root_object; - set_margins(&insert, border_margins, 0.0); - insert - }); - let list_right_margin = append_insert.is_none().then_val_or_default(border_margins); - set_margins(self.list.display_object(), border_margins, list_right_margin); + self.append_insertion_point = last_insert_crumb + .map(|index| build_child_widget(index, insert_config, INSERT_HOVER_MARGIN).root_object); let (open_bracket, close_bracket) = open_bracket.zip(close_bracket).unzip(); - let mut children = SmallVec::<[&object::Instance; 4]>::new(); - children.extend(&open_bracket); - children.push(self.list.display_object()); - children.extend(&append_insert); - children.extend(&close_bracket); + let children: SmallVec<[&object::Instance; 4]> = [ + open_bracket.as_ref(), + Some(self.list.display_object()), + self.append_insertion_point.as_ref(), + close_bracket.as_ref(), + ] + .into_iter() + .flatten() + .collect(); + root.replace_children(&children); let mut need_reposition = false; @@ -493,7 +500,7 @@ impl Model { } } - fn on_new_item(&mut self, at: usize, frp: &super::WidgetsFrp) { + fn on_new_item(&mut self, at: usize, frp: &WidgetsFrp) { let (mut expression, import) = match &self.default_value { DefaultValue::Tag(tag) => (tag.expression.clone().into(), tag.required_import.as_ref().map(ImString::from)), @@ -517,6 +524,18 @@ impl Model { frp.value_changed.emit(insertion); } } + + fn set_side_margin(&self, side_margin: f32) { + match self.append_insertion_point.as_ref() { + Some(insert) => { + set_margins(insert, side_margin, 0.0); + set_margins(self.list.display_object(), side_margin, 0.0); + } + None => { + set_margins(self.list.display_object(), side_margin, side_margin); + } + } + } } fn set_margins(object: &display::object::Instance, left: f32, right: f32) { @@ -565,23 +584,78 @@ impl From for DefaultValue { } } -impl super::SpanWidget for Widget { +impl SpanWidget for Widget { type Config = Config; + fn match_node(ctx: &ConfigContext) -> Score { + let is_placeholder = ctx.span_node.is_expected_argument(); + let decl_type = ctx.span_node.kind.tp().map(|t| t.as_str()); + + let first_decl_is_vector = + || decl_type.map_or(false, |t| t.trim_start_matches('(').starts_with(VECTOR_TYPE)); + + let type_may_be_vector = || { + let usage_type = ctx.info.usage_type.as_ref().map(|t| t.as_str()); + decl_type.map_or(false, |t| t.contains(VECTOR_TYPE)) + || usage_type.map_or(false, |t| t.contains(VECTOR_TYPE)) + }; + + let is_vector_expression = || { + let node_expr = ctx.span_expression(); + let looks_like_vector = node_expr.starts_with('[') + && node_expr.ends_with(']') + && matches!(ctx.span_node.tree_type, Some(ast::TreeType::Expression)); + + // We now know that the node expression begins with `[`. Check if the node actually + // contains a token as a direct child at its location. It will not be the case for + // expressions that contain nested arrays within the first chain element, but are not + // an array themselves (e.g. `[1, 2] + [3, 4]`). + let children = &ctx.span_node.children; + looks_like_vector + && children.iter().take_while(|c| c.parent_offset.value == 0).any(|c| c.is_token()) + }; + + match () { + _ if ctx.info.connection.is_some() => Score::Mismatch, + _ if is_placeholder && first_decl_is_vector() => Score::Perfect, + _ if is_placeholder && type_may_be_vector() => Score::Good, + _ if is_placeholder => Score::OnlyOverride, + _ if is_vector_expression() => Score::Perfect, + _ => Score::Mismatch, + } + } + + fn default_config(ctx: &ConfigContext) -> Configuration { + let default_tag = ctx.span_node.kind.tag_values().and_then(|t| t.first()); + let item_default = match default_tag { + Some(tag) => DefaultValue::Tag(tag.clone()), + None => { + let usage_type = ctx.info.usage_type.as_ref().map(|t| t.as_str()); + let decl_type = ctx.span_node.kind.tp().map(|t| t.as_str()); + let decl_or_usage = decl_type.or(usage_type); + DefaultValue::StaticExpression(infer_default_value_from_type(decl_or_usage)) + } + }; + Configuration::always(Config { item_widget: None, item_default }) + } + fn root_object(&self) -> &object::Instance { &self.display_object } - fn new(_: &Config, ctx: &super::ConfigContext) -> Self { + fn new(_: &Config, ctx: &ConfigContext) -> Self { let display_object = object::Instance::new_named("widget::ListEditor"); let model = Model::new(ctx, &display_object); let network = frp::Network::new("widget::ListEditor"); - Self { display_object, network, model: Rc::new(RefCell::new(model)) }.init_list_updates(ctx) + let reconfigured = network.any_mut("reconfigured"); + Self { display_object, network, reconfigured, model: Rc::new(RefCell::new(model)) } + .init_list_updates(ctx) } - fn configure(&mut self, cfg: &Config, ctx: super::ConfigContext) { + fn configure(&mut self, cfg: &Config, ctx: ConfigContext) { let mut model = self.model.borrow_mut(); model.configure(&self.display_object, cfg, ctx); + self.reconfigured.emit(()); } fn receive_ownership(&mut self, req: TransferRequest, nodes: Vec<(WidgetIdentity, TreeNode)>) { diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/single_choice.rs b/app/gui/view/graph-editor/src/component/node/input/widget/single_choice.rs index ce54a2c9ac51..229aa3678f85 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget/single_choice.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget/single_choice.rs @@ -1,8 +1,9 @@ //! Definition of single choice widget. +use crate::component::node::input::widget::prelude::*; use crate::prelude::*; -use crate::component::node::input::widget::Entry; +use crate::component::node::input::widget::label; use enso_frp as frp; use ensogl::control::io::mouse; @@ -94,14 +95,29 @@ pub struct Widget { activation_shape: triangle::View, } -impl super::SpanWidget for Widget { +impl SpanWidget for Widget { type Config = Config; + fn match_node(ctx: &ConfigContext) -> Score { + let tags = ctx.span_node.kind.tag_values().unwrap_or_default(); + let has_tags = !tags.is_empty(); + has_tags.then_val_or_default(Score::Perfect) + } + + fn default_config(ctx: &ConfigContext) -> Configuration { + let kind = &ctx.span_node.kind; + let label = kind.name().map(Into::into); + let tags = kind.tag_values().unwrap_or_default(); + let entries = Rc::new(tags.iter().map(Entry::from).collect()); + let default_config = Config { label, entries }; + Configuration::always(default_config) + } + fn root_object(&self) -> &display::object::Instance { &self.display_object } - fn new(_: &Config, ctx: &super::ConfigContext) -> Self { + fn new(_: &Config, ctx: &ConfigContext) -> Self { let app = ctx.app(); // ╭─display_object────────────────────╮ // │╭─content_wrapper─────────────────╮│ @@ -161,12 +177,11 @@ impl super::SpanWidget for Widget { .init(ctx) } - fn configure(&mut self, config: &Config, mut ctx: super::ConfigContext) { + fn configure(&mut self, config: &Config, mut ctx: ConfigContext) { let input = &self.config_frp.public.input; let has_value = !ctx.span_node.is_insertion_point(); - let current_value: Option = - has_value.then(|| ctx.expression_at(ctx.span_node.span()).into()); + let current_value: Option = has_value.then(|| ctx.span_expression().into()); input.current_crumbs(ctx.span_node.crumbs.clone()); input.current_value(current_value); @@ -174,7 +189,7 @@ impl super::SpanWidget for Widget { input.is_connected(ctx.info.subtree_connection.is_some()); if has_value { - ctx.modify_extension::(|ext| ext.bold = true); + ctx.modify_extension::(|ext| ext.bold = true); } let child = ctx.builder.child_widget(ctx.span_node, ctx.info.nesting_level); @@ -183,16 +198,16 @@ impl super::SpanWidget for Widget { } impl Widget { - fn init(self, ctx: &super::ConfigContext) -> Self { + fn init(self, ctx: &ConfigContext) -> Self { let is_open = self.init_dropdown_focus(ctx); self.init_dropdown_values(ctx, is_open); self.init_activation_shape(ctx); self } - fn init_dropdown_focus(&self, ctx: &super::ConfigContext) -> frp::Stream { + fn init_dropdown_focus(&self, ctx: &ConfigContext) -> frp::Stream { let widgets_frp = ctx.frp(); - let focus_receiver = self.display_object.clone_ref(); + let focus_receiver = &self.dropdown_wrapper; let focus_in = focus_receiver.on_event::(); let focus_out = focus_receiver.on_event::(); let network = &self.config_frp.network; @@ -215,14 +230,14 @@ impl Widget { // Otherwise the animation finishes within single frame, which looks bad. let close_after_selection_timer = frp::io::timer::Timeout::new(network); close_after_selection_timer.restart <+ dropdown_frp.user_select_action.constant(1); - eval close_after_selection_timer.on_expired((()) focus_receiver.blur()); + eval_ close_after_selection_timer.on_expired(focus_receiver.blur()); } is_open } - fn init_dropdown_values(&self, ctx: &super::ConfigContext, is_open: frp::Stream) { + fn init_dropdown_values(&self, ctx: &ConfigContext, is_open: frp::Stream) { let network = &self.config_frp.network; let dropdown_frp = &self.dropdown.borrow(); let config_frp = &self.config_frp; @@ -257,13 +272,13 @@ impl Widget { }; } - fn init_activation_shape(&self, ctx: &super::ConfigContext) { + fn init_activation_shape(&self, ctx: &ConfigContext) { let network = &self.config_frp.network; let config_frp = &self.config_frp; let widgets_frp = ctx.frp(); let styles = ctx.styles(); let activation_shape = &self.activation_shape; - let focus_receiver = &self.display_object; + let focus_receiver = &self.dropdown_wrapper; frp::extend! { network is_hovered <- widgets_frp.on_port_hover.map2(&config_frp.current_crumbs, |h, crumbs| { h.on().map_or(false, |h| crumbs.starts_with(h)) diff --git a/app/gui/view/graph-editor/src/component/profiling.rs b/app/gui/view/graph-editor/src/component/profiling.rs index b32cfba3bd08..0fe4388f942e 100644 --- a/app/gui/view/graph-editor/src/component/profiling.rs +++ b/app/gui/view/graph-editor/src/component/profiling.rs @@ -146,7 +146,8 @@ impl Button { /// Constructs a new button for toggling the editor's view mode. pub fn new(app: &Application) -> Button { let scene = &app.display.default_scene; - let styles = StyleWatchFrp::new(&scene.style_sheet); + let style_sheet = &scene.style_sheet; + let styles = StyleWatchFrp::new(style_sheet); let frp = Frp::new(); let network = &frp.network; @@ -154,7 +155,6 @@ impl Button { .with_placement(tooltip::Placement::Left); let button = ToggleButton::::new(app, tooltip_style); scene.layers.panel.add(&button); - button.set_visibility(true); button.frp.set_size(Vector2(32.0, 32.0)); frp::extend! { network @@ -179,21 +179,20 @@ impl Button { // === Color === use ensogl_hardcoded_theme::graph_editor::profiling_button as button_theme; - let non_toggled_color = styles.get_color(button_theme::non_toggled); let toggled_color = styles.get_color(button_theme::toggled); - let hovered_color = styles.get_color(button_theme::hovered); let toggled_hovered_color = styles.get_color(button_theme::toggled_hovered); init_color_scheme <- source::<()>(); - button.set_color_scheme <+ all_with5(&non_toggled_color,&toggled_color,&hovered_color - ,&toggled_hovered_color,&init_color_scheme - ,|&non_toggled,&toggled,&hovered,&toggled_hovered,_| + button.set_color_scheme <+ all_with3( + &toggled_color, + &toggled_hovered_color, + &init_color_scheme, + f!([style_sheet] (&toggled, &toggled_hovered, _) toggle_button::ColorScheme { - non_toggled : Some(non_toggled.into()), toggled : Some(toggled.into()), - hovered : Some(hovered.into()), toggled_hovered : Some(toggled_hovered.into()), - ..default() + ..toggle_button::default_color_scheme(&style_sheet) } + ) ); } diff --git a/app/gui/view/graph-editor/src/component/visualization/container.rs b/app/gui/view/graph-editor/src/component/visualization/container.rs index 31406f2115eb..cc135403a8f8 100644 --- a/app/gui/view/graph-editor/src/component/visualization/container.rs +++ b/app/gui/view/graph-editor/src/component/visualization/container.rs @@ -569,10 +569,8 @@ impl Container { selected_definition <- action_bar.visualisation_selection.map(f!([registry](path) path.as_ref().and_then(|path| registry.definition_from_path(path)) )); - action_bar.hide_icons <+ selected_definition.constant(()); + action_bar.set_vis_input_type <+ frp.set_vis_input_type; frp.source.vis_input_type <+ frp.set_vis_input_type; - let chooser = &model.action_bar.visualization_chooser(); - chooser.frp.set_vis_input_type <+ frp.set_vis_input_type; } diff --git a/app/gui/view/graph-editor/src/component/visualization/container/action_bar.rs b/app/gui/view/graph-editor/src/component/visualization/container/action_bar.rs index f9e2cd28ea5e..7e7db0845f6c 100644 --- a/app/gui/view/graph-editor/src/component/visualization/container/action_bar.rs +++ b/app/gui/view/graph-editor/src/component/visualization/container/action_bar.rs @@ -7,6 +7,7 @@ use ensogl::display::traits::*; use crate::component::node; use crate::component::visualization; use crate::component::visualization::container::visualization_chooser::VisualizationChooser; +use crate::data::enso; use enso_frp as frp; use enso_frp; @@ -234,6 +235,7 @@ ensogl::define_endpoints! { show_icons (), hide_icons (), set_selected_visualization (Option), + set_vis_input_type (Option), } Output { @@ -389,7 +391,11 @@ impl ActionBar { hide <- any(mouse_out_no_menu,remote_click); eval_ hide (model.hide()); - frp.source.visualisation_selection <+ visualization_chooser.chosen_entry; + // The action bar does not allow to deselect the visualisation, so we prohibit these + // events, which can occur on re-initialization. + has_selection <- visualization_chooser.chosen_entry.is_some(); + frp.source.visualisation_selection + <+ visualization_chooser.chosen_entry.gate(&has_selection); let reset_position_icon = &model.icons.reset_position_icon.events_deprecated; let reset_position_icon_down = reset_position_icon.mouse_down_primary.clone_ref(); @@ -403,14 +409,29 @@ impl ActionBar { show_reset_icon <- bool(&reset_position_icon_down,start_dragging); eval show_reset_icon((visibility) model.icons.set_reset_icon_visibility(*visibility)); + + + // === Visualisation Chooser === + + // Note: we only want to update the chooser if it is visible, or when it becomes + // visible. During startup we get the type information for every node, and propagate + // this information to the chooser. By delaying the creation of UI list elements until + // the chooser is visible, we can avoid creating a lot of UI elements that are never + // used. + + visible <- any(&frp.show_icons, &any_component_over); + hidden <- any(&frp.hide_icons, &hide); + + is_visible <- bool(&hidden, &visible); + on_show <- is_visible.on_change().on_true(); + + set_vis_input_while_visible <- frp.input.set_vis_input_type.gate(&is_visible); + set_vis_input_on_show <- frp.input.set_vis_input_type.sample(&on_show); + set_vis_input <- any(&set_vis_input_while_visible, &set_vis_input_on_show).on_change(); + visualization_chooser.input.set_vis_input_type <+ set_vis_input; } self } - - /// Visualization Chooser component getter. - pub fn visualization_chooser(&self) -> &VisualizationChooser { - &self.model.visualization_chooser - } } impl display::Object for ActionBar { diff --git a/app/gui/view/graph-editor/src/execution_environment.rs b/app/gui/view/graph-editor/src/execution_environment.rs index 119d2baa0155..9fdf0acef528 100644 --- a/app/gui/view/graph-editor/src/execution_environment.rs +++ b/app/gui/view/graph-editor/src/execution_environment.rs @@ -41,12 +41,13 @@ pub fn init_frp(frp: &Frp, model: &GraphEditorModelWithNetwork) { // === Layout === init <- source::<()>(); - size_update <- all(init,selector.size,inputs.space_for_window_buttons); - eval size_update ([model]((_,size,gap_size)) { + size_update <- all(init, selector.size, inputs.graph_editor_top_bar_offset_x); + eval size_update ([model] ((_, size, graph_editor_top_bar_offset_x)) { let y_offset = MACOS_TRAFFIC_LIGHTS_VERTICAL_CENTER; let traffic_light_width = traffic_lights_gap_width(); - let execution_environment_selector_x = gap_size.x + traffic_light_width; + let execution_environment_selector_x = + graph_editor_top_bar_offset_x + traffic_light_width; model.execution_environment_selector.set_x(execution_environment_selector_x); let breadcrumb_gap_width = execution_environment_selector_x + size.x + TOP_BAR_ITEM_MARGIN; diff --git a/app/gui/view/graph-editor/src/lib.rs b/app/gui/view/graph-editor/src/lib.rs index 90a25dd43d3f..4fa9141c976b 100644 --- a/app/gui/view/graph-editor/src/lib.rs +++ b/app/gui/view/graph-editor/src/lib.rs @@ -111,7 +111,8 @@ const MACOS_TRAFFIC_LIGHTS_CONTENT_WIDTH: f32 = 52.0; const MACOS_TRAFFIC_LIGHTS_CONTENT_HEIGHT: f32 = 12.0; /// Horizontal and vertical offset between traffic lights and window border const MACOS_TRAFFIC_LIGHTS_SIDE_OFFSET: f32 = 13.0; -const MACOS_TRAFFIC_LIGHTS_VERTICAL_CENTER: f32 = +/// The vertical center of the traffic lights, relative to the window border. +pub const MACOS_TRAFFIC_LIGHTS_VERTICAL_CENTER: f32 = -MACOS_TRAFFIC_LIGHTS_SIDE_OFFSET - MACOS_TRAFFIC_LIGHTS_CONTENT_HEIGHT / 2.0; const MAX_ZOOM: f32 = 1.0; /// Space between items in the top bar. @@ -124,7 +125,7 @@ fn traffic_lights_gap_width() -> f32 { if is_macos && !ARGS.groups.window.options.frame.value { MACOS_TRAFFIC_LIGHTS_CONTENT_WIDTH + MACOS_TRAFFIC_LIGHTS_SIDE_OFFSET } else { - TOP_BAR_ITEM_MARGIN + 0.0 } } @@ -451,7 +452,10 @@ ensogl::define_endpoints_2! { // === Layout === - space_for_window_buttons (Vector2), + + /// The offset in the x-axis at which the part of the top bar of the graph editor should + /// start. + graph_editor_top_bar_offset_x (f32), // === Read-only mode === @@ -2025,6 +2029,7 @@ impl GraphEditorModel { /// Warning! This function does not remove connected edges. It needs to be handled by the /// implementation. + #[profile(Debug)] fn remove_node(&self, node_id: impl Into) { let node_id = node_id.into(); self.nodes.remove(&node_id); @@ -2272,6 +2277,7 @@ impl GraphEditorModel { impl GraphEditorModel { #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. + #[profile(Debug)] pub fn set_node_position(&self, node_id: impl Into, position: Vector2) { let node_id = node_id.into(); if let Some(node) = self.nodes.get_cloned_ref(&node_id) { diff --git a/app/gui/view/graph-editor/src/shortcuts.rs b/app/gui/view/graph-editor/src/shortcuts.rs index 951499cd6428..fe5494e18afc 100644 --- a/app/gui/view/graph-editor/src/shortcuts.rs +++ b/app/gui/view/graph-editor/src/shortcuts.rs @@ -10,15 +10,35 @@ use ensogl::application::shortcut::ActionType::*; /// The list of all shortcuts used in the graph editor. pub const SHORTCUTS: &[(ensogl::application::shortcut::ActionType, &str, &str, &str)] = &[ - (Press, "!node_editing & !read_only", "tab", "start_node_creation"), - (Press, "!node_editing & !read_only", "enter", "start_node_creation"), + ( + Press, + "!node_editing & !read_only & !is_fs_visualization_displayed", + "tab", + "start_node_creation", + ), + ( + Press, + "!node_editing & !read_only & !is_fs_visualization_displayed", + "enter", + "start_node_creation", + ), // === Drag === (Press, "", "left-mouse-button", "node_press"), (Release, "", "left-mouse-button", "node_release"), - (Press, "!node_editing & !read_only", "backspace", "remove_selected_nodes"), - (Press, "!node_editing & !read_only", "delete", "remove_selected_nodes"), + ( + Press, + "!node_editing & !read_only & !is_fs_visualization_displayed", + "backspace", + "remove_selected_nodes", + ), + ( + Press, + "!node_editing & !read_only & !is_fs_visualization_displayed", + "delete", + "remove_selected_nodes", + ), (Press, "has_detached_edge", "escape", "drop_dragged_edge"), - (Press, "!read_only", "cmd g", "collapse_selected_nodes"), + (Press, "!read_only & !is_fs_visualization_displayed", "cmd g", "collapse_selected_nodes"), // === Visualization === (Press, "!node_editing", "space", "press_visualization_visibility"), ( @@ -51,11 +71,21 @@ pub const SHORTCUTS: &[(ensogl::application::shortcut::ActionType, &str, &str, & "ctrl space", "cycle_visualization_for_selected_node", ), - (DoublePress, "!read_only", "left-mouse-button", "enter_hovered_node"), + ( + DoublePress, + "!read_only & !is_fs_visualization_displayed", + "left-mouse-button", + "enter_hovered_node", + ), (DoublePress, "!read_only", "left-mouse-button", "start_node_creation_from_port"), (Press, "!read_only", "right-mouse-button", "start_node_creation_from_port"), - (Press, "!node_editing & !read_only", "cmd enter", "enter_selected_node"), - (Press, "!read_only", "alt enter", "exit_node"), + ( + Press, + "!node_editing & !read_only & !is_fs_visualization_displayed", + "cmd enter", + "enter_selected_node", + ), + (Press, "!read_only & !is_fs_visualization_displayed", "alt enter", "exit_node"), // === Node Editing === (Press, "!read_only", "cmd", "edit_mode_on"), (Release, "!read_only", "cmd", "edit_mode_off"), diff --git a/app/gui/view/src/lib.rs b/app/gui/view/src/lib.rs index 96853628a874..c704eaac39de 100644 --- a/app/gui/view/src/lib.rs +++ b/app/gui/view/src/lib.rs @@ -38,7 +38,6 @@ pub mod project_list; pub mod root; pub mod searcher; pub mod status_bar; -pub mod window_control_buttons; pub use ide_view_component_browser as component_browser; pub use ide_view_documentation as documentation; diff --git a/app/gui/view/src/project.rs b/app/gui/view/src/project.rs index e1c4123c3e9e..f03952b5f159 100644 --- a/app/gui/view/src/project.rs +++ b/app/gui/view/src/project.rs @@ -24,12 +24,19 @@ use ensogl::application::shortcut; use ensogl::application::Application; use ensogl::display; use ensogl::system::web; -use ensogl::system::web::dom; use ensogl::DEPRECATED_Animation; use ensogl_component::text; use ensogl_component::text::selection::Selection; use ensogl_hardcoded_theme::Theme; use ide_view_graph_editor::NodeSource; +use project_view_top_bar::ProjectViewTopBar; + + +// ============== +// === Export === +// ============== + +pub mod project_view_top_bar; @@ -127,6 +134,7 @@ ensogl::define_endpoints! { fullscreen_visualization_shown (bool), drop_files_enabled (bool), debug_mode (bool), + go_to_dashboard_button_pressed (), } } @@ -138,22 +146,20 @@ ensogl::define_endpoints! { #[derive(Clone, CloneRef, Debug)] struct Model { - app: Application, - display_object: display::object::Instance, - /// These buttons are present only in a cloud environment. - window_control_buttons: Immutable>, - graph_editor: Rc, - searcher: component_browser::View, - code_editor: code_editor::View, - fullscreen_vis: Rc>>, - project_list: Rc, - debug_mode_popup: debug_mode_popup::View, - popup: popup::View, + app: Application, + display_object: display::object::Instance, + project_view_top_bar: ProjectViewTopBar, + graph_editor: Rc, + searcher: component_browser::View, + code_editor: code_editor::View, + fullscreen_vis: Rc>>, + project_list: Rc, + debug_mode_popup: debug_mode_popup::View, + popup: popup::View, } impl Model { fn new(app: &Application) -> Self { - let scene = &app.display.default_scene; let display_object = display::object::Instance::new(); let searcher = app.new_view::(); let graph_editor = app.new_view::(); @@ -161,14 +167,7 @@ impl Model { let fullscreen_vis = default(); let debug_mode_popup = debug_mode_popup::View::new(app); let popup = popup::View::new(app); - let runs_in_web = ARGS.groups.startup.options.platform.value == "web"; - let window_control_buttons = runs_in_web.as_some_from(|| { - let window_control_buttons = app.new_view::(); - display_object.add_child(&window_control_buttons); - scene.layers.panel.add(&window_control_buttons); - window_control_buttons - }); - let window_control_buttons = Immutable(window_control_buttons); + let project_view_top_bar = ProjectViewTopBar::new(app); let project_list = Rc::new(ProjectList::new(app)); display_object.add_child(&graph_editor); @@ -176,6 +175,7 @@ impl Model { display_object.add_child(&searcher); display_object.add_child(&debug_mode_popup); display_object.add_child(&popup); + display_object.add_child(&project_view_top_bar); display_object.remove_child(&searcher); let app = app.clone_ref(); @@ -183,7 +183,7 @@ impl Model { Self { app, display_object, - window_control_buttons, + project_view_top_bar, graph_editor, searcher, code_editor, @@ -263,12 +263,18 @@ impl Model { } } - fn on_dom_shape_changed(&self, shape: &dom::shape::Shape) { - // Top buttons must always stay in top-left corner. - if let Some(window_control_buttons) = &*self.window_control_buttons { - let pos = Vector2(-shape.width, shape.height) / 2.0; - window_control_buttons.set_xy(pos); - } + fn position_project_view_top_bar( + &self, + scene_shape: &display::scene::Shape, + project_view_top_bar_size: Vector2, + ) { + let top_left = Vector2(-scene_shape.width, scene_shape.height) / 2.0; + let project_view_top_bar_origin = Vector2( + 0.0, + crate::graph_editor::MACOS_TRAFFIC_LIGHTS_VERTICAL_CENTER + - project_view_top_bar_size.y / 2.0, + ); + self.project_view_top_bar.set_xy(top_left + project_view_top_bar_origin); } fn on_close_clicked(&self) { @@ -368,6 +374,7 @@ impl View { let frp = Frp::new(); let network = &frp.network; let searcher = &model.searcher.frp(); + let project_view_top_bar = &model.project_view_top_bar; let graph = &model.graph_editor.frp; let code_editor = &model.code_editor; let project_list = &model.project_list; @@ -378,27 +385,36 @@ impl View { model.set_style(theme); let input_change_delay = frp::io::timer::Timeout::new(network); - if let Some(window_control_buttons) = &*model.window_control_buttons { - let initial_size = &window_control_buttons.size.value(); - model.graph_editor.input.space_for_window_buttons(initial_size); - frp::extend! { network - graph.space_for_window_buttons <+ window_control_buttons.size; - eval_ window_control_buttons.close (model.on_close_clicked()); - eval_ window_control_buttons.fullscreen (model.on_fullscreen_clicked()); - } - } - - let shape = scene.shape().clone_ref(); - frp::extend! { network - init <- source::<()>(); - shape <- all(shape, init)._0(); - eval shape ((shape) model.on_dom_shape_changed(shape)); + init <- source_(); eval_ frp.show_graph_editor(model.show_graph_editor()); eval_ frp.hide_graph_editor(model.hide_graph_editor()); + // === Project View Top Bar === + + let window_control_buttons = &project_view_top_bar.window_control_buttons; + eval_ window_control_buttons.close (model.on_close_clicked()); + eval_ window_control_buttons.fullscreen (model.on_fullscreen_clicked()); + let go_to_dashboard_button = &project_view_top_bar.go_to_dashboard_button; + frp.source.go_to_dashboard_button_pressed <+ + go_to_dashboard_button.is_pressed.on_true(); + + let project_view_top_bar_display_object = project_view_top_bar.display_object(); + _eval <- all_with3( + &init, + scene.shape(), + &project_view_top_bar_display_object.on_resized, + f!((_, scene_shape, project_view_top_bar_size) + model.position_project_view_top_bar(scene_shape, *project_view_top_bar_size) + ) + ); + project_view_top_bar_width <- + project_view_top_bar_display_object.on_resized.map(|new_size| new_size.x); + graph.graph_editor_top_bar_offset_x <+ project_view_top_bar_width; + + // === Read-only mode === graph.set_read_only <+ frp.set_read_only; @@ -567,7 +583,7 @@ impl View { // === Project Dialog === eval_ frp.show_project_list (model.show_project_list()); - project_chosen <- project_list.grid.entry_selected.constant(()); + project_chosen <- project_list.frp.selected_project.constant(()); mouse_down <- scene.mouse.frp_deprecated.down.constant(()); clicked_on_bg <- mouse_down.filter(f_!(scene.mouse.target.get().is_background())); should_be_closed <- any(frp.hide_project_list,project_chosen,clicked_on_bg); diff --git a/app/gui/view/src/project/project_view_top_bar.rs b/app/gui/view/src/project/project_view_top_bar.rs new file mode 100644 index 000000000000..e56732381af6 --- /dev/null +++ b/app/gui/view/src/project/project_view_top_bar.rs @@ -0,0 +1,72 @@ +//! Defines a UI container for the window control buttons and the "go to dashboard" button. This is +//! merely here to make use of the auto-layout functionality. + +use ensogl::prelude::*; + +use enso_config::ARGS; +use ensogl::application::Application; +use ensogl::display; + + + +mod go_to_dashboard_button; +pub mod window_control_buttons; + + + +// ================= +// === Constants === +// ================= + +/// The gap in pixels between the various components of the project view top bar. +const GAP: f32 = 16.0; +/// The padding left of the project view top bar. +const PADDING_LEFT: f32 = 19.0; + + + +// ============================ +// === Project View Top Bar === +// ============================ + +/// Defines a UI container for the window control buttons and the "go to dashboard" button. This is +/// merely here to make use of the auto-layout functionality. +#[derive(Clone, CloneRef, Debug)] +#[allow(missing_docs)] +pub struct ProjectViewTopBar { + root: display::object::Instance, + /// These buttons are only visible in a cloud environment. + pub window_control_buttons: window_control_buttons::View, + pub go_to_dashboard_button: go_to_dashboard_button::View, +} + +impl ProjectViewTopBar { + /// Constructor. + pub fn new(app: &Application) -> Self { + let root = display::object::Instance::new_named("ProjectViewTopBar"); + let window_control_buttons = app.new_view::(); + let go_to_dashboard_button = go_to_dashboard_button::View::new(app); + + if ARGS.groups.startup.options.platform.value == "web" { + root.add_child(&window_control_buttons); + } + root.add_child(&go_to_dashboard_button); + root.use_auto_layout() + .set_gap((GAP, 0.0)) + .set_padding_left(PADDING_LEFT) + // We use `GAP` as the right padding since it delimits the space to the part of the top + // bar that's defined in the graph editor. + .set_padding_right(GAP) + .set_children_alignment_center(); + + app.display.default_scene.layers.panel.add(&root); + + Self { root, window_control_buttons, go_to_dashboard_button } + } +} + +impl display::Object for ProjectViewTopBar { + fn display_object(&self) -> &display::object::Instance { + &self.root + } +} diff --git a/app/gui/view/src/project/project_view_top_bar/go_to_dashboard_button.rs b/app/gui/view/src/project/project_view_top_bar/go_to_dashboard_button.rs new file mode 100644 index 000000000000..196fb97b00d9 --- /dev/null +++ b/app/gui/view/src/project/project_view_top_bar/go_to_dashboard_button.rs @@ -0,0 +1,101 @@ +//! Provides a button to switch back from the project view to the dashboard. + +use crate::prelude::*; + +use ensogl::application::tooltip; +use ensogl::application::Application; +use ensogl::display; +use ensogl::display::style; +use ensogl_component::toggle_button; +use ensogl_component::toggle_button::ToggleButton; + + + +// ================= +// === Constants === +// ================= + +/// The width and height of the button. +pub const SIZE: f32 = 16.0; + + + +// ============ +// === Icon === +// ============ + +/// Defines an icon for returning to the dashboard. It looks like a hamburger button. +mod icon { + use super::*; + + use ensogl::data::color; + use ensogl_component::toggle_button::ColorableShape; + + ensogl::shape! { + (style: Style, color_rgba: Vector4) { + let fill_color = Var::::from(color_rgba); + let width = Var::::from("input_size.x"); + let height = Var::::from("input_size.y"); + let unit = &width / SIZE; + let mid_bar = Rect((&unit * 12.0, &unit * 3.0)).corners_radius(&unit); + let top_bar = mid_bar.translate_y(&unit * -5.0); + let bottom_bar = mid_bar.translate_y(&unit * 5.0); + let all_bars = top_bar + mid_bar + bottom_bar; + let shape = all_bars.fill(fill_color); + let hover_area = Rect((&width, &height)).fill(INVISIBLE_HOVER_COLOR); + (shape + hover_area).into() + } + } + + impl ColorableShape for Shape { + fn set_color(&self, color: color::Rgba) { + self.color_rgba.set(Vector4::new(color.red, color.green, color.blue, color.alpha)); + } + } +} + + + +// ============ +// === View === +// ============ + +/// Provides a button to switch back from the project view to the dashboard. +#[derive(Debug, Clone, CloneRef, Deref)] +pub struct View { + button: ToggleButton, +} + +impl View { + /// Constructor. + pub fn new(app: &Application) -> Self { + let scene = &app.display.default_scene; + + let tooltip_style = tooltip::Style::set_label("Dashboard".to_owned()) + .with_placement(tooltip::Placement::Right); + let button = ToggleButton::::new(app, tooltip_style); + scene.layers.panel.add(&button); + + button.set_color_scheme(Self::color_scheme(&scene.style_sheet)); + button.set_size(Vector2(SIZE, SIZE)); + + Self { button } + } + + fn color_scheme(style_sheet: &style::Sheet) -> toggle_button::ColorScheme { + let default_color_scheme = toggle_button::default_color_scheme(style_sheet); + toggle_button::ColorScheme { + // Make it look like a normal button (as opposed to a toggle button) by not having a + // toggled state visually. + toggled: default_color_scheme.non_toggled, + toggled_hovered: default_color_scheme.hovered, + ..default_color_scheme + } + } +} + +impl display::Object for View { + fn display_object(&self) -> &display::object::Instance { + self.button.display_object() + } +} diff --git a/app/gui/view/src/project/project_view_top_bar/window_control_buttons.rs b/app/gui/view/src/project/project_view_top_bar/window_control_buttons.rs new file mode 100644 index 000000000000..a2ce8b6cfd02 --- /dev/null +++ b/app/gui/view/src/project/project_view_top_bar/window_control_buttons.rs @@ -0,0 +1,199 @@ +//! The component with buttons in the top left corner. See [[View]]. + +use ensogl::display::shape::*; +use ensogl::prelude::*; + +use enso_frp as frp; +use ensogl::application; +use ensogl::application::Application; +use ensogl::display; +use ensogl::display::object::ObjectOps; +use ensogl::shape; +use ensogl_hardcoded_theme::application::window_control_buttons as theme; + + +// ============== +// === Export === +// ============== + +pub mod close; +pub mod fullscreen; + + + +// ============== +// === Shapes === +// ============== + +mod shape { + use super::*; + shape! { + alignment = center; + (style: Style) { + Plane().fill(INVISIBLE_HOVER_COLOR).into() + } + } +} + + + +// ============= +// === Model === +// ============= + +/// An internal model of Status Bar component +#[derive(Clone, CloneRef, Debug)] +pub struct Model { + app: Application, + display_object: display::object::Instance, + shape: shape::View, + close: close::View, + fullscreen: fullscreen::View, +} + +impl Model { + /// Constructor. + pub fn new(app: &Application) -> Self { + let app = app.clone_ref(); + let display_object = display::object::Instance::new_named("WindowControlButtons"); + + ensogl::shapes_order_dependencies! { + app.display.default_scene => { + shape -> close::shape; + shape -> fullscreen::shape; + } + }; + let close = close::View::new(&app); + display_object.add_child(&close); + + let fullscreen = fullscreen::View::new(&app); + display_object.add_child(&fullscreen); + + let shape = shape::View::new(); + display_object.add_child(&shape); + + app.display.default_scene.layers.panel.add(&display_object); + + Self { app, display_object, shape, close, fullscreen } + } + + /// Updates positions of the buttons and sizes of the mouse area. + pub fn set_layout(&self, spacing: f32) { + let close_size = self.close.size.value(); + let fullscreen_size = self.fullscreen.size.value(); + let fullscreen_offset = Vector2(close_size.x + spacing, 0.0); + self.fullscreen.set_xy(fullscreen_offset); + let width = fullscreen_offset.x + fullscreen_size.x; + let height = max(close_size.y, fullscreen_size.y); + let size = Vector2(width, height); + self.shape.set_size(size); + self.display_object.set_size(size); + } +} + + + +// =========== +// === FRP === +// =========== + +ensogl::define_endpoints! { + Input { + enabled (bool), + } + Output { + close(), + fullscreen(), + } +} + + + +// ============ +// === View === +// ============ + +/// The Top Buttons Panel component. +/// +/// The panel contains two buttons: one for closing IDE and one for toggling the fullscreen mode. +/// The panel is meant to be displayed only when IDE runs in a cloud environment. +#[derive(Clone, CloneRef, Debug)] +pub struct View { + #[allow(missing_docs)] + pub frp: Frp, + model: Model, + style: StyleWatchFrp, +} + +impl View { + /// Constructor. + pub fn new(app: &Application) -> Self { + let frp = Frp::new(); + let model = Model::new(app); + let network = &frp.network; + + let style = StyleWatchFrp::new(&app.display.default_scene.style_sheet); + let radius = style.get_number(theme::radius); + let spacing = style.get_number(theme::spacing); + + frp::extend! { network + // Layout + button_size <- radius.map(|&r| Vector2(2.0 * r, 2.0 * r)); + model.close.set_size <+ button_size; + model.fullscreen.set_size <+ button_size; + button_resized <- any_(&model.close.size, &model.fullscreen.size); + _eval <- all_with(&button_resized, &spacing, + f!((_, spacing) model.set_layout(*spacing)) + ); + + // Handle the panel-wide hover + mouse_near_buttons <- bool( + &model.shape.events_deprecated.mouse_out, + &model.shape.events_deprecated.mouse_over + ); + mouse_on_any_buttton <- model.close.is_hovered.or(&model.fullscreen.is_hovered); + mouse_nearby <- mouse_near_buttons.or(&mouse_on_any_buttton); + model.close.mouse_nearby <+ mouse_nearby; + model.fullscreen.mouse_nearby <+ mouse_nearby; + + // === Handle buttons' clicked events === + frp.source.close <+ model.close.clicked; + frp.source.fullscreen <+ model.fullscreen.clicked; + } + + model.set_layout(spacing.value()); + + Self { frp, model, style } + } +} + +impl display::Object for View { + fn display_object(&self) -> &display::object::Instance { + &self.model.display_object + } +} + +impl Deref for View { + type Target = Frp; + fn deref(&self) -> &Self::Target { + &self.frp + } +} + +impl FrpNetworkProvider for View { + fn network(&self) -> &frp::Network { + &self.frp.network + } +} + +impl application::View for View { + fn label() -> &'static str { + "TopButtons" + } + fn new(app: &Application) -> Self { + View::new(app) + } + fn app(&self) -> &Application { + &self.model.app + } +} diff --git a/app/gui/view/src/window_control_buttons/close.rs b/app/gui/view/src/project/project_view_top_bar/window_control_buttons/close.rs similarity index 98% rename from app/gui/view/src/window_control_buttons/close.rs rename to app/gui/view/src/project/project_view_top_bar/window_control_buttons/close.rs index 1c4369309455..15cd29c2f0a5 100644 --- a/app/gui/view/src/window_control_buttons/close.rs +++ b/app/gui/view/src/project/project_view_top_bar/window_control_buttons/close.rs @@ -20,7 +20,6 @@ pub mod shape { use super::*; ensogl::shape! { - alignment = center; (style: Style, background_color: Vector4, icon_color: Vector4) { let size = Var::canvas_size(); let radius = Min::min(size.x(),size.y()) / 2.0; diff --git a/app/gui/view/src/window_control_buttons/fullscreen.rs b/app/gui/view/src/project/project_view_top_bar/window_control_buttons/fullscreen.rs similarity index 98% rename from app/gui/view/src/window_control_buttons/fullscreen.rs rename to app/gui/view/src/project/project_view_top_bar/window_control_buttons/fullscreen.rs index 5688cffcfb54..4a52c2e4a30f 100644 --- a/app/gui/view/src/window_control_buttons/fullscreen.rs +++ b/app/gui/view/src/project/project_view_top_bar/window_control_buttons/fullscreen.rs @@ -20,7 +20,6 @@ pub use ensogl_hardcoded_theme::application::window_control_buttons::fullscreen pub mod shape { use super::*; ensogl::shape! { - alignment = center; (style: Style, background_color: Vector4, icon_color: Vector4) { let size = Var::canvas_size(); let radius = Min::min(size.x(),size.y()) / 2.0; diff --git a/app/gui/view/src/project_list.rs b/app/gui/view/src/project_list.rs index 9936b1e49036..71177c7f2ff9 100644 --- a/app/gui/view/src/project_list.rs +++ b/app/gui/view/src/project_list.rs @@ -4,6 +4,7 @@ use crate::prelude::*; use ensogl::display::shape::*; +use engine_protocol::project_manager::ProjectMetadata; use enso_frp as frp; use ensogl::application::frp::API; use ensogl::application::Application; @@ -196,6 +197,23 @@ mod background { +// =========== +// === FRP === +// =========== + +ensogl::define_endpoints! { + Input { + /// This is a list of projects to choose from. + project_list (Vec), + } + Output { + /// This is the selected project. + selected_project (Option), + } +} + + + // =================== // === ProjectList === // =================== @@ -205,18 +223,19 @@ mod background { /// This is a list of projects in a nice frame with a title. #[derive(Clone, CloneRef, Debug)] pub struct ProjectList { - network: frp::Network, display_object: display::object::Instance, background: background::View, caption: text::Text, + grid: grid_view::scrollable::SelectableGridView, #[allow(missing_docs)] - pub grid: grid_view::scrollable::SelectableGridView, + pub frp: Frp, } impl ProjectList { /// Create Project List Component. pub fn new(app: &Application) -> Self { - let network = frp::Network::new("ProjectList"); + let frp = Frp::new(); + let network = &frp.network; let display_object = display::object::Instance::new(); let background = background::View::new(); let caption = app.new_view::(); @@ -236,7 +255,7 @@ impl ProjectList { } let style_frp = StyleWatchFrp::new(&app.display.default_scene.style_sheet); - let style = Style::from_theme(&network, &style_frp); + let style = Style::from_theme(network, &style_frp); frp::extend! { network init <- source::<()>(); @@ -274,11 +293,30 @@ impl ProjectList { grid_x <- grid_width.map(|width| -width / 2.0); grid_y <- all_with3(&content_size, &bar_height, &paddings, |s,h,p| s.y / 2.0 - *h - *p); _eval <- all_with(&grid_x, &grid_y, f!((x, y) grid.set_xy(Vector2(*x, *y)))); + + grid.reset_entries <+ frp.input.project_list.map(|projects| (projects.len(), 1)); + grid_model_for_entry <= grid.model_for_entry_needed.map2( + &frp.input.project_list, + |(row, col), projects| { + let project = projects.get(*row)?; + Some((*row, *col, project.name.clone().into())) + } + ); + grid.model_for_entry <+ grid_model_for_entry; + + frp.source.selected_project <+ grid.entry_selected.map2( + &frp.input.project_list, + |selected_entry, projects| { + let (row, _) = (*selected_entry)?; + projects.get(row).cloned() + } + ); + grid.select_entry <+ frp.output.selected_project.filter_map(|s| s.as_ref().map(|_| None)); } style.init.emit(()); init.emit(()); - Self { network, display_object, background, caption, grid } + Self { display_object, background, caption, grid, frp } } } diff --git a/app/gui/view/src/root.rs b/app/gui/view/src/root.rs index a11d9b4954df..11984b2f081e 100644 --- a/app/gui/view/src/root.rs +++ b/app/gui/view/src/root.rs @@ -6,6 +6,7 @@ use ensogl::prelude::*; +use engine_protocol::project_manager::ProjectMetadata; use enso_frp as frp; use ensogl::application; use ensogl::application::Application; @@ -38,11 +39,12 @@ pub struct Model { status_bar: crate::status_bar::View, welcome_view: crate::welcome_screen::View, project_view: Rc>>, + frp: Frp, } impl Model { /// Constuctor. - pub fn new(app: &Application) -> Self { + pub fn new(app: &Application, frp: &Frp) -> Self { let app = app.clone_ref(); let display_object = display::object::Instance::new(); let state = Rc::new(CloneCell::new(State::WelcomeScreen)); @@ -51,8 +53,9 @@ impl Model { let welcome_view = app.new_view::(); let project_view = Rc::new(CloneCell::new(None)); display_object.add_child(&welcome_view); + let frp = frp.clone_ref(); - Self { app, display_object, status_bar, welcome_view, project_view, state } + Self { app, display_object, state, status_bar, welcome_view, project_view, frp } } /// Switch displayed view from Project View to Welcome Screen. Project View will not be @@ -82,6 +85,10 @@ impl Model { fn init_project_view(&self) { if self.project_view.get().is_none() { let view = self.app.new_view::(); + let project_list_frp = &view.project_list().frp; + frp::extend! { network + self.frp.source.selected_project <+ project_list_frp.selected_project; + } self.project_view.set(Some(view)); } } @@ -101,6 +108,8 @@ ensogl::define_endpoints! { switch_view_to_welcome_screen(), } Output { + /// The selected project in the project list + selected_project (Option), } } @@ -128,8 +137,8 @@ impl Deref for View { impl View { /// Constuctor. pub fn new(app: &Application) -> Self { - let model = Model::new(app); let frp = Frp::new(); + let model = Model::new(app, &frp); let network = &frp.network; let style = StyleWatchFrp::new(&app.display.default_scene.style_sheet); let offset_y = style.get_number(ensogl_hardcoded_theme::application::status_bar::offset_y); diff --git a/app/gui/view/src/window_control_buttons.rs b/app/gui/view/src/window_control_buttons.rs deleted file mode 100644 index a4f017b4df99..000000000000 --- a/app/gui/view/src/window_control_buttons.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! The component with buttons in the top left corner. See [[View]]. - -use ensogl::display::shape::*; -use ensogl::prelude::*; - -use enso_frp as frp; -use ensogl::application; -use ensogl::application::Application; -use ensogl::display; -use ensogl::display::object::ObjectOps; -use ensogl::shape; -use ensogl_hardcoded_theme::application::window_control_buttons as theme; - - -// ============== -// === Export === -// ============== - -pub mod close; -pub mod fullscreen; - - - -// ============== -// === Shapes === -// ============== - -mod shape { - use super::*; - shape! { - alignment = center; - (style: Style) { - Plane().fill(INVISIBLE_HOVER_COLOR).into() - } - } -} - - - -// ============== -// === Layout === -// ============== - -/// Information on how to layout shapes in the top buttons panel, as defined in the theme. -#[allow(missing_docs)] -#[derive(Clone, Copy, Debug)] -pub struct LayoutParams { - pub spacing: T, - pub padding_left: T, - pub padding_top: T, - pub padding_right: T, - pub padding_bottom: T, -} - -impl Default for LayoutParams { - fn default() -> Self { - Self { - spacing: 8.0, - padding_left: 13.0, - padding_top: 13.0, - padding_right: 13.0, - padding_bottom: 13.0, - } - } -} - -impl LayoutParams { - /// Applies a given function over all stored values and return layout with resulting values. - pub fn map(&self, f: impl Fn(&T) -> U) -> LayoutParams { - LayoutParams { - spacing: f(&self.spacing), - padding_left: f(&self.padding_left), - padding_top: f(&self.padding_top), - padding_right: f(&self.padding_right), - padding_bottom: f(&self.padding_bottom), - } - } -} - -impl LayoutParams> { - /// Get layout from theme. Each layout parameter will be an frp sampler. - pub fn from_theme(style: &StyleWatchFrp) -> Self { - let default = LayoutParams::default(); - let spacing = style.get_number_or(theme::spacing, default.spacing); - let padding_left = style.get_number_or(theme::padding::left, default.padding_left); - let padding_top = style.get_number_or(theme::padding::top, default.padding_top); - let padding_right = style.get_number_or(theme::padding::right, default.padding_right); - let padding_bottom = style.get_number_or(theme::padding::bottom, default.padding_bottom); - Self { spacing, padding_left, padding_top, padding_right, padding_bottom } - } - - /// Take values from the parameters' samplers. - pub fn value(&self) -> LayoutParams { - self.map(|sampler| sampler.value()) - } - - /// Join all member frp streams into a single stream with aggregated values. - pub fn flatten(&self, network: &frp::Network) -> frp::Stream> { - /// Helper method that puts back LayoutParams from its fields. - /// Be careful, as the arguments must be in the same order as they are in `all_with5` - /// invocation below. - // We intentionally take references to f32 for seamless usage in FRP. - #[allow(clippy::trivially_copy_pass_by_ref)] - fn to_layout( - spacing: &f32, - padding_left: &f32, - padding_top: &f32, - padding_right: &f32, - padding_bottom: &f32, - ) -> LayoutParams { - let ret = - LayoutParams { spacing, padding_left, padding_top, padding_right, padding_bottom }; - ret.map(|v| **v) - } - - network.all_with5( - "TopButtonsLayoutStyle", - &self.spacing, - &self.padding_left, - &self.padding_top, - &self.padding_right, - &self.padding_bottom, - to_layout, - ) - } -} - - - -// ============= -// === Model === -// ============= - -/// An internal model of Status Bar component -#[derive(Clone, CloneRef, Debug)] -pub struct Model { - app: Application, - display_object: display::object::Instance, - shape: shape::View, - close: close::View, - fullscreen: fullscreen::View, -} - -impl Model { - /// Constructor. - pub fn new(app: &Application) -> Self { - let app = app.clone_ref(); - let display_object = display::object::Instance::new(); - - ensogl::shapes_order_dependencies! { - app.display.default_scene => { - shape -> close::shape; - shape -> fullscreen::shape; - } - }; - let close = close::View::new(&app); - display_object.add_child(&close); - - let fullscreen = fullscreen::View::new(&app); - display_object.add_child(&fullscreen); - - let shape = shape::View::new(); - display_object.add_child(&shape); - - Self { app, display_object, shape, close, fullscreen } - } - - /// Updates positions of the buttons and sizes of the mouse area. - /// Returns the new size of the panel (being also the size of mouse area). - pub fn set_layout(&self, layout: LayoutParams) -> Vector2 { - let LayoutParams { spacing, padding_left, padding_top, padding_right, padding_bottom } = - layout; - let close_size = self.close.size.value(); - let fullscreen_size = self.fullscreen.size.value(); - let padding_offset = Vector2(padding_left, -padding_top); - let origin_offset = |size: Vector2| Vector2(size.x / 2.0, -size.y / 2.0); - - self.close.set_xy(padding_offset + origin_offset(close_size)); - let fullscreen_x = padding_left + close_size.x + spacing; - self.fullscreen - .set_xy(Vector2(fullscreen_x, -padding_top) + origin_offset(fullscreen_size)); - - let width = fullscreen_x + fullscreen_size.x + padding_right; - let height = padding_top + max(close_size.y, fullscreen_size.y) + padding_bottom; - - let size = Vector2(width, height); - self.shape.set_xy(Vector2(size.x, -size.y) / 2.0); - self.shape.set_size(size); - size - } -} - - - -// =========== -// === FRP === -// =========== - -ensogl::define_endpoints! { - Input { - enabled (bool), - } - Output { - close(), - fullscreen(), - size(Vector2), - } -} - - - -// ============ -// === View === -// ============ - -/// The Top Buttons Panel component. -/// -/// The panel contains two buttons: one for closing IDE and one for toggling the fullscreen mode. -/// The panel is meant to be displayed only when IDE runs in a cloud environment. -#[derive(Clone, CloneRef, Debug)] -pub struct View { - frp: Frp, - model: Model, - style: StyleWatchFrp, -} - -impl View { - /// Constructor. - pub fn new(app: &Application) -> Self { - let frp = Frp::new(); - let model = Model::new(app); - let network = &frp.network; - - let style = StyleWatchFrp::new(&app.display.default_scene.style_sheet); - let style_frp = LayoutParams::from_theme(&style); - let layout_style = style_frp.flatten(network); - let radius = style.get_number(theme::radius); - - frp::extend! { network - // Layout - button_size <- radius.map(|&r| Vector2(2.0 * r, 2.0 * r)); - model.close.set_size <+ button_size; - model.fullscreen.set_size <+ button_size; - button_resized <- any_(&model.close.size,&model.fullscreen.size); - layout_on_button_change <- sample(&layout_style,&button_resized); - need_relayout <- any(&layout_style,&layout_on_button_change); - frp.source.size <+ need_relayout.map(f!((layout) model.set_layout(*layout))); - - // Handle the panel-wide hover - mouse_near_buttons <- bool( - &model.shape.events_deprecated.mouse_out, - &model.shape.events_deprecated.mouse_over - ); - mouse_on_any_buttton <- model.close.is_hovered.or(&model.fullscreen.is_hovered); - mouse_nearby <- mouse_near_buttons.or(&mouse_on_any_buttton); - model.close.mouse_nearby <+ mouse_nearby; - model.fullscreen.mouse_nearby <+ mouse_nearby; - - // === Handle buttons' clicked events === - frp.source.close <+ model.close.clicked; - frp.source.fullscreen <+ model.fullscreen.clicked; - } - - let initial_style = style_frp.value(); - let initial_size = model.set_layout(initial_style); - frp.source.size.emit(initial_size); - - Self { frp, model, style } - } -} - -impl display::Object for View { - fn display_object(&self) -> &display::object::Instance { - &self.model.display_object - } -} - -impl Deref for View { - type Target = Frp; - fn deref(&self) -> &Self::Target { - &self.frp - } -} - -impl FrpNetworkProvider for View { - fn network(&self) -> &frp::Network { - &self.frp.network - } -} - -impl application::View for View { - fn label() -> &'static str { - "TopButtons" - } - fn new(app: &Application) -> Self { - View::new(app) - } - fn app(&self) -> &Application { - &self.model.app - } -} diff --git a/app/ide-desktop/lib/client/start.ts b/app/ide-desktop/lib/client/start.ts index d4e8585ec934..1bca40140327 100644 --- a/app/ide-desktop/lib/client/start.ts +++ b/app/ide-desktop/lib/client/start.ts @@ -36,7 +36,7 @@ await esbuild.build(BUNDLER_OPTIONS) console.log('Linking GUI files.') await fs.symlink(path.join(GUI_PATH, 'assets'), path.join(IDE_PATH, 'assets'), 'dir') -console.log('LinkingProject Manager files.') +console.log('Linking Project Manager files.') await fs.symlink(PROJECT_MANAGER_BUNDLE, path.join(IDE_PATH, paths.PROJECT_MANAGER_BUNDLE), 'dir') console.log('Spawning Electron process.') diff --git a/app/ide-desktop/lib/client/tasks/signArchivesMacOs.ts b/app/ide-desktop/lib/client/tasks/signArchivesMacOs.ts index 725bbb5a4aa8..1c2b7e269063 100644 --- a/app/ide-desktop/lib/client/tasks/signArchivesMacOs.ts +++ b/app/ide-desktop/lib/client/tasks/signArchivesMacOs.ts @@ -49,6 +49,10 @@ async function graalSignables(resourcesDir: string): Promise { [`Contents/Home/jmods/jdk.javadoc.jmod`, ['bin/javadoc']], [`Contents/Home/jmods/jdk.jconsole.jmod`, ['bin/jconsole']], [`Contents/Home/jmods/jdk.javadoc.jmod`, ['bin/javadoc']], + [ + `Contents/Home/jmods/jdk.jpackage.jmod`, + ['bin/jpackage', 'classes/jdk/jpackage/internal/resources/jpackageapplauncher'], + ], ] const binariesPatterns = [ diff --git a/app/ide-desktop/lib/client/watch.ts b/app/ide-desktop/lib/client/watch.ts index 21b72734afe7..6fd6d19643bb 100644 --- a/app/ide-desktop/lib/client/watch.ts +++ b/app/ide-desktop/lib/client/watch.ts @@ -93,7 +93,12 @@ const ALL_BUNDLES_READY = new Promise((resolve, reject) => { void dashboardBuilder.watch() console.log('Bundling content.') - const contentOpts = contentBundler.bundlerOptionsFromEnv() + const contentOpts = contentBundler.bundlerOptionsFromEnv({ + // This is in watch mode, however it runs its own server rather than an esbuild server. + devMode: false, + supportsLocalBackend: true, + supportsDeepLinks: false, + }) contentOpts.plugins.push({ name: 'enso-on-rebuild', setup: build => { @@ -103,6 +108,7 @@ const ALL_BUNDLES_READY = new Promise((resolve, reject) => { }, }) contentOpts.outdir = path.resolve(IDE_DIR_PATH, 'assets') + contentOpts.define.REDIRECT_OVERRIDE = JSON.stringify('http://localhost:8080') const contentBuilder = await esbuild.context(contentOpts) const content = await contentBuilder.rebuild() console.log('Result of content bundling: ', content) diff --git a/app/ide-desktop/lib/content-config/src/config.json b/app/ide-desktop/lib/content-config/src/config.json index c513b5e80a99..eff732d50106 100644 --- a/app/ide-desktop/lib/content-config/src/config.json +++ b/app/ide-desktop/lib/content-config/src/config.json @@ -1,7 +1,7 @@ { "options": { "authentication": { - "value": false, + "value": true, "description": "Determines whether user authentication is enabled. This option is always true when executed in the cloud." }, "dataCollection": { @@ -116,7 +116,7 @@ "primary": false }, "newDashboard": { - "value": false, + "value": true, "description": "Determines whether the new dashboard with cloud integration is enabled." }, "profiling": { diff --git a/app/ide-desktop/lib/content/bundle.ts b/app/ide-desktop/lib/content/bundle.ts index af9273ffacd3..9e7cb71fdb85 100644 --- a/app/ide-desktop/lib/content/bundle.ts +++ b/app/ide-desktop/lib/content/bundle.ts @@ -8,7 +8,13 @@ import * as bundler from './esbuild-config' // ======================= try { - void esbuild.build(bundler.bundleOptions()) + void esbuild.build( + bundler.bundleOptions({ + devMode: false, + supportsLocalBackend: true, + supportsDeepLinks: true, + }) + ) } catch (error) { console.error(error) throw error diff --git a/app/ide-desktop/lib/content/esbuild-config.ts b/app/ide-desktop/lib/content/esbuild-config.ts index e3f9555a7da4..222194bee60b 100644 --- a/app/ide-desktop/lib/content/esbuild-config.ts +++ b/app/ide-desktop/lib/content/esbuild-config.ts @@ -34,8 +34,20 @@ const THIS_PATH = pathModule.resolve(pathModule.dirname(url.fileURLToPath(import // === Environment variables === // ============================= +/** Arguments that must always be supplied, because they are not defined as + * environment variables. */ +export interface PassthroughArguments { + /** `true` if in development mode (live-reload), `false` if in production mode. */ + devMode: boolean + /** Whether the application may have the local backend running. */ + supportsLocalBackend: boolean + /** Whether the application supports deep links. This is only true when using + * the installed app on macOS and Windows. */ + supportsDeepLinks: boolean +} + /** Mandatory build options. */ -export interface Arguments { +export interface Arguments extends PassthroughArguments { /** List of files to be copied from WASM artifacts. */ wasmArtifacts: string /** Directory with assets. Its contents are to be copied. */ @@ -44,17 +56,15 @@ export interface Arguments { outputPath: string /** The main JS bundle to load WASM and JS wasm-pack bundles. */ ensoglAppPath: string - /** `true` if in development mode (live-reload), `false` if in production mode. */ - devMode: boolean } /** Get arguments from the environment. */ -export function argumentsFromEnv(): Arguments { +export function argumentsFromEnv(passthroughArguments: PassthroughArguments): Arguments { const wasmArtifacts = utils.requireEnv('ENSO_BUILD_GUI_WASM_ARTIFACTS') const assetsPath = utils.requireEnv('ENSO_BUILD_GUI_ASSETS') const outputPath = pathModule.resolve(utils.requireEnv('ENSO_BUILD_GUI'), 'assets') const ensoglAppPath = utils.requireEnv('ENSO_BUILD_GUI_ENSOGL_APP') - return { wasmArtifacts, assetsPath, outputPath, ensoglAppPath, devMode: false } + return { ...passthroughArguments, wasmArtifacts, assetsPath, outputPath, ensoglAppPath } } // =================== @@ -77,7 +87,15 @@ function git(command: string): string { /** Generate the builder options. */ export function bundlerOptions(args: Arguments) { - const { outputPath, ensoglAppPath, wasmArtifacts, assetsPath, devMode } = args + const { + outputPath, + ensoglAppPath, + wasmArtifacts, + assetsPath, + devMode, + supportsLocalBackend, + supportsDeepLinks, + } = args const buildOptions = { // Disabling naming convention because these are third-party options. /* eslint-disable @typescript-eslint/naming-convention */ @@ -138,6 +156,8 @@ export function bundlerOptions(args: Arguments) { /** Overrides the redirect URL for OAuth logins in the production environment. * This is needed for logins to work correctly under `./run gui watch`. */ REDIRECT_OVERRIDE: 'undefined', + SUPPORTS_LOCAL_BACKEND: JSON.stringify(supportsLocalBackend), + SUPPORTS_DEEP_LINKS: JSON.stringify(supportsDeepLinks), }, sourcemap: true, minify: true, @@ -165,13 +185,13 @@ export function bundlerOptions(args: Arguments) { * * Note that they should be further customized as per the needs of the specific workflow * (e.g. watch vs. build). */ -export function bundlerOptionsFromEnv() { - return bundlerOptions(argumentsFromEnv()) +export function bundlerOptionsFromEnv(passthroughArguments: PassthroughArguments) { + return bundlerOptions(argumentsFromEnv(passthroughArguments)) } /** esbuild options for bundling the package for a one-off build. * * Relies on the environment variables to be set. */ -export function bundleOptions() { - return bundlerOptionsFromEnv() +export function bundleOptions(passthroughArguments: PassthroughArguments) { + return bundlerOptionsFromEnv(passthroughArguments) } diff --git a/app/ide-desktop/lib/content/globals.d.ts b/app/ide-desktop/lib/content/globals.d.ts new file mode 100644 index 000000000000..3d4552b22e94 --- /dev/null +++ b/app/ide-desktop/lib/content/globals.d.ts @@ -0,0 +1,15 @@ +/** @file Globals defined only in this module. */ + +declare global { + // These are top-level constants, and therefore should be `CONSTANT_CASE`. + /* eslint-disable @typescript-eslint/naming-convention */ + /** Whether the */ + /** Whether the application may have the local backend running. */ + const SUPPORTS_LOCAL_BACKEND: boolean + /** Whether the application supports deep links. This is only true when using + * the installed app on macOS and Windows. */ + const SUPPORTS_DEEP_LINKS: boolean + /* eslint-enable @typescript-eslint/naming-convention */ +} + +export {} diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index 602e07146490..746e3ce4dae8 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -5,6 +5,7 @@ import * as semver from 'semver' import * as authentication from 'enso-authentication' +import * as common from 'enso-common' import * as contentConfig from 'enso-content-config' import * as app from '../../../../../target/ensogl-pack/linked-dist' @@ -16,6 +17,8 @@ const logger = app.log.logger // === Constants === // ================= +/** The name of the `localStorage` key storing the initial URL of the app. */ +const INITIAL_URL_KEY = `${common.PRODUCT_NAME.toLowerCase()}-initial-url` /** Path to the SSE endpoint over which esbuild sends events. */ const ESBUILD_PATH = '/esbuild' /** SSE event indicating a build has finished. */ @@ -39,6 +42,10 @@ if (IS_DEV_MODE) { location.href = location.href.toString() }) void navigator.serviceWorker.register(SERVICE_WORKER_PATH) +} else { + void navigator.serviceWorker + .getRegistration() + .then(serviceWorker => serviceWorker?.unregister()) } // ============= @@ -189,67 +196,83 @@ class Main implements AppRunner { /** The entrypoint into the IDE. */ main(inputConfig?: StringConfig) { - contentConfig.OPTIONS.loadAll([app.urlParams()]) - const isUsingAuthentication = contentConfig.OPTIONS.options.authentication.value - const isUsingNewDashboard = - contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value - const isOpeningMainEntryPoint = - contentConfig.OPTIONS.groups.startup.options.entry.value === - contentConfig.OPTIONS.groups.startup.options.entry.default - const isNotOpeningProject = - contentConfig.OPTIONS.groups.startup.options.project.value === '' - if ( - (isUsingAuthentication || isUsingNewDashboard) && - isOpeningMainEntryPoint && - isNotOpeningProject - ) { - const hideAuth = () => { - const auth = document.getElementById('dashboard') - const ide = document.getElementById('root') - if (auth) { - auth.style.display = 'none' - } - if (ide) { - ide.hidden = false - } - } - /** This package is an Electron desktop app (i.e., not in the Cloud), so - * we're running on the desktop. */ - /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345 - * `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE - * should only have one entry point. Right now, we have two. One for the cloud - * and one for the desktop. */ - const currentPlatform = contentConfig.OPTIONS.groups.startup.options.platform.value - let platform = authentication.Platform.desktop - if (currentPlatform === 'web') { - platform = authentication.Platform.cloud + /** Note: Signing out always redirects to `/`. It is impossible to make this work, + * as it is not possible to distinguish between having just logged out, and explicitly + * opening a page with no URL parameters set. + * + * Client-side routing endpoints are explicitly not supported for live-reload, as they are + * transitional pages that should not need live-reload when running `gui watch`. */ + const url = new URL(location.href) + const isInAuthenticationFlow = url.searchParams.has('code') && url.searchParams.has('state') + const authenticationUrl = location.href + if (isInAuthenticationFlow) { + history.replaceState(null, '', localStorage.getItem(INITIAL_URL_KEY)) + } + const parseOk = contentConfig.OPTIONS.loadAllAndDisplayHelpIfUnsuccessful([app.urlParams()]) + if (isInAuthenticationFlow) { + history.replaceState(null, '', authenticationUrl) + } else { + localStorage.setItem(INITIAL_URL_KEY, location.href) + } + if (parseOk) { + const isUsingAuthentication = contentConfig.OPTIONS.options.authentication.value + const isUsingNewDashboard = + contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value + const isOpeningMainEntryPoint = + contentConfig.OPTIONS.groups.startup.options.entry.value === + contentConfig.OPTIONS.groups.startup.options.entry.default + const isNotOpeningProject = + contentConfig.OPTIONS.groups.startup.options.project.value === '' + if ( + (isUsingAuthentication || isUsingNewDashboard) && + isOpeningMainEntryPoint && + isNotOpeningProject + ) { + this.runAuthentication(isInAuthenticationFlow, inputConfig) + } else { + void this.runApp(inputConfig) } - /** FIXME [PB]: https://github.com/enso-org/cloud-v2/issues/366 - * React hooks rerender themselves multiple times. It is resulting in multiple - * Enso main scene being initialized. As a temporary workaround we check whether - * appInstance was already ran. Target solution should move running appInstance - * where it will be called only once. */ - let appInstanceRan = false - const onAuthenticated = () => { + } + } + + /** Begins the authentication UI flow. */ + runAuthentication(isInAuthenticationFlow: boolean, inputConfig?: StringConfig) { + /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345 + * `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE + * should only have one entry point. Right now, we have two. One for the cloud + * and one for the desktop. */ + /** FIXME [PB]: https://github.com/enso-org/cloud-v2/issues/366 + * React hooks rerender themselves multiple times. It is resulting in multiple + * Enso main scene being initialized. As a temporary workaround we check whether + * appInstance was already ran. Target solution should move running appInstance + * where it will be called only once. */ + authentication.run({ + appRunner: this, + logger, + supportsLocalBackend: SUPPORTS_LOCAL_BACKEND, + supportsDeepLinks: SUPPORTS_DEEP_LINKS, + showDashboard: contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, + onAuthenticated: () => { + if (isInAuthenticationFlow) { + const initialUrl = localStorage.getItem(INITIAL_URL_KEY) + if (initialUrl != null) { + // This is not used past this point, however it is set to the initial URL + // to make refreshing work as expected. + history.replaceState(null, '', initialUrl) + } + } if (!contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value) { - hideAuth() - if (!appInstanceRan) { - appInstanceRan = true + document.getElementById('enso-dashboard')?.remove() + const ide = document.getElementById('root') + if (ide) { + ide.hidden = false + } + if (this.app == null) { void this.runApp(inputConfig) } } - } - authentication.run({ - appRunner: this, - logger, - platform, - showDashboard: - contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, - onAuthenticated, - }) - } else { - void this.runApp(inputConfig) - } + }, + }) } } diff --git a/app/ide-desktop/lib/content/start.ts b/app/ide-desktop/lib/content/start.ts index 28441e899ca0..01cc95adb41f 100644 --- a/app/ide-desktop/lib/content/start.ts +++ b/app/ide-desktop/lib/content/start.ts @@ -17,7 +17,11 @@ const HTTP_STATUS_OK = 200 /** Start the esbuild watcher. */ async function watch() { - const opts = bundler.bundleOptions() + const opts = bundler.bundleOptions({ + devMode: true, + supportsLocalBackend: true, + supportsDeepLinks: false, + }) const builder = await esbuild.context(opts) await builder.watch() await builder.serve({ diff --git a/app/ide-desktop/lib/content/watch.ts b/app/ide-desktop/lib/content/watch.ts index 98b270f79686..091b48a87d70 100644 --- a/app/ide-desktop/lib/content/watch.ts +++ b/app/ide-desktop/lib/content/watch.ts @@ -31,10 +31,13 @@ async function watch() { // This MUST be called before `builder.watch()` as `tailwind.css` must be generated // before the copy plugin runs. await dashboardBuilder.watch() - const opts = bundler.bundlerOptions({ - ...bundler.argumentsFromEnv(), - devMode: true, - }) + const opts = bundler.bundlerOptions( + bundler.argumentsFromEnv({ + devMode: true, + supportsLocalBackend: true, + supportsDeepLinks: false, + }) + ) opts.define.REDIRECT_OVERRIDE = JSON.stringify('http://localhost:8080') opts.entryPoints.push({ in: path.resolve(THIS_PATH, 'src', 'serviceWorker.ts'), diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts index f7c9156bb0bf..44c179170636 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts @@ -34,8 +34,8 @@ import * as cognito from 'amazon-cognito-identity-js' import * as results from 'ts-results' import * as config from './config' +import * as detect from '../detect' import * as loggerProvider from '../providers/logger' -import * as platformModule from '../platform' // ================= // === Constants === @@ -144,7 +144,7 @@ export class Cognito { /** Create a new Cognito wrapper. */ constructor( private readonly logger: loggerProvider.Logger, - private readonly platform: platformModule.Platform, + private readonly supportsDeepLinks: boolean, private readonly amplifyConfig: config.AmplifyConfig ) { /** Amplify expects `Auth.configure` to be called before any other `Auth` methods are @@ -173,7 +173,7 @@ export class Cognito { * * Does not rely on federated identity providers (e.g., Google or GitHub). */ signUp(username: string, password: string) { - return signUp(username, password, this.platform) + return signUp(this.supportsDeepLinks, username, password) } /** Send the email address verification code. @@ -263,7 +263,7 @@ export class Cognito { * * See: https://github.com/aws-amplify/amplify-js/issues/3391#issuecomment-756473970 */ private customState() { - return this.platform === platformModule.Platform.desktop ? window.location.pathname : null + return detect.isRunningInElectron() ? window.location.pathname : null } } @@ -339,9 +339,9 @@ function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind { /** A wrapper around the Amplify "sign up" endpoint that converts known errors * to {@link SignUpError}s. */ -async function signUp(username: string, password: string, platform: platformModule.Platform) { +async function signUp(supportsDeepLinks: boolean, username: string, password: string) { const result = await results.Result.wrapAsync(async () => { - const params = intoSignUpParams(username, password, platform) + const params = intoSignUpParams(supportsDeepLinks, username, password) await amplify.Auth.signUp(params) }) return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignUpErrorOrThrow) @@ -349,9 +349,9 @@ async function signUp(username: string, password: string, platform: platformModu /** Format a username and password as an {@link amplify.SignUpParams}. */ function intoSignUpParams( + supportsDeepLinks: boolean, username: string, - password: string, - platform: platformModule.Platform + password: string ): amplify.SignUpParams { return { username, @@ -368,7 +368,7 @@ function intoSignUpParams( * It is necessary to disable the naming convention rule here, because the key is * expected to appear exactly as-is in Cognito, so we must match it. */ // eslint-disable-next-line @typescript-eslint/naming-convention - 'custom:fromDesktop': platform === platformModule.Platform.desktop ? 'true' : 'false', + ...(supportsDeepLinks ? { 'custom:fromDesktop': JSON.stringify(true) } : {}), }, } } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx index efdd6e3a12b4..527074fd132b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx @@ -6,6 +6,8 @@ import toast from 'react-hot-toast' import * as app from '../../components/app' import * as auth from '../providers/auth' import * as svg from '../../components/svg' +import * as validation from '../../dashboard/validation' + import Input from './input' import SvgIcon from './svgIcon' @@ -83,6 +85,8 @@ function Registration() { type="password" name="password" placeholder="Password" + pattern={validation.PASSWORD_PATTERN} + title={validation.PASSWORD_TITLE} value={password} setValue={setPassword} /> diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx index 576e0f0b7d9c..ed0712eadbb6 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx @@ -7,6 +7,8 @@ import toast from 'react-hot-toast' import * as app from '../../components/app' import * as auth from '../providers/auth' import * as svg from '../../components/svg' +import * as validation from '../../dashboard/validation' + import Input from './input' import SvgIcon from './svgIcon' @@ -117,6 +119,8 @@ function ResetPassword() { type="password" name="new_password" placeholder="New Password" + pattern={validation.PASSWORD_PATTERN} + title={validation.PASSWORD_TITLE} value={newPassword} setValue={setNewPassword} /> diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx index 9de5aa889dfb..5c1f0cfe5515 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx @@ -15,7 +15,6 @@ import * as errorModule from '../../error' import * as http from '../../http' import * as loggerProvider from '../../providers/logger' import * as newtype from '../../newtype' -import * as platformModule from '../../platform' import * as remoteBackend from '../../dashboard/remoteBackend' import * as sessionProvider from './session' @@ -26,12 +25,16 @@ import * as sessionProvider from './session' const MESSAGES = { signUpSuccess: 'We have sent you an email with further instructions!', confirmSignUpSuccess: 'Your account has been confirmed! Please log in.', + setUsernameLoading: 'Setting username...', setUsernameSuccess: 'Your username has been set!', + setUsernameFailure: 'Could not set your username.', signInWithPasswordSuccess: 'Successfully logged in!', forgotPasswordSuccess: 'We have sent you an email with further instructions!', changePasswordSuccess: 'Successfully changed password!', resetPasswordSuccess: 'Successfully reset password!', + signOutLoading: 'Logging out...', signOutSuccess: 'Successfully logged out!', + signOutError: 'Error logging out, please try again.', pleaseWait: 'Please wait...', } as const @@ -42,7 +45,6 @@ const MESSAGES = { /** Possible types of {@link BaseUserSession}. */ export enum UserSessionType { partial = 'partial', - awaitingAcceptance = 'awaitingAcceptance', full = 'full', } @@ -140,7 +142,6 @@ const AuthContext = react.createContext({} as AuthContextType) /** Props for an {@link AuthProvider}. */ export interface AuthProviderProps { authService: authServiceModule.AuthService - platform: platformModule.Platform /** Callback to execute once the user has authenticated successfully. */ onAuthenticated: () => void children: react.ReactNode @@ -148,13 +149,12 @@ export interface AuthProviderProps { /** A React provider for the Cognito API. */ export function AuthProvider(props: AuthProviderProps) { - const { authService, platform, children } = props + const { authService, onAuthenticated, children } = props const { cognito } = authService - const { session } = sessionProvider.useSession() + const { session, deinitializeSession } = sessionProvider.useSession() const { setBackend } = backendProvider.useSetBackend() const logger = loggerProvider.useLogger() const navigate = router.useNavigate() - const onAuthenticated = react.useCallback(props.onAuthenticated, []) const [initialized, setInitialized] = react.useState(false) const [userSession, setUserSession] = react.useState(null) @@ -174,7 +174,9 @@ export function AuthProvider(props: AuthProviderProps) { headers.append('Authorization', `Bearer ${accessToken}`) const client = new http.Client(headers) const backend = new remoteBackend.RemoteBackend(client, logger) - if (platform === platformModule.Platform.cloud) { + // The backend MUST be the remote backend before login is finished. + // This is because the "set username" flow requires the remote backend. + if (!initialized || userSession == null) { setBackend(backend) } const organization = await backend.usersMe().catch(() => null) @@ -274,20 +276,25 @@ export function AuthProvider(props: AuthProviderProps) { username: string, email: string ) => { - if (backend.platform === platformModule.Platform.desktop) { + if (backend.type === backendModule.BackendType.local) { toast.error('You cannot set your username on the local backend.') return false } else { try { - await backend.createUser({ - userName: username, - userEmail: newtype.asNewtype(email), - }) + await toast.promise( + backend.createUser({ + userName: username, + userEmail: newtype.asNewtype(email), + }), + { + success: MESSAGES.setUsernameSuccess, + error: MESSAGES.setUsernameFailure, + loading: MESSAGES.setUsernameLoading, + } + ) navigate(app.DASHBOARD_PATH) - toast.success(MESSAGES.setUsernameSuccess) return true } catch (e) { - toast.error('Could not set your username.') return false } } @@ -326,8 +333,14 @@ export function AuthProvider(props: AuthProviderProps) { } const signOut = async () => { - await cognito.signOut() - toast.success(MESSAGES.signOutSuccess) + deinitializeSession() + setInitialized(false) + setUserSession(null) + await toast.promise(cognito.signOut(), { + success: MESSAGES.signOutSuccess, + error: MESSAGES.signOutError, + loading: MESSAGES.signOutLoading, + }) return true } @@ -387,6 +400,16 @@ export function useAuth() { return react.useContext(AuthContext) } +// =============================== +// === shouldPreventNavigation === +// =============================== + +/** True if navigation should be prevented, for debugging purposes. */ +function getShouldPreventNavigation() { + const location = router.useLocation() + return new URLSearchParams(location.search).get('prevent-navigation') === 'true' +} + // ======================= // === ProtectedLayout === // ======================= @@ -394,10 +417,11 @@ export function useAuth() { /** A React Router layout route containing routes only accessible by users that are logged in. */ export function ProtectedLayout() { const { session } = useAuth() + const shouldPreventNavigation = getShouldPreventNavigation() - if (!session) { + if (!shouldPreventNavigation && !session) { return - } else if (session.type === UserSessionType.partial) { + } else if (!shouldPreventNavigation && session?.type === UserSessionType.partial) { return } else { return @@ -412,8 +436,9 @@ export function ProtectedLayout() { * in the process of registering. */ export function SemiProtectedLayout() { const { session } = useAuth() + const shouldPreventNavigation = getShouldPreventNavigation() - if (session?.type === UserSessionType.full) { + if (!shouldPreventNavigation && session?.type === UserSessionType.full) { return } else { return @@ -428,10 +453,11 @@ export function SemiProtectedLayout() { * not logged in. */ export function GuestLayout() { const { session } = useAuth() + const shouldPreventNavigation = getShouldPreventNavigation() - if (session?.type === UserSessionType.partial) { + if (!shouldPreventNavigation && session?.type === UserSessionType.partial) { return - } else if (session?.type === UserSessionType.full) { + } else if (!shouldPreventNavigation && session?.type === UserSessionType.full) { return } else { return diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx index 4fc45e826080..a045f18387d3 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx @@ -16,6 +16,8 @@ import * as listen from '../listen' /** State contained in a {@link SessionContext}. */ interface SessionContextType { session: results.Option + /** Set `initialized` to false. Must be called when logging out. */ + deinitializeSession: () => void } /** See `AuthContext` for safety details. */ @@ -58,7 +60,7 @@ export function SessionProvider(props: SessionProviderProps) { const [initialized, setInitialized] = react.useState(false) /** Register an async effect that will fetch the user's session whenever the `refresh` state is - * incremented. This is useful when a user has just logged in (as their cached credentials are + * set. This is useful when a user has just logged in (as their cached credentials are * out of date, so this will update them). */ const session = hooks.useAsyncEffect( results.None, @@ -95,7 +97,7 @@ export function SessionProvider(props: SessionProviderProps) { * * See: * https://github.com/aws-amplify/amplify-js/issues/3391#issuecomment-756473970 */ - window.history.replaceState({}, '', mainPageUrl) + history.replaceState({}, '', mainPageUrl) doRefresh() break } @@ -112,10 +114,14 @@ export function SessionProvider(props: SessionProviderProps) { return cancel }, [registerAuthEventListener]) - const value = { session } + const deinitializeSession = () => { + setInitialized(false) + } return ( - {initialized && children} + + {initialized && children} + ) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx index 6323c806c38f..764369622fd2 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx @@ -12,7 +12,13 @@ import * as config from '../config' import * as listen from './listen' import * as loggerProvider from '../providers/logger' import * as newtype from '../newtype' -import * as platformModule from '../platform' + +// ============= +// === Types === +// ============= + +/** The subset of Amplify configuration related to sign in and sign out redirects. */ +interface AmplifyRedirects extends Pick {} // ================= // === Constants === @@ -31,21 +37,19 @@ const CONFIRM_REGISTRATION_PATHNAME = '//auth/confirmation' * password email. */ const LOGIN_PATHNAME = '//auth/login' -/** URL used as the OAuth redirect when running in the desktop app. */ -const DESKTOP_REDIRECT = newtype.asNewtype(`${common.DEEP_LINK_SCHEME}://auth`) -/** Map from platform to the OAuth redirect URL that should be used for that platform. */ -const PLATFORM_TO_CONFIG: Record< - platformModule.Platform, - Pick -> = { - [platformModule.Platform.desktop]: { - redirectSignIn: DESKTOP_REDIRECT, - redirectSignOut: DESKTOP_REDIRECT, - }, - [platformModule.Platform.cloud]: { - redirectSignIn: config.ACTIVE_CONFIG.cloudRedirect, - redirectSignOut: config.ACTIVE_CONFIG.cloudRedirect, - }, +/** URI used as the OAuth redirect when deep links are supported. */ +const DEEP_LINK_REDIRECT = newtype.asNewtype( + `${common.DEEP_LINK_SCHEME}://auth` +) +/** OAuth redirect URLs for the electron app. */ +const DEEP_LINK_REDIRECTS: AmplifyRedirects = { + redirectSignIn: DEEP_LINK_REDIRECT, + redirectSignOut: DEEP_LINK_REDIRECT, +} +/** OAuth redirect URLs for the browser. */ +const CLOUD_REDIRECTS: AmplifyRedirects = { + redirectSignIn: config.ACTIVE_CONFIG.cloudRedirect, + redirectSignOut: config.ACTIVE_CONFIG.cloudRedirect, } const BASE_AMPLIFY_CONFIG = { @@ -88,8 +92,9 @@ const AMPLIFY_CONFIGS = { export interface AuthConfig { /** Logger for the authentication service. */ logger: loggerProvider.Logger - /** Whether the application is running on a desktop (i.e., versus in the Cloud). */ - platform: platformModule.Platform + /** Whether the application supports deep links. This is only true when using + * the installed app on macOS and Windows. */ + supportsDeepLinks: boolean /** Function to navigate to a given (relative) URL. * * Used to redirect to pages like the password reset page with the query parameters set in the @@ -116,9 +121,9 @@ export interface AuthService { * This function should only be called once, and the returned service should be used throughout the * application. This is because it performs global configuration of the Amplify library. */ export function initAuthService(authConfig: AuthConfig): AuthService { - const { logger, platform, navigate } = authConfig - const amplifyConfig = loadAmplifyConfig(logger, platform, navigate) - const cognitoClient = new cognito.Cognito(logger, platform, amplifyConfig) + const { logger, supportsDeepLinks, navigate } = authConfig + const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate) + const cognitoClient = new cognito.Cognito(logger, supportsDeepLinks, amplifyConfig) return { cognito: cognitoClient, registerAuthEventListener: listen.registerAuthEventListener, @@ -128,34 +133,35 @@ export function initAuthService(authConfig: AuthConfig): AuthService { /** Return the appropriate Amplify configuration for the current platform. */ function loadAmplifyConfig( logger: loggerProvider.Logger, - platform: platformModule.Platform, + supportsDeepLinks: boolean, navigate: (url: string) => void ): auth.AmplifyConfig { /** Load the environment-specific Amplify configuration. */ const baseConfig = AMPLIFY_CONFIGS[config.ENVIRONMENT] - let urlOpener = null - let accessTokenSaver = null - if (platform === platformModule.Platform.desktop) { - /** If we're running on the desktop, we want to override the default URL opener for OAuth - * flows. This is because the default URL opener opens the URL in the desktop app itself, - * but we want the user to be sent to their system browser instead. The user should be sent - * to their system browser because: + let urlOpener: ((url: string) => void) | null = null + let accessTokenSaver: ((accessToken: string) => void) | null = null + if ('authenticationApi' in window) { + /** When running on destop we want to have option to save access token to a file, + * so it can be later reuse when issuing requests to Cloud API. */ + accessTokenSaver = saveAccessToken + } + if (supportsDeepLinks) { + /** If we support redirecting back here via deep links, we want to override the default + * URL opener for OAuth flows. This is because the default URL opener opens the URL + * in the desktop app itself, but we want the user to be sent to their system browser + * instead. The user should be sent to their system browser because: * * - users trust their system browser with their credentials more than they trust our app; * - our app can keep itself on the relevant page until the user is sent back to it (i.e., * we avoid unnecessary reloads/refreshes caused by redirects. */ urlOpener = openUrlWithExternalBrowser - /** When running on destop we want to have option to save access token to a file, - * so it can be later reused when issuing requests to the Cloud API. */ - accessTokenSaver = saveAccessToken - /** To handle redirects back to the application from the system browser, we also need to * register a custom URL handler. */ setDeepLinkHandler(logger, navigate) } /** Load the platform-specific Amplify configuration. */ - const platformConfig = PLATFORM_TO_CONFIG[platform] + const platformConfig = supportsDeepLinks ? DEEP_LINK_REDIRECTS : CLOUD_REDIRECTS return { ...baseConfig, ...platformConfig, @@ -243,19 +249,19 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin * URL to the Amplify library, which will parse the URL and complete the OAuth flow. */ function handleAuthResponse(url: string) { void (async () => { - /** Temporarily override the `window.location` object so that Amplify doesn't try to call - * `window.location.replaceState` (which doesn't work in the renderer process because of + /** Temporarily override the `history` object so that Amplify doesn't try to call + * `history.replaceState` (which doesn't work in the renderer process because of * Electron's `webSecurity`). This is a hack, but it's the only way to get Amplify to work * with a custom URL protocol in Electron. * * # Safety * * It is safe to disable the `unbound-method` lint here because we intentionally want to use - * the original `window.history.replaceState` function, which is not bound to the - * `window.history` object. */ + * the original `history.replaceState` function, which is not bound to the + * `history` object. */ // eslint-disable-next-line @typescript-eslint/unbound-method - const replaceState = window.history.replaceState - window.history.replaceState = () => false + const replaceState = history.replaceState + history.replaceState = () => false try { /** # Safety * @@ -267,8 +273,8 @@ function handleAuthResponse(url: string) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call await amplify.Auth._handleAuthResponse(url) } finally { - /** Restore the original `window.location.replaceState` function. */ - window.history.replaceState = replaceState + /** Restore the original `history.replaceState` function. */ + history.replaceState = replaceState } })() } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx index 34888dc6007a..6dc553eca089 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx @@ -39,8 +39,7 @@ import * as router from 'react-router-dom' import * as toast from 'react-hot-toast' import * as authService from '../authentication/service' -import * as localBackend from '../dashboard/localBackend' -import * as platformModule from '../platform' +import * as detect from '../detect' import * as authProvider from '../authentication/providers/auth' import * as backendProvider from '../providers/backend' @@ -82,11 +81,15 @@ export const SET_USERNAME_PATH = '/set-username' /** Global configuration for the `App` component. */ export interface AppProps { logger: loggerProvider.Logger - platform: platformModule.Platform + /** Whether the application may have the local backend running. */ + supportsLocalBackend: boolean + /** Whether the application supports deep links. This is only true when using + * the installed app on macOS and Windows. */ + supportsDeepLinks: boolean /** Whether the dashboard should be rendered. */ showDashboard: boolean onAuthenticated: () => void - appRunner: AppRunner | null + appRunner: AppRunner } /** Component called by the parent module, returning the root React component for this @@ -95,11 +98,9 @@ export interface AppProps { * This component handles all the initialization and rendering of the app, and manages the app's * routes. It also initializes an `AuthProvider` that will be used by the rest of the app. */ function App(props: AppProps) { - const { platform } = props // This is a React component even though it does not contain JSX. // eslint-disable-next-line no-restricted-syntax - const Router = - platform === platformModule.Platform.desktop ? router.MemoryRouter : router.BrowserRouter + const Router = detect.isRunningInElectron() ? router.MemoryRouter : router.BrowserRouter /** Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` * will redirect the user between the login/register pages and the dashboard. */ return ( @@ -122,8 +123,14 @@ function App(props: AppProps) { * because the {@link AppRouter} relies on React hooks, which can't be used in the same React * component as the component that defines the provider. */ function AppRouter(props: AppProps) { - const { logger, platform, showDashboard, onAuthenticated } = props + const { logger, showDashboard, onAuthenticated } = props const navigate = router.useNavigate() + // FIXME[sb]: After platform detection for Electron is merged in, `IS_DEV_MODE` should be + // set to true on `ide watch`. + if (IS_DEV_MODE) { + // @ts-expect-error This is used exclusively for debugging. + window.navigate = navigate + } const mainPageUrl = new URL(window.location.href) const memoizedAuthService = react.useMemo(() => { const authConfig = { navigate, ...props } @@ -164,20 +171,12 @@ function AppRouter(props: AppProps) { userSession={userSession} registerAuthEventListener={registerAuthEventListener} > - + {/* This is safe, because the backend is always set by the authentication flow. */} + {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} + {routes} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts index bab6add54186..296366f63593 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts @@ -1,12 +1,17 @@ /** @file Type definitions common between all backends. */ import * as dateTime from './dateTime' import * as newtype from '../newtype' -import * as platform from '../platform' // ============= // === Types === // ============= +/** The {@link Backend} variant. If a new variant is created, it should be added to this enum. */ +export enum BackendType { + local = 'local', + remote = 'remote', +} + /** Unique identifier for a user/organization. */ export type UserOrOrganizationId = newtype.Newtype @@ -366,7 +371,7 @@ export function assetIsType(type: Type) { /** Interface for sending requests to a backend that manages assets and runs projects. */ export interface Backend { - readonly platform: platform.Platform + readonly type: BackendType /** Set the username of the current user. */ createUser: (body: CreateUserRequestBody) => Promise diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx index 038c780402a3..9d4cff420024 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx @@ -6,6 +6,7 @@ import toast from 'react-hot-toast' import * as auth from '../../authentication/providers/auth' import * as modalProvider from '../../providers/modal' import * as svg from '../../components/svg' +import * as validation from '../validation' import Modal from './modal' @@ -96,6 +97,8 @@ function ChangePasswordModal() { type="password" name="new_password" placeholder="New Password" + pattern={validation.PASSWORD_PATTERN} + title={validation.PASSWORD_TITLE} value={newPassword} onChange={event => { setNewPassword(event.target.value) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx index 68467555ba9d..2d14fcccf8dd 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx @@ -2,6 +2,8 @@ * interactive components. */ import * as react from 'react' +import * as common from 'enso-common' + import * as backendModule from '../backend' import * as dateTime from '../dateTime' import * as fileInfo from '../../fileInfo' @@ -9,7 +11,7 @@ import * as hooks from '../../hooks' import * as http from '../../http' import * as localBackend from '../localBackend' import * as newtype from '../../newtype' -import * as platformModule from '../../platform' +import * as projectManager from '../projectManager' import * as remoteBackendModule from '../remoteBackend' import * as svg from '../../components/svg' import * as uploadMultipleFiles from '../../uploadMultipleFiles' @@ -129,7 +131,7 @@ const COLUMN_NAME: Record, string> = { /** CSS classes for every column. Currently only used to set the widths. */ const COLUMN_CSS_CLASS: Record = { [Column.name]: 'w-60', - [Column.lastModified]: 'w-32', + [Column.lastModified]: 'w-40', [Column.sharedWith]: 'w-36', [Column.docs]: 'w-96', [Column.labels]: 'w-80', @@ -207,9 +209,9 @@ function rootDirectoryId(userOrOrganizationId: backendModule.UserOrOrganizationI } /** Returns the list of columns to be displayed. */ -function columnsFor(displayMode: ColumnDisplayMode, backendPlatform: platformModule.Platform) { +function columnsFor(displayMode: ColumnDisplayMode, backendType: backendModule.BackendType) { const columns = COLUMNS_FOR[displayMode] - return backendPlatform === platformModule.Platform.desktop + return backendType === backendModule.BackendType.local ? columns.filter(column => column !== Column.sharedWith) : columns } @@ -220,8 +222,8 @@ function columnsFor(displayMode: ColumnDisplayMode, backendPlatform: platformMod /** Props for {@link Dashboard}s that are common to all platforms. */ export interface DashboardProps { - platform: platformModule.Platform - appRunner: AppRunner | null + supportsLocalBackend: boolean + appRunner: AppRunner } // TODO[sb]: Implement rename when clicking name of a selected row. @@ -229,7 +231,7 @@ export interface DashboardProps { /** The component that contains the entire UI. */ function Dashboard(props: DashboardProps) { - const { platform, appRunner } = props + const { supportsLocalBackend, appRunner } = props const logger = loggerProvider.useLogger() const { accessToken, organization } = auth.useFullUserSession() @@ -241,6 +243,7 @@ function Dashboard(props: DashboardProps) { const [refresh, doRefresh] = hooks.useRefresh() const [query, setQuery] = react.useState('') + const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = react.useState(false) const [directoryId, setDirectoryId] = react.useState(rootDirectoryId(organization.id)) const [directoryStack, setDirectoryStack] = react.useState< backendModule.Asset[] @@ -278,30 +281,63 @@ function Dashboard(props: DashboardProps) { backendModule.Asset[] >([]) + const listingLocalDirectoryAndWillFail = + backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail + const listingRemoteDirectoryAndWillFail = + backend.type === backendModule.BackendType.remote && !organization.isEnabled const directory = directoryStack[directoryStack.length - 1] const parentDirectory = directoryStack[directoryStack.length - 2] + const switchToIdeTab = react.useCallback(() => { + setTab(Tab.ide) + const ideElement = document.getElementById(IDE_ELEMENT_ID) + if (ideElement) { + ideElement.style.top = '' + ideElement.style.display = 'absolute' + } + }, []) + + const switchToDashboardTab = react.useCallback(() => { + setTab(Tab.dashboard) + const ideElement = document.getElementById(IDE_ELEMENT_ID) + if (ideElement) { + ideElement.style.top = '-100vh' + ideElement.style.display = 'fixed' + } + }, []) + react.useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if ( - // On macOS, we need to check for combination of `alt` + `d` which is `∂` (`del`). - (event.key === 'd' || event.key === '∂') && - event.ctrlKey && - event.altKey && - !event.shiftKey && - !event.metaKey - ) { - setTab(Tab.dashboard) - const ideElement = document.getElementById(IDE_ELEMENT_ID) - if (ideElement) { - ideElement.style.top = '-100vh' - ideElement.style.display = 'fixed' - } - } + const onProjectManagerLoadingFailed = () => { + setLoadingProjectManagerDidFail(true) } - document.addEventListener('keydown', onKeyDown) + document.addEventListener( + projectManager.ProjectManagerEvents.loadingFailed, + onProjectManagerLoadingFailed + ) return () => { - document.removeEventListener('keydown', onKeyDown) + document.removeEventListener( + projectManager.ProjectManagerEvents.loadingFailed, + onProjectManagerLoadingFailed + ) + } + }, []) + + react.useEffect(() => { + if (backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail) { + setIsLoadingAssets(false) + } + }, [isLoadingAssets, loadingProjectManagerDidFail, backend.type]) + + react.useEffect(() => { + if (supportsLocalBackend) { + setBackend(new localBackend.LocalBackend()) + } + }, []) + + react.useEffect(() => { + document.addEventListener('show-dashboard', switchToDashboardTab) + return () => { + document.removeEventListener('show-dashboard', switchToDashboardTab) } }, []) @@ -379,7 +415,7 @@ function Dashboard(props: DashboardProps) { { setProject(null) }} openIde={async () => { - setTab(Tab.ide) + switchToIdeTab() if (project?.projectId !== projectAsset.id) { setProject(await backend.getProjectDetails(projectAsset.id)) } - const ideElement = document.getElementById(IDE_ELEMENT_ID) - if (ideElement) { - ideElement.style.top = '' - ideElement.style.display = 'absolute' - } }} /> {projectAsset.title} @@ -603,10 +635,16 @@ function Dashboard(props: DashboardProps) { hooks.useAsyncEffect( null, async signal => { - const assets = await backend.listDirectory({ parentId: directoryId }) - if (!signal.aborted) { + if (listingLocalDirectoryAndWillFail) { + // Do not `setIsLoadingAssets(false)` + } else if (!listingRemoteDirectoryAndWillFail) { + const assets = await backend.listDirectory({ parentId: directoryId }) + if (!signal.aborted) { + setIsLoadingAssets(false) + setAssets(assets) + } + } else { setIsLoadingAssets(false) - setAssets(assets) } }, [accessToken, directoryId, refresh, backend] @@ -616,9 +654,7 @@ function Dashboard(props: DashboardProps) { const onBlur = () => { setIsFileBeingDragged(false) } - window.addEventListener('blur', onBlur) - return () => { window.removeEventListener('blur', onBlur) } @@ -684,38 +720,28 @@ function Dashboard(props: DashboardProps) { onDragEnter={openDropZone} > { if (project && tab === Tab.dashboard) { - setTab(Tab.ide) - const ideElement = document.getElementById(IDE_ELEMENT_ID) - if (ideElement) { - ideElement.style.top = '' - ideElement.style.display = 'absolute' - } + switchToIdeTab() } else { - setTab(Tab.dashboard) - const ideElement = document.getElementById(IDE_ELEMENT_ID) - if (ideElement) { - ideElement.style.top = '-100vh' - ideElement.style.display = 'fixed' - } + switchToDashboardTab() } }} - setBackendPlatform={newBackendPlatform => { - if (newBackendPlatform !== backend.platform) { + setBackendType={newBackendType => { + if (newBackendType !== backend.type) { setIsLoadingAssets(true) setProjectAssets([]) setDirectoryAssets([]) setSecretAssets([]) setFileAssets([]) - switch (newBackendPlatform) { - case platformModule.Platform.desktop: + switch (newBackendType) { + case backendModule.BackendType.local: setBackend(new localBackend.LocalBackend()) break - case platformModule.Platform.cloud: { + case backendModule.BackendType.remote: { const headers = new Headers() headers.append('Authorization', `Bearer ${accessToken}`) const client = new http.Client(headers) @@ -728,7 +754,14 @@ function Dashboard(props: DashboardProps) { query={query} setQuery={setQuery} /> - {backend.platform === platformModule.Platform.cloud && !organization.isEnabled ? ( + {listingLocalDirectoryAndWillFail ? ( +
+
+ Could not connect to the Project Manager. Please try restarting{' '} + {common.PRODUCT_NAME}, or manually launching the Project Manager. +
+
+ ) : listingRemoteDirectoryAndWillFail ? (
We will review your user details and enable the cloud experience for you @@ -741,7 +774,7 @@ function Dashboard(props: DashboardProps) {

Drive

- {backend.platform === platformModule.Platform.cloud && ( + {backend.type === backendModule.BackendType.remote && ( <>
{directory && ( @@ -763,11 +796,11 @@ function Dashboard(props: DashboardProps) {