From 66201dc94ce99043b003647d58906aa99e262a57 Mon Sep 17 00:00:00 2001 From: Michael Mauderer Date: Fri, 7 Jul 2023 14:47:10 +0200 Subject: [PATCH] New Prototype AI Searcher (#7146) Adds a new bare-bones AI searcher that can be triggered with `cmd+tab`. It will interpret the searcher input as a prompt to an AI model and replace the created node with the suggestion that was computed. https://github.com/enso-org/enso/assets/1428930/f8403533-54ba-4ea5-9d3c-6bdf3cf336b5 Implements the first step of #7099. # Important Notes Contains some refactoring that allows us to have multiple controllers side by side. So QA testing should make sure that the Component Browser Searcher is still working as before. --- CHANGELOG.md | 3 + app/gui/src/controller/graph.rs | 21 + app/gui/src/controller/searcher.rs | 142 +--- .../src/controller/searcher/breadcrumbs.rs | 3 +- app/gui/src/presenter.rs | 2 +- app/gui/src/presenter/graph.rs | 2 +- app/gui/src/presenter/project.rs | 19 +- app/gui/src/presenter/searcher.rs | 623 +++++------------- app/gui/src/presenter/searcher/ai.rs | 249 +++++++ .../presenter/searcher/component_browser.rs | 446 +++++++++++++ .../{ => component_browser}/provider.rs | 2 +- app/gui/view/graph-editor/src/lib.rs | 1 - app/gui/view/graph-editor/src/shortcuts.rs | 12 - app/gui/view/src/project.rs | 92 ++- app/gui/view/src/root.rs | 2 +- build-config.yaml | 2 +- 16 files changed, 995 insertions(+), 626 deletions(-) create mode 100644 app/gui/src/presenter/searcher/ai.rs create mode 100644 app/gui/src/presenter/searcher/component_browser.rs rename app/gui/src/presenter/searcher/{ => component_browser}/provider.rs (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b48cc4f2b8e..58bd468bed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,6 +191,8 @@ - [Fixed cursor position when ctrl-clicking the node][7014]. Sometimes ctrl-clicking to edit the node placed the mouse cursor in the wrong position in the text. This is fixed now. +- [Added prototype AI Searcher that can be used to create new nodes from + natural language input][7146] [5910]: https://github.com/enso-org/enso/pull/5910 [6279]: https://github.com/enso-org/enso/pull/6279 @@ -208,6 +210,7 @@ [6893]: https://github.com/enso-org/enso/pull/6893 [7028]: https://github.com/enso-org/enso/pull/7028 [7014]: https://github.com/enso-org/enso/pull/7014 +[7146]: https://github.com/enso-org/enso/pull/7146 #### EnsoGL (rendering engine) diff --git a/app/gui/src/controller/graph.rs b/app/gui/src/controller/graph.rs index 02f8cfe12cc..9c09703e038 100644 --- a/app/gui/src/controller/graph.rs +++ b/app/gui/src/controller/graph.rs @@ -77,6 +77,11 @@ pub struct NoPatternOnNode { pub node: node::Id, } +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug, Fail)] +#[fail(display = "Source node has an unsupported pattern, so it cannot form connections.")] +pub struct UnsupportedPatternOnNode; + #[allow(missing_docs)] #[derive(Clone, Copy, Debug, Fail)] #[fail(display = "AST node is missing ID.")] @@ -127,6 +132,22 @@ impl Node { pub fn has_position(&self) -> bool { self.metadata.as_ref().map_or(false, |m| m.position.is_some()) } + + /// Get the node's variable name, if it has one. + pub fn variable_name(&self) -> Result, UnsupportedPatternOnNode> { + // TODO [mwu] + // Here we just require that the whole node's pattern is a single var, like + // `var = expr`. This prevents using pattern subpart (like `x` in + // `Point x y = get_pos`), or basically any node that doesn't stick to `var = expr` + // form. If we wanted to support pattern subparts, the engine would need to send us + // value updates for matched pattern pieces. See the issue: + // https://github.com/enso-org/enso/issues/1038 + if let Some(pattern) = self.info.pattern() { + ast::identifier::as_var(pattern).map(Some).ok_or(UnsupportedPatternOnNode) + } else { + Ok(None) + } + } } impl Deref for Node { diff --git a/app/gui/src/controller/searcher.rs b/app/gui/src/controller/searcher.rs index 7c8dea2c83d..b9f003fb33d 100644 --- a/app/gui/src/controller/searcher.rs +++ b/app/gui/src/controller/searcher.rs @@ -3,17 +3,15 @@ use crate::model::traits::*; use crate::prelude::*; -use crate::controller::graph::executed::Handle; use crate::controller::graph::FailedToCreateNode; use crate::controller::graph::ImportType; use crate::controller::graph::RequiredImport; use crate::controller::searcher::component::group; -use crate::model::execution_context::QualifiedMethodPointer; -use crate::model::execution_context::Visualization; use crate::model::module::NodeEditStatus; use crate::model::module::NodeMetadata; use crate::model::suggestion_database; +use crate::presenter::searcher; use breadcrumbs::Breadcrumbs; use double_representation::graph::GraphInfo; use double_representation::graph::LocationHint; @@ -86,16 +84,6 @@ pub struct NotSupported { #[fail(display = "An action cannot be executed when searcher is in \"edit node\" mode.")] pub struct CannotExecuteWhenEditingNode; -#[allow(missing_docs)] -#[derive(Copy, Clone, Debug, Fail)] -#[fail(display = "An action cannot be executed when searcher is run without `this` argument.")] -pub struct CannotRunWithoutThisArgument; - -#[allow(missing_docs)] -#[derive(Copy, Clone, Debug, Fail)] -#[fail(display = "No visualization data received for an AI suggestion.")] -pub struct NoAIVisualizationDataReceived; - #[allow(missing_docs)] #[derive(Copy, Clone, Debug, Fail)] #[fail(display = "Cannot commit expression in current mode ({:?}).", mode)] @@ -110,12 +98,10 @@ pub struct CannotCommitExpression { // ===================== /// The notification emitted by Searcher Controller -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum Notification { /// A new Suggestion list is available. NewActionList, - /// Code should be inserted by means of using an AI autocompletion. - AISuggestionUpdated(String, text::Range), } @@ -191,18 +177,12 @@ impl ThisNode { /// introduce a variable. pub fn new(id: double_representation::node::Id, graph: &controller::Graph) -> Option { let node = graph.node(id).ok()?; - let (var, needs_to_introduce_pattern) = if let Some(ast) = node.info.pattern() { - // TODO [mwu] - // Here we just require that the whole node's pattern is a single var, like - // `var = expr`. This prevents using pattern subpart (like `x` in - // `Point x y = get_pos`), or basically any node that doesn't stick to `var = expr` - // form. If we wanted to support pattern subparts, the engine would need to send us - // value updates for matched pattern pieces. See the issue: - // https://github.com/enso-org/enso/issues/1038 - (ast::identifier::as_var(ast)?.to_owned(), false) - } else { - (graph.variable_name_for(&node.info).ok()?.repr(), true) - }; + + let existing_var = node.variable_name().ok()?.map(|name| name.to_owned()); + let needs_to_introduce_pattern = existing_var.is_none(); + let make_new_var = || graph.variable_name_for(&node.info).ok().map(|var| var.repr()); + let var = existing_var.or_else(make_new_var)?; + Some(ThisNode { id, var, needs_to_introduce_pattern }) } @@ -242,6 +222,14 @@ impl Mode { Mode::EditNode { node_id, .. } => *node_id, } } + + /// Return the ID of the node used as source for the Searcher. + pub fn source_node(&self) -> Option { + match self { + Mode::NewNode { source_node, .. } => *source_node, + Mode::EditNode { .. } => None, + } + } } /// A fragment filled by single picked suggestion. @@ -550,78 +538,12 @@ impl Searcher { self.notifier.notify(Notification::NewActionList); } - const AI_QUERY_PREFIX: &'static str = "AI:"; - const AI_QUERY_ACCEPT_TOKEN: &'static str = "#"; - const AI_STOP_SEQUENCE: &'static str = "`"; - const AI_GOAL_PLACEHOLDER: &'static str = "__$$GOAL$$__"; - - /// Accepts the current AI query and exchanges it for actual expression. - /// To accomplish this, it performs the following steps: - /// 1. Attaches a visualization to `this`, calling `AI.build_ai_prompt`, to - /// get a data-specific prompt for Open AI; - /// 2. Sends the prompt to the Open AI backend proxy, along with the user - /// query. - /// 3. Replaces the query with the result of the Open AI call. - async fn accept_ai_query( - query: String, - query_range: text::Range, - this: ThisNode, - graph: Handle, - notifier: notification::Publisher, - ) -> FallibleResult { - let vis_ptr = QualifiedMethodPointer::from_qualified_text( - "Standard.Visualization.AI", - "Standard.Visualization.AI", - "build_ai_prompt", - )?; - let vis = Visualization::new(vis_ptr.module.to_owned(), this.id, vis_ptr, vec![]); - let mut result = graph.attach_visualization(vis.clone()).await?; - let next = result.next().await.ok_or(NoAIVisualizationDataReceived)?; - let prompt = std::str::from_utf8(&next)?; - let prompt_with_goal = prompt.replace(Self::AI_GOAL_PLACEHOLDER, &query); - graph.detach_visualization(vis.id).await?; - let completion = graph.get_ai_completion(&prompt_with_goal, Self::AI_STOP_SEQUENCE).await?; - notifier.publish(Notification::AISuggestionUpdated(completion, query_range)).await; - Ok(()) - } - - /// Handles AI queries (i.e. searcher input starting with `"AI:"`). Doesn't - /// do anything if the query doesn't end with a specified "accept" - /// sequence. Otherwise, calls `Self::accept_ai_query` to perform the final - /// replacement. - fn handle_ai_query(&self, query: String) -> FallibleResult { - let len = query.as_bytes().len(); - let range = text::Range::new(Byte::from(0), Byte::from(len)); - let query = query.trim_start_matches(Self::AI_QUERY_PREFIX); - if !query.ends_with(Self::AI_QUERY_ACCEPT_TOKEN) { - return Ok(()); - } - let query = query.trim_end_matches(Self::AI_QUERY_ACCEPT_TOKEN).trim().to_string(); - let this = self.this_arg.clone(); - if this.is_none() { - return Err(CannotRunWithoutThisArgument.into()); - } - let this = this.as_ref().as_ref().unwrap().clone(); - let graph = self.graph.clone_ref(); - let notifier = self.notifier.clone_ref(); - executor::global::spawn(async move { - if let Err(e) = Self::accept_ai_query(query, range, this, graph, notifier).await { - error!("error when handling AI query: {e}"); - } - }); - - Ok(()) - } - /// Set the Searcher Input. /// /// This function should be called each time user modifies Searcher input in view. It may result /// in a new action list (the appropriate notification will be emitted). #[profile(Debug)] pub fn set_input(&self, new_input: String, cursor_position: Byte) -> FallibleResult { - if new_input.starts_with(Self::AI_QUERY_PREFIX) { - return self.handle_ai_query(new_input); - } debug!("Manually setting input to {new_input} with cursor position {cursor_position}"); let parsed_input = input::Input::parse(self.ide.parser(), new_input, cursor_position); let new_context = parsed_input.context().map(|ctx| ctx.into_ast().repr()); @@ -860,7 +782,7 @@ impl Searcher { fn get_expression(&self, input: Ast) -> Ast { match self.this_var() { - Some(this_var) => apply_this_argument(this_var, &input), + Some(this_var) => searcher::apply_this_argument(this_var, &input), None => input, } } @@ -1266,32 +1188,6 @@ impl Drop for EditGuard { // === Helpers === -fn apply_this_argument(this_var: &str, ast: &Ast) -> Ast { - if let Ok(opr) = ast::known::Opr::try_from(ast) { - let shape = ast::SectionLeft { arg: Ast::var(this_var), off: 1, opr: opr.into() }; - Ast::new(shape, None) - } else if let Some(mut infix) = ast::opr::GeneralizedInfix::try_new(ast) { - if let Some(ref mut larg) = &mut infix.left { - larg.arg = apply_this_argument(this_var, &larg.arg); - } else { - infix.left = Some(ast::opr::ArgWithOffset { arg: Ast::var(this_var), offset: 1 }); - } - infix.into_ast() - } else if let Some(mut prefix_chain) = ast::prefix::Chain::from_ast(ast) { - prefix_chain.func = apply_this_argument(this_var, &prefix_chain.func); - prefix_chain.into_ast() - } else { - let shape = ast::Infix { - larg: Ast::var(this_var), - loff: 0, - opr: Ast::opr(ast::opr::predefined::ACCESS), - roff: 0, - rarg: ast.clone_ref(), - }; - Ast::new(shape, None) - } -} - /// Build a component list with a single component, representing the given literal. When used as a /// suggestion, a number literal will be inserted without changes, but a string literal will be /// surrounded by quotation marks. @@ -1315,13 +1211,14 @@ fn component_list_for_literal( pub mod test { use super::*; + use crate::controller::graph::RequiredImport; use crate::controller::ide::plain::ProjectOperationsNotSupported; use crate::executor::test_utils::TestWithLocalPoolExecutor; + use crate::presenter::searcher::apply_this_argument; use crate::test::mock::data::project_qualified_name; use crate::test::mock::data::MAIN_FINISH; use crate::test::mock::data::MODULE_NAME; - use crate::controller::graph::RequiredImport; use engine_protocol::language_server::types::test::value_update_with_type; use engine_protocol::language_server::SuggestionId; use enso_suggestion_database::entry::Argument; @@ -1331,6 +1228,7 @@ pub mod test { use parser::Parser; use std::assert_matches::assert_matches; + pub fn completion_response(results: &[SuggestionId]) -> language_server::response::Completion { language_server::response::Completion { results: results.to_vec(), diff --git a/app/gui/src/controller/searcher/breadcrumbs.rs b/app/gui/src/controller/searcher/breadcrumbs.rs index c2078f6eaae..75af72a2c03 100644 --- a/app/gui/src/controller/searcher/breadcrumbs.rs +++ b/app/gui/src/controller/searcher/breadcrumbs.rs @@ -16,7 +16,8 @@ use model::suggestion_database::Entry; /// A controller that keeps the path of entered modules in the Searcher and provides the /// functionality of the breadcrumbs panel. The integration between the -/// controller and the view is done by the [searcher presenter](crate::presenter::searcher). +/// controller and the view is done by the [searcher +/// presenter](crate::presenter::component_browser_searcher). #[derive(Debug, Clone, CloneRef, Default)] pub struct Breadcrumbs { list: Rc>>, diff --git a/app/gui/src/presenter.rs b/app/gui/src/presenter.rs index 83e845185ff..cd596efec90 100644 --- a/app/gui/src/presenter.rs +++ b/app/gui/src/presenter.rs @@ -28,7 +28,7 @@ pub mod searcher; pub use code::Code; pub use graph::Graph; pub use project::Project; -pub use searcher::Searcher; +pub use searcher::component_browser::ComponentBrowserSearcher; diff --git a/app/gui/src/presenter/graph.rs b/app/gui/src/presenter/graph.rs index 29090a92d9e..9327eca0690 100644 --- a/app/gui/src/presenter/graph.rs +++ b/app/gui/src/presenter/graph.rs @@ -652,7 +652,7 @@ impl ViewUpdate { /// This presenter focuses on the graph structure: nodes, their expressions and types, and /// connections between them. It does not integrate Searcher nor Breadcrumbs (managed by /// [`presenter::Searcher`] and [`presenter::CallStack`] respectively). -#[derive(Debug)] +#[derive(Clone, CloneRef, Debug)] pub struct Graph { network: frp::Network, model: Rc, diff --git a/app/gui/src/presenter/project.rs b/app/gui/src/presenter/project.rs index 36249993a7b..30fd5f699d8 100644 --- a/app/gui/src/presenter/project.rs +++ b/app/gui/src/presenter/project.rs @@ -6,12 +6,16 @@ use crate::prelude::*; use crate::executor::global::spawn_stream_handler; use crate::model::project::synchronized::ProjectNameInvalid; use crate::presenter; +use crate::presenter::searcher::ai::AISearcher; +use crate::presenter::searcher::SearcherPresenter; +use crate::presenter::ComponentBrowserSearcher; use engine_protocol::language_server::ExecutionEnvironment; use engine_protocol::project_manager::ProjectMetadata; use enso_frp as frp; use ide_view as view; use ide_view::project::SearcherParams; +use ide_view::project::SearcherType; use model::module::NotificationKind; use model::project::Notification; use model::project::VcsStatus; @@ -34,7 +38,7 @@ struct Model { status_bar: view::status_bar::View, graph: presenter::Graph, code: presenter::Code, - searcher: RefCell>, + searcher: RefCell>>, available_projects: Rc>>, shortcut_transaction: RefCell>>, } @@ -76,7 +80,12 @@ impl Model { } fn setup_searcher_presenter(&self, params: SearcherParams) { - let new_presenter = presenter::Searcher::setup_controller( + let searcher_constructor = match params.searcher_type { + SearcherType::AiCompletion => AISearcher::setup_searcher_boxed, + SearcherType::ComponentBrowser => ComponentBrowserSearcher::setup_searcher_boxed, + }; + + let new_presenter = searcher_constructor( self.ide_controller.clone_ref(), self.controller.clone_ref(), self.graph_controller.clone_ref(), @@ -84,6 +93,7 @@ impl Model { self.view.clone_ref(), params, ); + match new_presenter { Ok(searcher) => { *self.searcher.borrow_mut() = Some(searcher); @@ -96,11 +106,12 @@ impl Model { fn editing_committed( &self, + view_id: ide_view::graph_editor::NodeId, entry_id: Option, ) -> bool { let searcher = self.searcher.take(); if let Some(searcher) = searcher { - if let Some(created_node) = searcher.expression_accepted(entry_id) { + if let Some(created_node) = searcher.expression_accepted(view_id, entry_id) { self.graph.allow_expression_auto_updates(created_node, true); false } else { @@ -361,7 +372,7 @@ impl Project { }); graph_view.remove_node <+ view.editing_committed.filter_map(f!([model]((node_view, entry)) { - model.editing_committed(*entry).as_some(*node_view) + model.editing_committed(*node_view,*entry).as_some(*node_view) })); eval_ view.editing_aborted(model.editing_aborted()); diff --git a/app/gui/src/presenter/searcher.rs b/app/gui/src/presenter/searcher.rs index e4ab05e31ca..2e36ea91b05 100644 --- a/app/gui/src/presenter/searcher.rs +++ b/app/gui/src/presenter/searcher.rs @@ -1,497 +1,109 @@ -//! The module containing [`Searcher`] presenter. See [`crate::presenter`] documentation to know -//! more about presenters in general. +//! Searcher trait. This trait is implemented by all searchers and exposes the API that is used by +//! the project presenter to interact with the searcher. Contains also some shared logic and +//! utility functions. use crate::prelude::*; use crate::controller::graph::NewNodeInfo; -use crate::controller::searcher::action::Suggestion; -use crate::controller::searcher::component; use crate::controller::searcher::Mode; -use crate::controller::searcher::Notification; -use crate::executor::global::spawn_stream_handler; use crate::model::module::NodeMetadata; -use crate::model::suggestion_database::entry::Kind; use crate::presenter; use crate::presenter::graph::AstNodeId; use crate::presenter::graph::ViewNodeId; -use crate::presenter::searcher::provider::ControllerComponentsProviderExt; -use enso_frp as frp; -use enso_suggestion_database::documentation_ir::EntryDocumentation; -use enso_suggestion_database::documentation_ir::Placeholder; -use enso_text as text; use ide_view as view; use ide_view::component_browser::component_list_panel::grid as component_grid; -use ide_view::component_browser::component_list_panel::BreadcrumbId; -use ide_view::component_browser::component_list_panel::SECTION_NAME_CRUMB_INDEX; use ide_view::graph_editor::GraphEditor; +use ide_view::graph_editor::NodeId; use ide_view::project::SearcherParams; +use ide_view::project::SearcherType; + +pub mod ai; +pub mod component_browser; -// ============== -// === Export === -// ============== - -pub mod provider; - - - -// ============== -// === Errors === -// ============== - -#[allow(missing_docs)] -#[derive(Copy, Clone, Debug, Fail)] -#[fail(display = "No component group with the index {:?}.", _0)] -pub struct NoSuchComponent(component_grid::GroupEntryId); - - - -// ======================== -// === Helper Functions === -// ======================== - -fn title_for_docs(suggestion: &model::suggestion_database::Entry) -> String { - match suggestion.kind { - Kind::Type => format!("Type {}", suggestion.name), - Kind::Constructor => format!("Constructor {}", suggestion.name), - Kind::Function => format!("Function {}", suggestion.name), - Kind::Local => format!("Node {}", suggestion.name), - Kind::Method => { - let preposition = if suggestion.self_type.is_some() { " of " } else { "" }; - let self_type = suggestion.self_type.as_ref().map_or("", |tp| tp.name()); - format!("Method {}{}{}", suggestion.name, preposition, self_type) - } - Kind::Module => format!("Module {}", suggestion.name), - } -} - -fn doc_placeholder_for(suggestion: &model::suggestion_database::Entry) -> String { - let title = title_for_docs(suggestion); - format!("

{title}

No documentation available

") -} - - -// ============= -// === Model === -// ============= - -#[derive(Clone, CloneRef, Debug)] -struct Model { - controller: controller::Searcher, - view: view::project::View, - provider: Rc>>, - input_view: ViewNodeId, -} - -impl Model { - #[profile(Debug)] - fn new( - controller: controller::Searcher, - view: view::project::View, - input_view: ViewNodeId, - ) -> Self { - let provider = default(); - Self { controller, view, provider, input_view } - } - - #[profile(Debug)] - fn input_changed(&self, new_input: &str, cursor_position: text::Byte) { - if let Err(err) = self.controller.set_input(new_input.to_owned(), cursor_position) { - error!("Error while setting new searcher input: {err}."); - } - } - - fn suggestion_for_entry_id( - &self, - id: component_grid::GroupEntryId, - ) -> FallibleResult { - let component: FallibleResult<_> = self - .controller - .provider() - .component_by_view_id(id) - .ok_or_else(|| NoSuchComponent(id).into()); - Ok(match component?.data { - component::Data::FromDatabase { entry, .. } => - Suggestion::FromDatabase(entry.clone_ref()), - component::Data::Virtual { snippet } => Suggestion::Hardcoded(snippet.clone_ref()), - }) - } - - /// Should be called if a suggestion is selected but not used yet. - fn suggestion_selected(&self, entry_id: Option) { - let suggestion = entry_id.map(|id| self.suggestion_for_entry_id(id)); - let to_preview = match suggestion { - Some(Ok(suggestion)) => Some(suggestion), - Some(Err(err)) => { - warn!("Error while previewing suggestion: {err}."); - None - } - None => None, - }; - if let Err(error) = self.controller.preview(to_preview) { - error!("Failed to preview searcher input (selected suggestion: {entry_id:?}) because of error: {error}."); - } - } - - fn suggestion_accepted( - &self, - id: component_grid::GroupEntryId, - ) -> Option<(ViewNodeId, text::Range, ImString)> { - let provider = self.controller.provider(); - let component: FallibleResult<_> = - provider.component_by_view_id(id).ok_or_else(|| NoSuchComponent(id).into()); - let new_code = component.and_then(|component| { - let suggestion = match component.data { - component::Data::FromDatabase { entry, .. } => - Suggestion::FromDatabase(entry.clone_ref()), - component::Data::Virtual { snippet } => Suggestion::Hardcoded(snippet.clone_ref()), - }; - self.controller.use_suggestion(suggestion) - }); - match new_code { - Ok(text::Change { range, text }) => { - self.update_breadcrumbs(); - Some((self.input_view, range, text.into())) - } - Err(err) => { - error!("Error while applying suggestion: {err}."); - None - } - } - } - - fn breadcrumb_selected(&self, id: BreadcrumbId) { - self.controller.select_breadcrumb(id); - } - - fn update_breadcrumbs(&self) { - let names = self.controller.breadcrumbs().into_iter(); - let browser = self.view.searcher(); - // We only update the breadcrumbs starting from the second element because the first - // one is reserved as a section name. - let from = 1; - let breadcrumbs_from = (names.map(Into::into).collect(), from); - browser.model().list.model().breadcrumbs.set_entries_from(breadcrumbs_from); - } - - fn show_breadcrumbs_ellipsis(&self, show: bool) { - let browser = self.view.searcher(); - browser.model().list.model().breadcrumbs.show_ellipsis(show); - } - - fn set_section_name_crumb(&self, text: ImString) { - let browser = self.view.searcher(); - let breadcrumbs = &browser.model().list.model().breadcrumbs; - breadcrumbs.set_entry((SECTION_NAME_CRUMB_INDEX, text.into())); - } - - fn on_active_section_change(&self, section_id: component_grid::SectionId) { - let components = self.controller.components(); - let mut section_names = components.top_module_section_names(); - let name = match section_id { - component_grid::SectionId::Namespace(n) => - section_names.nth(n).map(|n| n.clone_ref()).unwrap_or_default(), - component_grid::SectionId::Popular => "Popular".to_im_string(), - component_grid::SectionId::LocalScope => "Local".to_im_string(), - }; - self.set_section_name_crumb(name); - } - - fn module_entered(&self, module: component_grid::ElementId) { - self.enter_module(module); - } - - fn enter_module(&self, module: component_grid::ElementId) -> Option<()> { - let provider = self.controller.provider(); - let id = if let Some(entry) = module.as_entry_id() { - let component = provider.component_by_view_id(entry)?; - component.id()? - } else { - let group = provider.group_by_view_id(module.group)?; - group.component_id? - }; - self.controller.enter_module(&id); - self.update_breadcrumbs(); - let show_ellipsis = self.controller.last_module_has_submodules(); - self.show_breadcrumbs_ellipsis(show_ellipsis); - Some(()) - } - - fn expression_accepted( - &self, - entry_id: Option, - ) -> Option { - if let Some(entry_id) = entry_id { - self.suggestion_accepted(entry_id); - } - if !self.controller.is_input_empty() { - self.controller.commit_node().map(Some).unwrap_or_else(|err| { - error!("Error while committing node expression: {err}."); - None - }) - } else { - // if input is empty or contains spaces only, we cannot update the node (there is no - // valid AST to assign). Because it is an expected thing, we also do not report error. - None - } - } - - fn documentation_of_component( - &self, - id: view::component_browser::component_list_panel::grid::GroupEntryId, - ) -> EntryDocumentation { - let component = self.controller.provider().component_by_view_id(id); - if let Some(component) = component { - match component.data { - component::Data::FromDatabase { id, .. } => - self.controller.documentation_for_entry(*id), - component::Data::Virtual { snippet } => - snippet.documentation.clone().unwrap_or_default(), - } - } else { - default() - } - } - - fn documentation_of_group(&self, id: component_grid::GroupId) -> EntryDocumentation { - let group = self.controller.provider().group_by_view_id(id); - if let Some(group) = group { - if let Some(id) = group.component_id { - self.controller.documentation_for_entry(id) - } else { - Placeholder::VirtualComponentGroup { name: group.name.clone() }.into() - } - } else { - default() - } - } - - fn should_select_first_entry(&self) -> bool { - self.controller.is_filtering() || self.controller.is_input_empty() - } -} - -/// The Searcher presenter, synchronizing state between searcher view and searcher controller. -/// -/// The presenter should be created for one instantiated searcher controller (when node starts to -/// being edited). Alternatively, the [`setup_controller`] method covers constructing the controller -/// and the presenter. -#[derive(Debug)] -pub struct Searcher { - _network: frp::Network, - model: Rc, -} - -impl Searcher { - /// Constructor. The returned structure works right away. - #[profile(Task)] - pub fn new( - controller: controller::Searcher, - view: view::project::View, - input_view: ViewNodeId, - ) -> Self { - let model = Rc::new(Model::new(controller, view, input_view)); - let network = frp::Network::new("presenter::Searcher"); - - let graph = &model.view.graph().frp; - let browser = model.view.searcher(); - - frp::extend! { network - eval model.view.searcher_input_changed ([model]((expr, selections)) { - let cursor_position = selections.last().map(|sel| sel.end).unwrap_or_default(); - model.input_changed(expr, cursor_position); - }); - - action_list_changed <- any_mut::<()>(); - // When the searcher input is changed, we need to update immediately the list of - // entries in the component browser (as opposed to waiting for a `NewActionList` event - // which is delivered asynchronously). This is because the input may be accepted - // before the asynchronous event is delivered and to accept the correct entry the list - // must be up-to-date. - action_list_changed <+ model.view.searcher_input_changed.constant(()); - - eval_ model.view.toggle_component_browser_private_entries_visibility ( - model.controller.reload_list()); - } - - let grid = &browser.model().list.model().grid; - let navigator = &browser.model().list.model().section_navigator; - let breadcrumbs = &browser.model().list.model().breadcrumbs; - let documentation = &browser.model().documentation; - frp::extend! { network - eval_ action_list_changed ([model, grid, navigator] { - model.provider.take(); - let controller_provider = model.controller.provider(); - let namespace_section_count = controller_provider.namespace_section_count(); - navigator.set_namespace_section_count.emit(namespace_section_count); - let provider = provider::Component::provide_new_list(controller_provider, &grid); - *model.provider.borrow_mut() = Some(provider); - }); - grid.select_first_entry <+ action_list_changed.filter(f_!(model.should_select_first_entry())); - input_edit <- grid.suggestion_accepted.filter_map(f!((e) model.suggestion_accepted(*e))); - graph.edit_node_expression <+ input_edit; - - entry_selected <- grid.active.map(|&s| s?.as_entry_id()); - selected_entry_changed <- entry_selected.on_change().constant(()); - grid.unhover_element <+ any2( - &selected_entry_changed, - &model.view.toggle_component_browser_private_entries_visibility, - ); - hovered_not_selected <- all_with(&grid.hovered, &grid.active, |h, s| { - match (h, s) { - (Some(h), Some(s)) => h != s, - _ => false, - } - }); - documentation.frp.show_hovered_item_preview_caption <+ hovered_not_selected; - docs_params <- all3(&action_list_changed, &grid.active, &grid.hovered); - docs <- docs_params.filter_map(f!([model]((_, selected, hovered)) { - let entry = hovered.as_ref().or(selected.as_ref()); - entry.map(|entry| { - if let Some(group_id) = entry.as_header() { - model.documentation_of_group(group_id) - } else { - let entry_id = entry.as_entry_id().expect("GroupEntryId"); - model.documentation_of_component(entry_id) - } - }) - })); - documentation.frp.display_documentation <+ docs; - - eval_ grid.suggestion_accepted([]analytics::remote_log_event("component_browser::suggestion_accepted")); - eval entry_selected((entry) model.suggestion_selected(*entry)); - eval grid.module_entered((id) model.module_entered(*id)); - eval breadcrumbs.selected((id) model.breadcrumb_selected(*id)); - active_section <- grid.active_section.filter_map(|s| *s); - eval active_section((section) model.on_active_section_change(*section)); - } - - let weak_model = Rc::downgrade(&model); - let notifications = model.controller.subscribe(); - let graph = model.view.graph().clone(); - spawn_stream_handler(weak_model, notifications, move |notification, _| { - match notification { - Notification::NewActionList => action_list_changed.emit(()), - Notification::AISuggestionUpdated(expr, range) => - graph.edit_node_expression((input_view, range, ImString::new(expr))), - }; - std::future::ready(()) - }); - - Self { model, _network: network } - } - - /// Create a new input node for use in the searcher. Initiates a new node in the ast and - /// associates it with the already existing view. - /// - /// Returns the new node id and optionally the source node which was selected/dragged when - /// creating this node. - fn create_input_node( - parameters: SearcherParams, - graph: &presenter::Graph, - graph_editor: &GraphEditor, - graph_controller: &controller::Graph, - ) -> FallibleResult<(ast::Id, Option)> { - /// The expression to be used for newly created nodes when initialising the searcher without - /// an existing node. - const DEFAULT_INPUT_EXPRESSION: &str = "Nothing"; - let SearcherParams { input, source_node, .. } = parameters; - - let view_data = graph_editor.model.nodes.get_cloned_ref(&input); - - let position = view_data.map(|node| node.position().xy()); - let position = position.map(|vector| model::module::Position { vector }); - - let metadata = NodeMetadata { position, ..default() }; - let mut new_node = NewNodeInfo::new_pushed_back(DEFAULT_INPUT_EXPRESSION); - new_node.metadata = Some(metadata); - new_node.introduce_pattern = false; - let transaction_name = "Add code for created node's visualization preview."; - let _transaction = graph_controller - .undo_redo_repository() - .open_ignored_transaction_or_ignore_current(transaction_name); - let created_node = graph_controller.add_node(new_node)?; - - graph.assign_node_view_explicitly(input, created_node); - - let source_node = source_node.and_then(|id| graph.ast_node_of_view(id.node)); - - Ok((created_node, source_node)) - } - +/// Trait for the searcher. +pub trait SearcherPresenter: Debug { /// Initiate the operating mode for the searcher based on the given [`SearcherParams`]. If the /// view associated with the input node given in the parameters does not yet exist, it will /// be created. /// /// Returns the [`Mode`] that should be used for the searcher. - pub fn init_input_node( + fn init_input_node( parameters: SearcherParams, - graph: &presenter::Graph, + graph_presenter: &presenter::Graph, graph_editor: &GraphEditor, graph_controller: &controller::Graph, - ) -> FallibleResult { + ) -> FallibleResult + where + Self: Sized, + { let SearcherParams { input, .. } = parameters; - let ast_node = graph.ast_node_of_view(input); + let ast_node = graph_presenter.ast_node_of_view(input); let mode = match ast_node { - Some(node_id) => Ok(Mode::EditNode { node_id }), + Some(node_id) => Mode::EditNode { node_id }, None => { let (new_node, source_node) = - Self::create_input_node(parameters, graph, graph_editor, graph_controller)?; - Ok(Mode::NewNode { node_id: new_node, source_node }) + create_input_node(parameters, graph_presenter, graph_editor, graph_controller)?; + Mode::NewNode { node_id: new_node, source_node } } }; - let target_node = mode.as_ref().map(|mode| mode.node_id()); - if let Ok(target_node) = target_node { - graph.allow_expression_auto_updates(target_node, false); + let target_node = mode.node_id(); + + // We only want to show the preview of the node if it is a component browser searcher. + if matches!(parameters.searcher_type, SearcherType::ComponentBrowser) { + if let Some(target_node_view) = graph_presenter.view_id_of_ast_node(target_node) { + graph_editor.model.with_node(target_node_view, |node| node.show_preview()); + } + } else { + warn!("No view associated with node {:?}.", target_node); } - mode + // We disable auto-updates for the expression of the node, so we can set the expression + // of the input node without triggering an update of the graph. This is used, for example, + // to show a preview of the item selected in the component browser without changing the + // text the user has typed on the searcher input node. + graph_presenter.allow_expression_auto_updates(target_node, false); + + Ok(mode) } /// Setup new, appropriate searcher controller for the edition of `node_view`, and construct /// presenter handling it. - #[profile(Task)] - pub fn setup_controller( + fn setup_searcher( ide_controller: controller::Ide, project_controller: controller::Project, graph_controller: controller::ExecutedGraph, graph_presenter: &presenter::Graph, view: view::project::View, parameters: SearcherParams, - ) -> FallibleResult { - // We get the position for searcher before initializing the input node, because the - // added node will affect the AST, and the position will become incorrect. - let position_in_code = graph_controller.graph().definition_end_location()?; + ) -> FallibleResult + where + Self: Sized; - let mode = Self::init_input_node( - parameters, - graph_presenter, - view.graph(), - &graph_controller.graph(), - )?; - - let searcher_controller = controller::Searcher::new_from_graph_controller( + /// As [`setup_searcher`], but returns a boxed presenter. + fn setup_searcher_boxed( + ide_controller: controller::Ide, + project_controller: controller::Project, + graph_controller: controller::ExecutedGraph, + graph_presenter: &presenter::Graph, + view: view::project::View, + parameters: SearcherParams, + ) -> FallibleResult> + where + Self: Sized + 'static, + { + // Avoiding the cast would require a local variable, which would not be more readable. + #![allow(trivial_casts)] + Self::setup_searcher( ide_controller, - &project_controller.model, + project_controller, graph_controller, - mode, - parameters.cursor_position, - position_in_code, - )?; - - // Clear input on a new node. By default this will be set to whatever is used as the default - // content of the new node. - if let Mode::NewNode { source_node, .. } = mode { - if source_node.is_none() { - if let Err(e) = searcher_controller.set_input("".to_string(), text::Byte(0)) { - error!("Failed to clear input when creating searcher for a new node: {e:?}."); - } - } - } - - let input = parameters.input; - Ok(Self::new(searcher_controller, view, input)) + graph_presenter, + view, + parameters, + ) + .map(|searcher| Box::new(searcher) as Box) } /// Expression accepted in Component Browser. @@ -500,32 +112,111 @@ impl Searcher { /// editing finishes. The `entry_id` might be none in case where the user want to accept /// the node input without any entry selected. If the commitment results in creating a new /// node, its AST ID is returned. - pub fn expression_accepted( - self, + fn expression_accepted( + self: Box, + node_id: NodeId, entry_id: Option, - ) -> Option { - self.model.expression_accepted(entry_id) - } + ) -> Option; /// Abort editing, without taking any action. /// /// This method takes `self`, as the presenter (with the searcher view) should be dropped once /// editing finishes. - pub fn abort_editing(self) { - self.model.controller.abort_editing() - } + fn abort_editing(self: Box); /// Returns the node view that is being edited by the searcher. - pub fn input_view(&self) -> ViewNodeId { - self.model.input_view - } + fn input_view(&self) -> ViewNodeId; +} - /// Returns true if the entry under given index is one of the examples. - pub fn is_entry_an_example(&self, entry: view::searcher::entry::Id) -> bool { - use crate::controller::searcher::action::Action::Example; - let controller = &self.model.controller; - let entry = controller.actions().list().and_then(|l| l.get_cloned(entry)); - entry.map_or(false, |e| matches!(e.action, Example(_))) +// === Helpers === + +/// Create a new AST that combines a `this` argument with the given AST. For example, to add a +/// method call `sort` to this argument `table`. That would result in an AST that represents +/// `table.sort`. +pub fn apply_this_argument(this_var: &str, ast: &Ast) -> Ast { + if let Ok(opr) = ast::known::Opr::try_from(ast) { + let shape = ast::SectionLeft { arg: Ast::var(this_var), off: 1, opr: opr.into() }; + Ast::new(shape, None) + } else if let Some(mut infix) = ast::opr::GeneralizedInfix::try_new(ast) { + if let Some(ref mut larg) = &mut infix.left { + larg.arg = apply_this_argument(this_var, &larg.arg); + } else { + infix.left = Some(ast::opr::ArgWithOffset { arg: Ast::var(this_var), offset: 1 }); + } + infix.into_ast() + } else if let Some(mut prefix_chain) = ast::prefix::Chain::from_ast(ast) { + prefix_chain.func = apply_this_argument(this_var, &prefix_chain.func); + prefix_chain.into_ast() + } else { + let shape = ast::Infix { + larg: Ast::var(this_var), + loff: 0, + opr: Ast::opr(ast::opr::predefined::ACCESS), + roff: 0, + rarg: ast.clone_ref(), + }; + Ast::new(shape, None) } } + +/// Initialise the expression in case there is a source node for the new node. This allows us to +/// correctly render an edge from the source node to the new node. +fn initialise_with_this_argument( + created_node: ast::Id, + source_node: Option, + graph_controller: &controller::Graph, +) { + let node = source_node.and_then(|node| graph_controller.node(node).ok()); + let this_expr = + node.and_then(|node| node.variable_name().ok().flatten().map(|name| name.to_string())); + let initial_expression = + this_expr.map(|this_expr| apply_this_argument(&this_expr, &Ast::blank())); + if let Some(initial_expression) = initial_expression { + if let Err(e) = graph_controller.set_expression(created_node, initial_expression.repr()) { + warn!("Failed to set initial expression for node {:?}: {}", created_node, e); + } + } else { + warn!("Failed to create initial expression for node {:?}.", created_node); + } +} + +/// Create a new input node for use in the searcher. Initiates a new node in the ast and +/// associates it with the already existing view. +/// +/// Returns the new node id and optionally the source node which was selected/dragged when +/// creating this node. +fn create_input_node( + parameters: SearcherParams, + graph: &presenter::Graph, + graph_editor: &GraphEditor, + graph_controller: &controller::Graph, +) -> FallibleResult<(ast::Id, Option)> { + /// The expression to be used for newly created nodes when initialising the searcher without + /// an existing node. + const DEFAULT_INPUT_EXPRESSION: &str = "Nothing"; + let SearcherParams { input, source_node, .. } = parameters; + + let view_data = graph_editor.model.nodes.get_cloned_ref(&input); + + let position = view_data.map(|node| node.position().xy()); + let position = position.map(|vector| model::module::Position { vector }); + + let metadata = NodeMetadata { position, ..default() }; + let mut new_node = NewNodeInfo::new_pushed_back(DEFAULT_INPUT_EXPRESSION); + new_node.metadata = Some(metadata); + new_node.introduce_pattern = false; + let transaction_name = "Add code for created node's visualization preview."; + let _transaction = graph_controller + .undo_redo_repository() + .open_ignored_transaction_or_ignore_current(transaction_name); + let created_node = graph_controller.add_node(new_node)?; + + graph.assign_node_view_explicitly(input, created_node); + + let source_node = source_node.and_then(|id| graph.ast_node_of_view(id.node)); + + initialise_with_this_argument(created_node, source_node, graph_controller); + + Ok((created_node, source_node)) +} diff --git a/app/gui/src/presenter/searcher/ai.rs b/app/gui/src/presenter/searcher/ai.rs new file mode 100644 index 00000000000..b2b43cb39a4 --- /dev/null +++ b/app/gui/src/presenter/searcher/ai.rs @@ -0,0 +1,249 @@ +//! AI Searcher presenter. This is a presenter for the AI Searcher component. It contains the +//! logic for handling the user input and the logic for processing the searcher input as a prompt +//! used for the AI model. + +use crate::prelude::*; + +use crate::controller::searcher::input; +use crate::controller::searcher::Mode; +use crate::controller::searcher::ThisNode; +use crate::controller::ExecutedGraph; +use crate::controller::Project; +use crate::model::execution_context::QualifiedMethodPointer; +use crate::model::execution_context::Visualization; +use crate::presenter::graph::AstNodeId; +use crate::presenter::graph::ViewNodeId; +use crate::presenter::searcher::apply_this_argument; +use crate::presenter::searcher::SearcherPresenter; +use crate::presenter::Graph; + +use enso_frp as frp; +use enso_prelude::FallibleResult; +use enso_text as text; +use enso_text::Byte; +use ide_view::component_browser::component_list_panel::grid::GroupEntryId; +use ide_view::graph_editor::GraphEditor; +use ide_view::graph_editor::NodeId; +use ide_view::project; +use ide_view::project::SearcherParams; + + + +// ============= +// === Model === +// ============= + +#[derive(Debug)] +struct Model { + view: project::View, + input_view: ViewNodeId, + graph_controller: ExecutedGraph, + graph_presenter: Graph, + this_arg: Rc>, + input_expression: RefCell, + mode: Mode, + ide_controller: controller::Ide, +} + +impl Model { + fn input_changed(&self, new_input: &str) { + self.input_expression.replace(new_input.to_string()); + } +} + + +// ================== +// === AISearcher === +// ================== + +/// Searcher that uses the user input as a prompt for an AI model that then generates a new node +/// that is inserted into the graph. +#[derive(Clone, Debug)] +pub struct AISearcher { + _network: frp::Network, + model: Rc, +} + +impl SearcherPresenter for AISearcher { + fn setup_searcher( + ide_controller: controller::Ide, + _project_controller: Project, + graph_controller: ExecutedGraph, + graph_presenter: &Graph, + view: project::View, + parameters: SearcherParams, + ) -> FallibleResult + where + Self: Sized, + { + let mode = Self::init_input_node( + parameters, + graph_presenter, + view.graph(), + &graph_controller.graph(), + )?; + let this_arg = Rc::new(match mode { + Mode::NewNode { source_node: Some(node), .. } => + ThisNode::new(node, &graph_controller.graph()), + _ => None, + }); + + let model = Rc::new(Model { + view, + input_view: parameters.input, + graph_controller, + graph_presenter: graph_presenter.clone_ref(), + this_arg, + input_expression: default(), + mode, + ide_controller, + }); + + let network = frp::Network::new("AI Searcher"); + frp::extend! { network + eval model.view.searcher_input_changed ([model]((expr, _selections)) { + model.input_changed(expr); + }); + } + Ok(Self { model, _network: network }) + } + + fn expression_accepted( + self: Box, + node_id: NodeId, + _entry_id: Option, + ) -> Option { + let ast_id = self.model.graph_presenter.ast_node_of_view(node_id)?; + let expression = self.model.input_expression.borrow().clone(); + if let Err(e) = self.handle_ai_query(expression.repr()) { + warn!("Failed to handle AI query: {:?}", e); + self.abort_editing(); + }; + Some(ast_id) + } + + fn abort_editing(self: Box) { + let node = self.model.mode.node_id(); + if let Err(e) = self.model.graph_controller.graph().remove_node(node) { + warn!("Failed to remove searcher input after aborting editing: {:?}", e); + } + } + + fn input_view(&self) -> ViewNodeId { + self.model.input_view + } +} + +#[allow(missing_docs)] +#[derive(Copy, Clone, Debug, Fail)] +#[fail(display = "An action cannot be executed when searcher is run without `this` argument.")] +pub struct CannotRunWithoutThisArgument; + +#[allow(missing_docs)] +#[derive(Copy, Clone, Debug, Fail)] +#[fail(display = "No visualization data received for an AI suggestion.")] +pub struct NoAIVisualizationDataReceived; + +/// The notification emitted by Searcher Controller +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Notification { + /// Code should be inserted by means of using an AI autocompletion. + AISuggestionUpdated(String, text::Range), +} + +impl AISearcher { + const AI_STOP_SEQUENCE: &'static str = "`"; + const AI_GOAL_PLACEHOLDER: &'static str = "__$$GOAL$$__"; + + /// Accepts the current AI query and exchanges it for actual expression. + /// To accomplish this, it performs the following steps: + /// 1. Attaches a visualization to `this`, calling `AI.build_ai_prompt`, to + /// get a data-specific prompt for Open AI; + /// 2. Sends the prompt to the Open AI backend proxy, along with the user + /// query. + /// 3. Replaces the query with the result of the Open AI call. + async fn accept_ai_query( + mode: Mode, + query: String, + this: ThisNode, + graph: ExecutedGraph, + _graph_view: GraphEditor, + _input_view: ViewNodeId, + ide: controller::Ide, + ) -> FallibleResult { + let vis_ptr = QualifiedMethodPointer::from_qualified_text( + "Standard.Visualization.AI", + "Standard.Visualization.AI", + "build_ai_prompt", + )?; + let vis = Visualization::new(vis_ptr.module.clone(), this.id, vis_ptr, vec![]); + let mut result = graph.attach_visualization(vis.clone()).await?; + let next = result.next().await.ok_or(NoAIVisualizationDataReceived)?; + let prompt = std::str::from_utf8(&next)?; + let prompt_with_goal = prompt.replace(Self::AI_GOAL_PLACEHOLDER, &query); + graph.detach_visualization(vis.id).await?; + let completion = graph.get_ai_completion(&prompt_with_goal, Self::AI_STOP_SEQUENCE).await?; + let parser = ide.parser(); + let new_expression = input::Input::parse(parser, completion, 0.into()); + let new_expression_ast = new_expression.ast().cloned().unwrap(); + let expression_to_insert = apply_this_argument(&this.var, &new_expression_ast); + Self::commit_node(mode, graph, expression_to_insert, this)?; + Ok(()) + } + + fn commit_node( + mode: Mode, + graph: ExecutedGraph, + expression: Ast, + this_arg: ThisNode, + ) -> FallibleResult { + let node_id = mode.node_id(); + let graph = graph.graph(); + graph.set_expression_ast(node_id, expression)?; + if let Mode::NewNode { .. } = mode { + graph.introduce_name_on(node_id)?; + } + this_arg.introduce_pattern(graph.clone_ref())?; + + Ok(()) + } + + /// Handles AI queries (i.e. searcher input starting with `"AI:"`). Doesn't + /// do anything if the query doesn't end with a specified "accept" + /// sequence. Otherwise, calls `Self::accept_ai_query` to perform the final + /// replacement. + fn handle_ai_query(&self, query: String) -> FallibleResult { + let this = self.model.this_arg.clone(); + if this.is_none() { + return Err(CannotRunWithoutThisArgument.into()); + } + let this = this.as_ref().as_ref().unwrap().clone(); + let graph = self.model.graph_controller.clone_ref(); + let graph_view = self.model.view.graph().clone(); + let input_view = self.model.input_view; + let mode = self.model.mode; + let ide = self.model.ide_controller.clone_ref(); + executor::global::spawn(async move { + let query_result = Self::accept_ai_query( + mode, + query, + this, + graph.clone_ref(), + graph_view, + input_view, + ide.clone_ref(), + ) + .await; + if let Err(e) = query_result { + let error_message = format!("Error when handling AI query: {e}"); + ide.status_notifications().publish_event(error_message); + error!("Error when handling AI query: {e}"); + if let Err(e) = graph.graph().remove_node(mode.node_id()) { + warn!("Failed to remove searcher view node after AI query error: {e}"); + } + } + }); + + Ok(()) + } +} diff --git a/app/gui/src/presenter/searcher/component_browser.rs b/app/gui/src/presenter/searcher/component_browser.rs new file mode 100644 index 00000000000..2c5cddf7c40 --- /dev/null +++ b/app/gui/src/presenter/searcher/component_browser.rs @@ -0,0 +1,446 @@ +//! The module containing [`ComponentBrowserSearcher`] presenter. See [`crate::presenter`] +//! documentation to know more about presenters in general. + +use crate::prelude::*; + +use crate::controller::searcher::action::Suggestion; +use crate::controller::searcher::component; +use crate::controller::searcher::Mode; +use crate::controller::searcher::Notification; +use crate::executor::global::spawn_stream_handler; +use crate::model::suggestion_database::entry::Kind; +use crate::presenter; +use crate::presenter::graph::AstNodeId; +use crate::presenter::graph::ViewNodeId; +use crate::presenter::searcher::component_browser::provider::ControllerComponentsProviderExt; + +use crate::presenter::searcher::SearcherPresenter; +use enso_frp as frp; +use enso_suggestion_database::documentation_ir::EntryDocumentation; +use enso_suggestion_database::documentation_ir::Placeholder; +use enso_text as text; +use ide_view as view; +use ide_view::component_browser; +use ide_view::component_browser::component_list_panel::grid as component_grid; +use ide_view::component_browser::component_list_panel::BreadcrumbId; +use ide_view::component_browser::component_list_panel::SECTION_NAME_CRUMB_INDEX; +use ide_view::graph_editor::NodeId; +use ide_view::project::SearcherParams; + + +// ============== +// === Export === +// ============== + +pub mod provider; + + +// ============== +// === Errors === +// ============== + +#[allow(missing_docs)] +#[derive(Copy, Clone, Debug, Fail)] +#[fail(display = "No component group with the index {:?}.", _0)] +pub struct NoSuchComponent(component_grid::GroupEntryId); + + + +// ======================== +// === Helper Functions === +// ======================== + +fn title_for_docs(suggestion: &model::suggestion_database::Entry) -> String { + match suggestion.kind { + Kind::Type => format!("Type {}", suggestion.name), + Kind::Constructor => format!("Constructor {}", suggestion.name), + Kind::Function => format!("Function {}", suggestion.name), + Kind::Local => format!("Node {}", suggestion.name), + Kind::Method => { + let preposition = if suggestion.self_type.is_some() { " of " } else { "" }; + let self_type = suggestion.self_type.as_ref().map_or("", |tp| tp.name()); + format!("Method {}{}{}", suggestion.name, preposition, self_type) + } + Kind::Module => format!("Module {}", suggestion.name), + } +} + +fn doc_placeholder_for(suggestion: &model::suggestion_database::Entry) -> String { + let title = title_for_docs(suggestion); + format!("

{title}

No documentation available

") +} + + +// ============= +// === Model === +// ============= + +#[derive(Clone, CloneRef, Debug)] +struct Model { + controller: controller::Searcher, + project: view::project::View, + provider: Rc>>, + input_view: ViewNodeId, + view: component_browser::View, +} + +impl Model { + #[profile(Debug)] + fn new( + controller: controller::Searcher, + project: view::project::View, + input_view: ViewNodeId, + view: component_browser::View, + ) -> Self { + let provider = default(); + Self { controller, project, view, provider, input_view } + } + + #[profile(Debug)] + fn input_changed(&self, new_input: &str, cursor_position: text::Byte) { + if let Err(err) = self.controller.set_input(new_input.to_owned(), cursor_position) { + error!("Error while setting new searcher input: {err}."); + } + } + + fn suggestion_for_entry_id( + &self, + id: component_grid::GroupEntryId, + ) -> FallibleResult { + let component: FallibleResult<_> = self + .controller + .provider() + .component_by_view_id(id) + .ok_or_else(|| NoSuchComponent(id).into()); + Ok(match component?.data { + component::Data::FromDatabase { entry, .. } => + Suggestion::FromDatabase(entry.clone_ref()), + component::Data::Virtual { snippet } => Suggestion::Hardcoded(snippet.clone_ref()), + }) + } + + /// Should be called if a suggestion is selected but not used yet. + fn suggestion_selected(&self, entry_id: Option) { + let suggestion = entry_id.map(|id| self.suggestion_for_entry_id(id)); + let to_preview = match suggestion { + Some(Ok(suggestion)) => Some(suggestion), + Some(Err(err)) => { + warn!("Error while previewing suggestion: {err}."); + None + } + None => None, + }; + if let Err(error) = self.controller.preview(to_preview) { + error!("Failed to preview searcher input (selected suggestion: {entry_id:?}) because of error: {error}."); + } + } + + fn suggestion_accepted( + &self, + id: component_grid::GroupEntryId, + ) -> Option<(ViewNodeId, text::Range, ImString)> { + let provider = self.controller.provider(); + let component: FallibleResult<_> = + provider.component_by_view_id(id).ok_or_else(|| NoSuchComponent(id).into()); + let new_code = component.and_then(|component| { + let suggestion = match component.data { + component::Data::FromDatabase { entry, .. } => + Suggestion::FromDatabase(entry.clone_ref()), + component::Data::Virtual { snippet } => Suggestion::Hardcoded(snippet.clone_ref()), + }; + self.controller.use_suggestion(suggestion) + }); + match new_code { + Ok(text::Change { range, text }) => { + self.update_breadcrumbs(); + Some((self.input_view, range, text.into())) + } + Err(err) => { + error!("Error while applying suggestion: {err}."); + None + } + } + } + + fn breadcrumb_selected(&self, id: BreadcrumbId) { + self.controller.select_breadcrumb(id); + } + + fn update_breadcrumbs(&self) { + let names = self.controller.breadcrumbs().into_iter(); + let browser = &self.view; + // We only update the breadcrumbs starting from the second element because the first + // one is reserved as a section name. + let from = 1; + let breadcrumbs_from = (names.map(Into::into).collect(), from); + browser.model().list.model().breadcrumbs.set_entries_from(breadcrumbs_from); + } + + fn show_breadcrumbs_ellipsis(&self, show: bool) { + let browser = &self.view; + browser.model().list.model().breadcrumbs.show_ellipsis(show); + } + + fn set_section_name_crumb(&self, text: ImString) { + let browser = &self.view; + let breadcrumbs = &browser.model().list.model().breadcrumbs; + breadcrumbs.set_entry((SECTION_NAME_CRUMB_INDEX, text.into())); + } + + fn on_active_section_change(&self, section_id: component_grid::SectionId) { + let components = self.controller.components(); + let mut section_names = components.top_module_section_names(); + let name = match section_id { + component_grid::SectionId::Namespace(n) => + section_names.nth(n).map(|n| n.clone_ref()).unwrap_or_default(), + component_grid::SectionId::Popular => "Popular".to_im_string(), + component_grid::SectionId::LocalScope => "Local".to_im_string(), + }; + self.set_section_name_crumb(name); + } + + fn module_entered(&self, module: component_grid::ElementId) { + self.enter_module(module); + } + + fn enter_module(&self, module: component_grid::ElementId) -> Option<()> { + let provider = self.controller.provider(); + let id = if let Some(entry) = module.as_entry_id() { + let component = provider.component_by_view_id(entry)?; + component.id()? + } else { + let group = provider.group_by_view_id(module.group)?; + group.component_id? + }; + self.controller.enter_module(&id); + self.update_breadcrumbs(); + let show_ellipsis = self.controller.last_module_has_submodules(); + self.show_breadcrumbs_ellipsis(show_ellipsis); + Some(()) + } + + fn expression_accepted( + &self, + _node_id: NodeId, + entry_id: Option, + ) -> Option { + if let Some(entry_id) = entry_id { + self.suggestion_accepted(entry_id); + } + if !self.controller.is_input_empty() { + self.controller.commit_node().map(Some).unwrap_or_else(|err| { + error!("Error while committing node expression: {err}."); + None + }) + } else { + // if input is empty or contains spaces only, we cannot update the node (there is no + // valid AST to assign). Because it is an expected thing, we also do not report error. + None + } + } + + fn documentation_of_component( + &self, + id: view::component_browser::component_list_panel::grid::GroupEntryId, + ) -> EntryDocumentation { + let component = self.controller.provider().component_by_view_id(id); + if let Some(component) = component { + match component.data { + component::Data::FromDatabase { id, .. } => + self.controller.documentation_for_entry(*id), + component::Data::Virtual { snippet } => + snippet.documentation.clone().unwrap_or_default(), + } + } else { + default() + } + } + + fn documentation_of_group(&self, id: component_grid::GroupId) -> EntryDocumentation { + let group = self.controller.provider().group_by_view_id(id); + if let Some(group) = group { + if let Some(id) = group.component_id { + self.controller.documentation_for_entry(id) + } else { + Placeholder::VirtualComponentGroup { name: group.name.clone() }.into() + } + } else { + default() + } + } + + fn should_select_first_entry(&self) -> bool { + self.controller.is_filtering() || self.controller.is_input_empty() + } +} + +/// The Searcher presenter, synchronizing state between searcher view and searcher controller. +/// +/// The presenter should be created for one instantiated searcher controller (when a node starts +/// being edited). Alternatively, the [`setup_controller`] method covers constructing the controller +/// and the presenter. +#[derive(Debug)] +pub struct ComponentBrowserSearcher { + _network: frp::Network, + model: Rc, +} + + +impl SearcherPresenter for ComponentBrowserSearcher { + #[profile(Task)] + fn setup_searcher( + ide_controller: controller::Ide, + project_controller: controller::Project, + graph_controller: controller::ExecutedGraph, + graph_presenter: &presenter::Graph, + view: view::project::View, + parameters: SearcherParams, + ) -> FallibleResult { + // We get the position for searcher before initializing the input node, because the + // added node will affect the AST, and the position will become incorrect. + let position_in_code = graph_controller.graph().definition_end_location()?; + + let mode = Self::init_input_node( + parameters, + graph_presenter, + view.graph(), + &graph_controller.graph(), + )?; + + let searcher_controller = controller::Searcher::new_from_graph_controller( + ide_controller, + &project_controller.model, + graph_controller, + mode, + parameters.cursor_position, + position_in_code, + )?; + + // Clear input on a new node. By default this will be set to whatever is used as the default + // content of the new node. + if let Mode::NewNode { source_node, .. } = mode { + if source_node.is_none() { + if let Err(e) = searcher_controller.set_input("".to_string(), text::Byte(0)) { + error!("Failed to clear input when creating searcher for a new node: {e:?}."); + } + } + } + + let input = parameters.input; + Ok(Self::new(searcher_controller, view, input)) + } + + fn expression_accepted( + self: Box, + node_id: NodeId, + entry_id: Option, + ) -> Option { + self.model.expression_accepted(node_id, entry_id) + } + + + fn abort_editing(self: Box) { + self.model.controller.abort_editing() + } + + fn input_view(&self) -> ViewNodeId { + self.model.input_view + } +} + +impl ComponentBrowserSearcher { + #[profile(Task)] + fn new( + controller: controller::Searcher, + view: view::project::View, + input_view: ViewNodeId, + ) -> Self { + let searcher_view = view.searcher().clone_ref(); + let model = Rc::new(Model::new(controller, view, input_view, searcher_view)); + let network = frp::Network::new("presenter::Searcher"); + + let graph = &model.project.graph().frp; + let browser = &model.view; + + frp::extend! { network + eval model.project.searcher_input_changed ([model]((expr, selections)) { + let cursor_position = selections.last().map(|sel| sel.end).unwrap_or_default(); + model.input_changed(expr, cursor_position); + }); + + action_list_changed <- any_mut::<()>(); + // When the searcher input is changed, we need to update immediately the list of + // entries in the component browser (as opposed to waiting for a `NewActionList` event + // which is delivered asynchronously). This is because the input may be accepted + // before the asynchronous event is delivered and to accept the correct entry the list + // must be up-to-date. + action_list_changed <+ model.project.searcher_input_changed.constant(()); + + eval_ model.project.toggle_component_browser_private_entries_visibility ( + model.controller.reload_list()); + } + + let grid = &browser.model().list.model().grid; + let navigator = &browser.model().list.model().section_navigator; + let breadcrumbs = &browser.model().list.model().breadcrumbs; + let documentation = &browser.model().documentation; + frp::extend! { network + eval_ action_list_changed ([model, grid, navigator] { + model.provider.take(); + let controller_provider = model.controller.provider(); + let namespace_section_count = controller_provider.namespace_section_count(); + navigator.set_namespace_section_count.emit(namespace_section_count); + let provider = provider::Component::provide_new_list(controller_provider, &grid); + *model.provider.borrow_mut() = Some(provider); + }); + grid.select_first_entry <+ action_list_changed.filter(f_!(model.should_select_first_entry())); + input_edit <- grid.suggestion_accepted.filter_map(f!((e) model.suggestion_accepted(*e))); + graph.edit_node_expression <+ input_edit; + + entry_selected <- grid.active.map(|&s| s?.as_entry_id()); + selected_entry_changed <- entry_selected.on_change().constant(()); + grid.unhover_element <+ any2( + &selected_entry_changed, + &model.project.toggle_component_browser_private_entries_visibility, + ); + hovered_not_selected <- all_with(&grid.hovered, &grid.active, |h, s| { + match (h, s) { + (Some(h), Some(s)) => h != s, + _ => false, + } + }); + documentation.frp.show_hovered_item_preview_caption <+ hovered_not_selected; + docs_params <- all3(&action_list_changed, &grid.active, &grid.hovered); + docs <- docs_params.filter_map(f!([model]((_, selected, hovered)) { + let entry = hovered.as_ref().or(selected.as_ref()); + entry.map(|entry| { + if let Some(group_id) = entry.as_header() { + model.documentation_of_group(group_id) + } else { + let entry_id = entry.as_entry_id().expect("GroupEntryId"); + model.documentation_of_component(entry_id) + } + }) + })); + documentation.frp.display_documentation <+ docs; + + eval_ grid.suggestion_accepted([]analytics::remote_log_event("component_browser::suggestion_accepted")); + eval entry_selected((entry) model.suggestion_selected(*entry)); + eval grid.module_entered((id) model.module_entered(*id)); + eval breadcrumbs.selected((id) model.breadcrumb_selected(*id)); + active_section <- grid.active_section.filter_map(|s| *s); + eval active_section((section) model.on_active_section_change(*section)); + } + + let weak_model = Rc::downgrade(&model); + let notifications = model.controller.subscribe(); + spawn_stream_handler(weak_model, notifications, move |notification, _| { + match notification { + Notification::NewActionList => action_list_changed.emit(()), + }; + std::future::ready(()) + }); + + Self { model, _network: network } + } +} diff --git a/app/gui/src/presenter/searcher/provider.rs b/app/gui/src/presenter/searcher/component_browser/provider.rs similarity index 99% rename from app/gui/src/presenter/searcher/provider.rs rename to app/gui/src/presenter/searcher/component_browser/provider.rs index d9d9ca12a6c..fa9826f5e15 100644 --- a/app/gui/src/presenter/searcher/provider.rs +++ b/app/gui/src/presenter/searcher/component_browser/provider.rs @@ -72,7 +72,7 @@ impl Action { use controller::searcher::action::Suggestion; match suggestion { Suggestion::FromDatabase(suggestion) => - presenter::searcher::doc_placeholder_for(suggestion), + presenter::searcher::component_browser::doc_placeholder_for(suggestion), Suggestion::Hardcoded(suggestion) => { format!( "

{}

No documentation available

", diff --git a/app/gui/view/graph-editor/src/lib.rs b/app/gui/view/graph-editor/src/lib.rs index 7ffd344ea57..dfdedceea7d 100644 --- a/app/gui/view/graph-editor/src/lib.rs +++ b/app/gui/view/graph-editor/src/lib.rs @@ -3022,7 +3022,6 @@ fn init_remaining_graph_editor_frp( ); out.node_added <+ new_node; node_to_edit_after_adding <- new_node.filter_map(|&(id,_,do_edit)| do_edit.as_some(id)); - eval node_to_edit_after_adding((id) model.with_node(*id, |node| node.show_preview())); let on_before_rendering = ensogl::animation::on_before_rendering(); node_to_pan <- new_node._0().debounce(); diff --git a/app/gui/view/graph-editor/src/shortcuts.rs b/app/gui/view/graph-editor/src/shortcuts.rs index 56f1957450b..fa34e9312b2 100644 --- a/app/gui/view/graph-editor/src/shortcuts.rs +++ b/app/gui/view/graph-editor/src/shortcuts.rs @@ -10,18 +10,6 @@ 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 & !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"), diff --git a/app/gui/view/src/project.rs b/app/gui/view/src/project.rs index 344eea3690b..2cf5778486c 100644 --- a/app/gui/view/src/project.rs +++ b/app/gui/view/src/project.rs @@ -55,6 +55,16 @@ const INPUT_CHANGE_DELAY_MS: i32 = 200; // === FRP === // =========== +/// The searcher that should be displayed. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum SearcherType { + /// The Searcher with Component Browser. + #[default] + ComponentBrowser, + /// The Searcher with AI completion. + AiCompletion, +} + /// The parameters of the displayed searcher. #[derive(Clone, Copy, Debug, Default)] pub struct SearcherParams { @@ -65,15 +75,25 @@ pub struct SearcherParams { pub source_node: Option, /// A position of the cursor in the input node. pub cursor_position: text::Byte, + /// The type of the searcher. + pub searcher_type: SearcherType, } impl SearcherParams { - fn new_for_new_node(node_id: NodeId, source_node: Option) -> Self { - Self { input: node_id, source_node, cursor_position: default() } + fn new_for_new_node( + node_id: NodeId, + source_node: Option, + searcher_type: SearcherType, + ) -> Self { + Self { input: node_id, source_node, cursor_position: default(), searcher_type } } - fn new_for_edited_node(node_id: NodeId, cursor_position: text::Byte) -> Self { - Self { input: node_id, source_node: None, cursor_position } + fn new_for_edited_node( + node_id: NodeId, + cursor_position: text::Byte, + searcher_type: SearcherType, + ) -> Self { + Self { input: node_id, source_node: None, cursor_position, searcher_type } } } @@ -111,9 +131,17 @@ ensogl::define_endpoints! { execution_context_reload_and_restart(), toggle_read_only(), set_read_only(bool), + /// Started creation of a new node using the AI searcher. + start_node_creation_with_ai_searcher(), + /// Started creation of a new node using the Component Browser. + start_node_creation_with_component_browser(), + /// Accepts the currently selected input of the searcher. + accept_searcher_input(), } Output { + /// The type of the searcher currently in use. + searcher_type (SearcherType), searcher (Option), /// The searcher input has changed and the Component Browser content should be refreshed. /// Is **not** emitted with every graph's node expression change, only when @@ -124,6 +152,9 @@ ensogl::define_endpoints! { adding_new_node (bool), old_expression_of_edited_node (Expression), editing_aborted (NodeId), + // TODO[MM]: this should not contain the group entry id as that is component browser + // specific. It should be refactored to be an implementation detail of the component + // browser. editing_committed (NodeId, Option), project_list_shown (bool), code_editor_shown (bool), @@ -343,6 +374,7 @@ impl View { model.set_style(theme); Self { model, frp } + .init_start_node_edit_frp() .init_top_bar_frp(scene) .init_graph_editor_frp() .init_code_editor_frp() @@ -474,7 +506,8 @@ impl View { eval position ((pos) model.searcher.set_xy(*pos)); // Showing searcher. - searcher.show <+ frp.searcher.is_some().on_true().constant(()); + searcher.show <+ frp.searcher.unwrap().map(|params| + matches!(params.searcher_type, SearcherType::ComponentBrowser)).on_true(); searcher.hide <+ frp.searcher.is_none().on_true().constant(()); eval searcher.is_visible ([model](is_visible) { let is_attached = model.searcher.has_parent(); @@ -495,8 +528,8 @@ impl View { frp::extend! { network node_added_by_user <- graph.node_added.filter(|(_, _, should_edit)| *should_edit); - searcher_for_adding <- node_added_by_user.map( - |&(node, src, _)| SearcherParams::new_for_new_node(node, src) + searcher_for_adding <- node_added_by_user.map2(&frp.searcher_type, + |&(node, src, _), searcher_type| SearcherParams::new_for_new_node(node, src, *searcher_type) ); frp.source.adding_new_node <+ searcher_for_adding.to_true(); new_node_edited <- graph.node_editing_started.gate(&frp.adding_new_node); @@ -504,9 +537,10 @@ impl View { edit_which_opens_searcher <- graph.node_expression_edited.gate_not(&frp.is_searcher_opened).debounce(); - frp.source.searcher <+ edit_which_opens_searcher.map(|(node_id, _, selections)| { + frp.source.searcher <+ edit_which_opens_searcher.map2(&frp.searcher_type, + |(node_id, _, selections), searcher_type| { let cursor_position = selections.last().map(|sel| sel.end).unwrap_or_default(); - Some(SearcherParams::new_for_edited_node(*node_id, cursor_position)) + Some(SearcherParams::new_for_edited_node(*node_id, cursor_position, *searcher_type)) }); frp.source.is_searcher_opened <+ frp.searcher.map(|s| s.is_some()); } @@ -524,13 +558,19 @@ impl View { frp::extend! { network last_searcher <- frp.searcher.filter_map(|&s| s); - // The searcher will be closed due to accepting the input (e.g., pressing enter). - committed_in_searcher <- - grid.expression_accepted.map2(&last_searcher, |&entry, &s| (s.input, entry)); - // === Handling Inputs to the Searcher and Committing Edit === + ai_searcher_active <- frp.searcher_type.map(|t| *t == SearcherType::AiCompletion); + // Note: the "enter" event for the CB searcher is handled in its own view. + committed_in_ai_searcher <- frp.accept_searcher_input.gate(&ai_searcher_active); + committed_in_ai_searcher <- committed_in_ai_searcher.map2(&last_searcher, |_, &s| (s.input, None)); + + // The searcher will be closed due to accepting the input (e.g., pressing enter). + committed_in_cb_searcher <- + grid.expression_accepted.map2(&last_searcher, |&entry, &s| (s.input, entry)); + + committed_in_searcher <- any(committed_in_ai_searcher, committed_in_cb_searcher); searcher_input_change_opt <- graph.node_expression_edited.map2(&frp.searcher, |(node_id, expr, selections), searcher| { let input_change = || (*node_id, expr.clone_ref(), selections.clone()); @@ -604,6 +644,7 @@ impl View { ); graph.stop_editing <+ any(&committed_in_searcher_event, &aborted_in_searcher_event); frp.source.searcher <+ searcher_should_close.constant(None); + frp.source.searcher_type <+ searcher_should_close.constant(SearcherType::default()); frp.source.adding_new_node <+ searcher_should_close.constant(false); } self @@ -683,6 +724,26 @@ impl View { self } + fn init_start_node_edit_frp(self) -> Self { + let frp = &self.frp; + let network = &frp.network; + let graph_editor = &self.model.graph_editor.frp; + frp::extend! { network + // Searcher type to use for node creation + ai_searcher <- frp.start_node_creation_with_ai_searcher.constant(SearcherType::AiCompletion); + component_browser_searcher <- frp.start_node_creation_with_component_browser.constant(SearcherType::ComponentBrowser); + searcher_type <- any(&ai_searcher, &component_browser_searcher); + + frp.source.searcher_type <+ searcher_type; + + should_not_create_node <- graph_editor.node_editing || graph_editor.read_only; + should_not_create_node <- should_not_create_node || graph_editor.is_fs_visualization_displayed; + start_node_creation <- searcher_type.gate_not(&should_not_create_node); + graph_editor.start_node_creation <+ start_node_creation.constant(()); + } + self + } + fn init_shortcut_observer(self, app: &Application) -> Self { let frp = &self.frp; frp::extend! { network @@ -764,8 +825,9 @@ impl application::View for View { // TODO(#7178): Remove this temporary shortcut when the modified-on-disk notification // is ready. (Press, "", "cmd alt y", "execution_context_reload_and_restart"), - // TODO(#6179): Remove this temporary shortcut when Play button is ready. - (Press, "", "ctrl shift b", "toggle_read_only"), + (Press, "!is_searcher_opened", "cmd tab", "start_node_creation_with_ai_searcher"), + (Press, "!is_searcher_opened", "tab", "start_node_creation_with_component_browser"), + (Press, "is_searcher_opened", "enter", "accept_searcher_input"), ] .iter() .map(|(a, b, c, d)| Self::self_shortcut_when(*a, *c, *d, *b)) diff --git a/app/gui/view/src/root.rs b/app/gui/view/src/root.rs index d7fdfcb1f53..6fbf0d221db 100644 --- a/app/gui/view/src/root.rs +++ b/app/gui/view/src/root.rs @@ -44,7 +44,7 @@ pub struct Model { } impl Model { - /// Constuctor. + /// Constructor. pub fn new(app: &Application, frp: &Frp) -> Self { let app = app.clone_ref(); let display_object = display::object::Instance::new(); diff --git a/build-config.yaml b/build-config.yaml index 95eb717c213..a23726a6c0a 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.97 MiB +wasm-size-limit: 16.06 MiB required-versions: # NB. The Rust version is pinned in rust-toolchain.toml.