diff --git a/app/gui/src/controller/ide.rs b/app/gui/src/controller/ide.rs index 8bfe4937c3c2..5776fb6dba77 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() + } } 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 2d1f2cba95d9..f93ee22ebe43 100644 --- a/app/gui/src/controller/searcher.rs +++ b/app/gui/src/controller/searcher.rs @@ -679,33 +679,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()), - } - } } } @@ -1001,12 +974,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 1cb848f360e9..020740f6a015 100644 --- a/app/gui/src/controller/searcher/action.rs +++ b/app/gui/src/controller/searcher/action.rs @@ -66,14 +66,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 { @@ -84,8 +76,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.). } @@ -101,10 +91,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..c7a5f7eaa1d4 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,36 @@ 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 ide = Ide::new(ensogl_app, view.clone_ref(), controller); + 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 +124,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 +165,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 === // ============= @@ -290,6 +208,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 +235,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..0d19114ef6a5 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,7 +251,6 @@ 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 } @@ -214,8 +290,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 +314,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/project.rs b/app/gui/src/presenter/project.rs index 01ed7c3efddb..c41a9a127308 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, @@ -370,28 +328,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 { 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/src/project.rs b/app/gui/view/src/project.rs index b92539147cc8..29ddec48298d 100644 --- a/app/gui/view/src/project.rs +++ b/app/gui/view/src/project.rs @@ -567,7 +567,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_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/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index af9bb17ba35a..3d43ce325b42 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -187,7 +187,13 @@ class Main implements AppRunner { const isOpeningMainEntryPoint = contentConfig.OPTIONS.groups.startup.options.entry.value === contentConfig.OPTIONS.groups.startup.options.entry.default - if ((isUsingAuthentication || isUsingNewDashboard) && isOpeningMainEntryPoint) { + 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') 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 fb1d1329ad04..154b7f91012d 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 @@ -126,6 +126,19 @@ const COLUMN_NAME: Record, string> = { [Column.ide]: 'IDE', } as const +/** 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.sharedWith]: 'w-36', + [Column.docs]: 'w-96', + [Column.labels]: 'w-80', + [Column.dataAccess]: 'w-96', + [Column.usagePlan]: '', + [Column.engine]: 'w-20', + [Column.ide]: 'w-20', +} as const + /** The corresponding `Permissions` for each backend `PermissionAction`. */ const PERMISSION: Record = { [backendModule.PermissionAction.own]: { type: permissionDisplay.Permission.owner }, @@ -193,6 +206,14 @@ function rootDirectoryId(userOrOrganizationId: backendModule.UserOrOrganizationI ) } +/** Returns the list of columns to be displayed. */ +function columnsFor(displayMode: ColumnDisplayMode, backendPlatform: platformModule.Platform) { + const columns = COLUMNS_FOR[displayMode] + return backendPlatform === platformModule.Platform.desktop + ? columns.filter(column => column !== Column.sharedWith) + : columns +} + // ================= // === Dashboard === // ================= @@ -812,9 +833,13 @@ function Dashboard(props: DashboardProps) { )} - +
- + + {columnsFor(columnDisplayMode, backend.platform).map(column => ( + > items={visibleProjectAssets} getKey={proj => proj.id} @@ -824,7 +849,7 @@ function Dashboard(props: DashboardProps) { above. } - columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ + columns={columnsFor(columnDisplayMode, backend.platform).map(column => ({ id: column, heading: ColumnHeading(column, backendModule.AssetType.project), render: renderer(column, backendModule.AssetType.project), @@ -916,14 +941,19 @@ function Dashboard(props: DashboardProps) { {query ? ' matching your query' : ''}. } - columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ - id: column, - heading: ColumnHeading( - column, - backendModule.AssetType.directory - ), - render: renderer(column, backendModule.AssetType.directory), - }))} + columns={columnsFor(columnDisplayMode, backend.platform).map( + column => ({ + id: column, + heading: ColumnHeading( + column, + backendModule.AssetType.directory + ), + render: renderer( + column, + backendModule.AssetType.directory + ), + }) + )} onClick={(directoryAsset, event) => { event.stopPropagation() setSelectedAssets( @@ -948,14 +978,19 @@ function Dashboard(props: DashboardProps) { {query ? ' matching your query' : ''}. } - columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ - id: column, - heading: ColumnHeading( - column, - backendModule.AssetType.secret - ), - render: renderer(column, backendModule.AssetType.secret), - }))} + columns={columnsFor(columnDisplayMode, backend.platform).map( + column => ({ + id: column, + heading: ColumnHeading( + column, + backendModule.AssetType.secret + ), + render: renderer( + column, + backendModule.AssetType.secret + ), + }) + )} onClick={(secret, event) => { event.stopPropagation() setSelectedAssets( @@ -998,14 +1033,16 @@ function Dashboard(props: DashboardProps) { {query ? ' matching your query' : ''}. } - columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ - id: column, - heading: ColumnHeading( - column, - backendModule.AssetType.file - ), - render: renderer(column, backendModule.AssetType.file), - }))} + columns={columnsFor(columnDisplayMode, backend.platform).map( + column => ({ + id: column, + heading: ColumnHeading( + column, + backendModule.AssetType.file + ), + render: renderer(column, backendModule.AssetType.file), + }) + )} onClick={(file, event) => { event.stopPropagation() setSelectedAssets( diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx index 998d865274af..3f8f9c37c0d4 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx @@ -88,6 +88,7 @@ function Ide(props: Props) { return } else { const script = document.createElement('script') + script.crossOrigin = 'anonymous' script.src = `${IDE_CDN_URL}/${engineVersion}/index.js.gz` script.onload = async () => { document.body.removeChild(script) @@ -101,6 +102,7 @@ function Ide(props: Props) { } document.body.appendChild(script) const style = document.createElement('link') + style.crossOrigin = 'anonymous' style.rel = 'stylesheet' style.href = `${IDE_CDN_URL}/${engineVersion}/style.css` document.body.appendChild(style) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx index 41d7f838128f..cae206b6166c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx @@ -44,82 +44,77 @@ function TopBar(props: TopBarProps) { return (
-
- {platform === platformModule.Platform.desktop && ( -
- - -
- )} -
- - {projectName ?? 'Dashboard'} - -
- {svg.BARS_ICON} -
- +
-
-
{svg.MAGNIFYING_GLASS_ICON}
- { - setQuery(event.target.value) + {svg.COMPUTER_ICON} + +
+ )} +
+ + {projectName ?? 'Dashboard'} + +
{svg.BARS_ICON}
+ + {projectName ?? 'No project open'} + +
+
+
{svg.MAGNIFYING_GLASS_ICON}
+ { + setQuery(event.target.value) + }} + className="flex-1 mx-2 bg-transparent" + />
-
- help chat + help chat
{svg.SPEECH_BUBBLE_ICON}
{/* User profile and menu. */} -
+
{ event.stopPropagation() diff --git a/build-config.yaml b/build-config.yaml index bf84c332d939..ca035692c78a 100644 --- a/build-config.yaml +++ b/build-config.yaml @@ -1,6 +1,6 @@ # Options intended to be common for all developers. -wasm-size-limit: 15.85 MiB +wasm-size-limit: 15.87 MiB required-versions: # NB. The Rust version is pinned in rust-toolchain.toml. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 61780f67b450..69daf7756f13 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -107,6 +107,26 @@ reproduce it, the faster we can fix the bug! It's also helpful to have the output of `enso --version`, as that will let us know if the bug is Operating System or Architecture specific. +### Turning on verbose logs + +Sometimes, it is helpful to attach a verbose log to your bug report. The way to +enable verbose logging depends on which version of Enso you are using. For a +standalone distribution (`.exe` on Windows, `.AppImage` on Linux), you can +enable verbose logging by passing `-debug.verbose` option. If you are starting +the `project-manager`, or language server separately, then pass +`--log-level trace` option. With verbose logging, there are a lot of messages +printed to the standard output, and it is possible that on slower terminal +emulators this will clog the terminal and hence the whole backend. To avoid +this, we recommend redirecting the output to `/dev/null`, via a command like +`enso -debug.verbose > /dev/null 2>&1`. + +The logs are kept in a central location `$ENSO_DATA_DIRECTORY/log` - on Linux, +they are in `$XDG_DATA_HOME/enso/log` (usually `~/.local/share/enso/log`), and +on Windows they are in `%APPDATA%\enso\log`, see +[distribution.md](distribution/distribution.md) for details. The log level name +consists of the timestamp of the log file creation. There is no automatic log +rotation, so you may want to delete the old logs from time to time. + ## Hacking on Enso This will get you up and running for Enso development, with only a minimal diff --git a/engine/runtime/src/main/scala/org/enso/compiler/context/SuggestionBuilder.scala b/engine/runtime/src/main/scala/org/enso/compiler/context/SuggestionBuilder.scala index 9c5aa819f4f0..835d85be69d1 100644 --- a/engine/runtime/src/main/scala/org/enso/compiler/context/SuggestionBuilder.scala +++ b/engine/runtime/src/main/scala/org/enso/compiler/context/SuggestionBuilder.scala @@ -92,7 +92,7 @@ final class SuggestionBuilder[A: IndexedSource]( } val getters = members .flatMap(_.arguments) - .distinctBy(_.name) + .distinctBy(_.name.name) .map(buildGetter(module, tpName.name, _)) val tpSuggestions = tpe +: conses ++: getters diff --git a/integration-test/tests/engine.rs b/integration-test/tests/engine.rs index 7f046d27e25f..622245b8b875 100644 --- a/integration-test/tests/engine.rs +++ b/integration-test/tests/engine.rs @@ -33,7 +33,7 @@ impl TestOnNewProjectControllersOnly { let initializer = enso_gui::Initializer::new(config); let error_msg = "Couldn't open project."; let ide = initializer.initialize_ide_controller().await.expect(error_msg); - ide.manage_projects().unwrap().create_new_project(None).await.unwrap(); + ide.manage_projects().unwrap().create_new_project(None, None).await.unwrap(); let project = ide.current_project().unwrap(); Self { _ide: ide, project, _executor: executor } } diff --git a/lib/rust/ensogl/pack/js/src/runner/index.ts b/lib/rust/ensogl/pack/js/src/runner/index.ts index 4f42493ced5e..81dd9966408f 100644 --- a/lib/rust/ensogl/pack/js/src/runner/index.ts +++ b/lib/rust/ensogl/pack/js/src/runner/index.ts @@ -426,10 +426,9 @@ export class App { /** Show a spinner. The displayed progress is constant. */ showProgressIndicator(progress: number) { - if (this.progressIndicator) { - this.hideProgressIndicator() + if (this.progressIndicator == null) { + this.progressIndicator = new wasm.ProgressIndicator(this.config) } - this.progressIndicator = new wasm.ProgressIndicator(this.config) this.progressIndicator.set(progress) } diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerController.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerController.scala index 283066070324..988d22e3b598 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerController.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerController.scala @@ -297,6 +297,9 @@ class LanguageServerController( s"Received client ($clientId) disconnect request during shutdown. Ignoring." ) + case ShutDownServer => + logger.debug(s"Received shutdown request during shutdown. Ignoring.") + case m: StartServer => // This instance has not yet been shut down. Retry context.parent.forward(Retry(m)) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerKiller.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerKiller.scala index a0b51b6c6200..f0536a233ee6 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerKiller.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerKiller.scala @@ -2,6 +2,7 @@ package org.enso.projectmanager.infrastructure.languageserver import akka.actor.{Actor, ActorRef, Cancellable, PoisonPill, Props, Terminated} import com.typesafe.scalalogging.LazyLogging +import org.enso.projectmanager.event.ProjectEvent.ProjectClosed import org.enso.projectmanager.infrastructure.languageserver.LanguageServerController.ShutDownServer import org.enso.projectmanager.infrastructure.languageserver.LanguageServerKiller.KillTimeout import org.enso.projectmanager.infrastructure.languageserver.LanguageServerProtocol.{ @@ -10,6 +11,7 @@ import org.enso.projectmanager.infrastructure.languageserver.LanguageServerProto } import org.enso.projectmanager.util.UnhandledLogging +import java.util.UUID import scala.concurrent.duration.FiniteDuration /** An actor that shuts all running language servers. It orchestrates all @@ -20,7 +22,7 @@ import scala.concurrent.duration.FiniteDuration * @param shutdownTimeout a shutdown timeout */ class LanguageServerKiller( - controllers: List[ActorRef], + controllers: Map[UUID, ActorRef], shutdownTimeout: FiniteDuration ) extends Actor with LazyLogging @@ -34,20 +36,22 @@ class LanguageServerKiller( context.stop(self) } else { logger.info("Killing all servers [{}].", controllers) - controllers.foreach(context.watch) - controllers.foreach(_ ! ShutDownServer) + controllers.foreach { case (_, ref) => + context.watch(ref) + ref ! ShutDownServer + } val cancellable = context.system.scheduler.scheduleOnce( shutdownTimeout, self, KillTimeout ) - context.become(killing(controllers.toSet, cancellable, sender())) + context.become(killing(controllers.map(_.swap), cancellable, sender())) } } private def killing( - liveControllers: Set[ActorRef], + liveControllers: Map[ActorRef, UUID], cancellable: Cancellable, replyTo: ActorRef ): Receive = { @@ -63,7 +67,13 @@ class LanguageServerKiller( } case KillTimeout => - liveControllers.foreach(_ ! PoisonPill) + logger.warn( + s"Not all language servers' controllers finished on time. Forcing termination." + ) + liveControllers.foreach { case (actorRef, projectId) => + actorRef ! PoisonPill + context.system.eventStream.publish(ProjectClosed(projectId)) + } context.stop(self) } @@ -80,7 +90,7 @@ object LanguageServerKiller { * @return a configuration object */ def props( - controllers: List[ActorRef], + controllers: Map[UUID, ActorRef], shutdownTimeout: FiniteDuration ): Props = Props(new LanguageServerKiller(controllers, shutdownTimeout)) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerRegistry.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerRegistry.scala index 09eb95249abe..bf371d3805d1 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerRegistry.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/LanguageServerRegistry.scala @@ -88,7 +88,7 @@ class LanguageServerRegistry( case msg @ KillThemAll => val killer = context.actorOf( LanguageServerKiller.props( - serverControllers.values.toList, + serverControllers, timeoutConfig.shutdownTimeout ), "language-server-killer" diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/ShutdownHookActivator.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/ShutdownHookActivator.scala index 80f0b706c4a9..bb6aa2c99d05 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/ShutdownHookActivator.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/languageserver/ShutdownHookActivator.scala @@ -32,12 +32,13 @@ class ShutdownHookActivator[F[+_, +_]: Exec: CovariantFlatMap] private def running( hooks: Map[UUID, List[ShutdownHook[F]]] = - Map.empty.withDefaultValue(List.empty) + Map.empty.withDefaultValue(List.empty), + scheduled: List[UUID] = Nil ): Receive = { case RegisterShutdownHook(projectId, hook) => val realHook = hook.asInstanceOf[ShutdownHook[F]] val updated = hooks.updated(projectId, realHook :: hooks(projectId)) - context.become(running(updated)) + context.become(running(updated, scheduled)) case ProjectClosed(projectId) => val projectHooks = hooks(projectId) @@ -45,13 +46,22 @@ class ShutdownHookActivator[F[+_, +_]: Exec: CovariantFlatMap] context.actorOf( ShutdownHookRunner.props[F](projectId, projectHooks.reverse) ) + context.become(running(hooks - projectId, projectId :: scheduled)) + } else if (scheduled.contains(projectId)) { + logger.debug( + s"Request for starting shutdown hooks has already been filed for project ${projectId}. Ignoring." + ) + } else { + logger.warn( + s"Shutdown hook activator has no recollection of project ${projectId}. Either it was closed already or it never existed. Ignoring." + ) } case ShutdownHooksFired(projectId) => - context.become(running(hooks - projectId)) + context.become(running(hooks, scheduled.filter(_ != projectId))) case ArePendingShutdownHooks => - val arePending = hooks.values.map(_.size).sum != 0 + val arePending = hooks.values.map(_.size).sum != 0 || scheduled.nonEmpty sender() ! arePending } diff --git a/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlSuggestionsRepo.scala b/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlSuggestionsRepo.scala index 28dc737d7c7a..a17e7ca8a3cc 100644 --- a/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlSuggestionsRepo.scala +++ b/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlSuggestionsRepo.scala @@ -1,21 +1,17 @@ package org.enso.searcher.sql -import java.util.UUID +import org.enso.polyglot.runtime.Runtime.Api._ import org.enso.polyglot.{ExportedSymbol, Suggestion} -import org.enso.polyglot.runtime.Runtime.Api.{ - ExportsAction, - ExportsUpdate, - SuggestionAction, - SuggestionUpdate, - SuggestionsDatabaseAction -} import org.enso.searcher.data.QueryResult import org.enso.searcher.{SuggestionEntry, SuggestionsRepo} import slick.jdbc.SQLiteProfile.api._ import slick.jdbc.meta.MTable import slick.relational.RelationalProfile -import scala.collection.immutable.{HashMap, ListMap} +import java.util.UUID + +import scala.collection.immutable.HashMap +import scala.collection.mutable import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} @@ -682,15 +678,28 @@ final class SqlSuggestionsRepo(val db: SqlDatabase)(implicit private def insertAllQuery( suggestions: Iterable[Suggestion] ): DBIO[Seq[Long]] = { - val suggestionsMap = - suggestions.map(s => SuggestionRowUniqueIndex(s) -> s).to(ListMap) - val rows = suggestions.map(toSuggestionRow) - for { - _ <- Suggestions ++= rows - rows <- Suggestions.result - } yield { - val rowsMap = rows.map(r => SuggestionRowUniqueIndex(r) -> r.id.get).toMap - suggestionsMap.keys.map(rowsMap(_)).toSeq + val duplicatesBuilder = Vector.newBuilder[(Suggestion, Suggestion)] + val suggestionsMap: mutable.Map[SuggestionRowUniqueIndex, Suggestion] = + mutable.LinkedHashMap() + suggestions.foreach { suggestion => + val idx = SuggestionRowUniqueIndex(suggestion) + suggestionsMap.put(idx, suggestion).foreach { duplicate => + duplicatesBuilder.addOne((duplicate, suggestion)) + } + } + val duplicates = duplicatesBuilder.result() + if (duplicates.isEmpty) { + val rows = suggestions.map(toSuggestionRow) + for { + _ <- Suggestions ++= rows + rows <- Suggestions.result + } yield { + val rowsMap = + rows.map(r => SuggestionRowUniqueIndex(r) -> r.id.get).toMap + suggestionsMap.keys.map(rowsMap(_)).toSeq + } + } else { + DBIO.failed(SqlSuggestionsRepo.UniqueConstraintViolatedError(duplicates)) } } @@ -1053,3 +1062,14 @@ final class SqlSuggestionsRepo(val db: SqlDatabase)(implicit } yield new UUID(m, l) } + +object SqlSuggestionsRepo { + + /** An error indicating that the database unique constraint was violated. + * + * @param duplicates the entries that violate the unique constraint + */ + final case class UniqueConstraintViolatedError( + duplicates: Seq[(Suggestion, Suggestion)] + ) extends Exception(s"Database unique constraint is violated [$duplicates].") +} diff --git a/lib/scala/searcher/src/test/scala/org/enso/searcher/sql/SuggestionsRepoTest.scala b/lib/scala/searcher/src/test/scala/org/enso/searcher/sql/SuggestionsRepoTest.scala index ff3ed4cd1fe9..bd154d5c37c6 100644 --- a/lib/scala/searcher/src/test/scala/org/enso/searcher/sql/SuggestionsRepoTest.scala +++ b/lib/scala/searcher/src/test/scala/org/enso/searcher/sql/SuggestionsRepoTest.scala @@ -6,14 +6,13 @@ import org.enso.polyglot.{ExportedSymbol, ModuleExports, Suggestion} import org.enso.polyglot.runtime.Runtime.Api import org.enso.searcher.SuggestionEntry import org.enso.searcher.data.QueryResult +import org.enso.searcher.sql.SqlSuggestionsRepo.UniqueConstraintViolatedError import org.enso.searcher.sql.equality.SuggestionsEquality import org.enso.testkit.RetrySpec import org.scalactic.TripleEqualsSupport import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec -import java.sql.SQLException - import scala.concurrent.Await import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration._ @@ -132,7 +131,10 @@ class SuggestionsRepoTest _ <- repo.insertAll(Seq(suggestion.local, suggestion.local)) } yield () - an[SQLException] should be thrownBy Await.result(action, Timeout) + an[UniqueConstraintViolatedError] should be thrownBy Await.result( + action, + Timeout + ) } "select suggestion by id" taggedAs Retry in withRepo { repo =>
+ ))} +