Virtual Entries in Component Browser (#3621)

Add "text input" and "number input" virtual entries in the "Input" virtual component group in the Component Browser. The entries provide an easy way to put strings and numbers into a graph.

https://www.pivotaltracker.com/story/show/181870589

#### Visuals

See below for a video showing the "text input" and "number input" virtual entries in the "Input" component group in the "Favorites Data Science Tools" section of the Component Browser. Please note that the video also displays a few known issues that are present in the existing code and not introduced by this PR:

- "Opening the Component Browser 2nd or later time flashes its last contents from the previous time" - reported as [issue 15 in PR 3530](https://github.com/enso-org/enso/pull/3530#pullrequestreview-1035698205) (which is [expected](https://github.com/enso-org/enso/pull/3530#issuecomment-1187676313) to be fixed by https://www.pivotaltracker.com/story/show/182610422).
- The text of all the entries in the Component Browser does not show immediately, but the entries appear one by one instead (this is related to the performance of the current implementation of Component Browser and Text Area).
- Selection in the Component Browser can show half-way between entries - reported as https://www.pivotaltracker.com/story/show/182713338.

https://user-images.githubusercontent.com/273837/183472391-c14eeded-481f-492e-a1b8-b86f42faf0cd.mov

# Important Notes
- The virtual entries are not filtered by input type or return type. The filtering is expected to be implemented in https://www.pivotaltracker.com/story/show/182842444.
This commit is contained in:
Mateusz Czapliński 2022-08-17 15:28:07 +02:00 committed by GitHub
parent 68f9fce21a
commit 3c12a8c0a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 469 additions and 78 deletions

2
Cargo.lock generated
View File

@ -1659,6 +1659,7 @@ name = "double-representation"
version = "0.1.0"
dependencies = [
"ast",
"const_format",
"engine-protocol",
"enso-data-structures",
"enso-logger",
@ -1960,6 +1961,7 @@ dependencies = [
"ast",
"bimap",
"console_error_panic_hook",
"const_format",
"convert_case 0.5.0",
"double-representation",
"engine-protocol",

View File

@ -38,6 +38,7 @@ parser = { path = "language/parser" }
span-tree = { path = "language/span-tree" }
bimap = { version = "0.4.0" }
console_error_panic_hook = { version = "0.1.6" }
const_format = { version = "0.2.22" }
convert_case = { version = "0.5.0" }
failure = { version = "0.1.6" }
flo_stream = { version = "0.4.0" }

View File

@ -16,6 +16,7 @@ enso-logger = { path = "../../../../lib/rust/logger" }
enso-prelude = { path = "../../../../lib/rust/prelude" }
enso-profiler = { path = "../../../../lib/rust/profiler" }
enso-text = { path = "../../../../lib/rust/text" }
const_format = { version = "0.2.22" }
failure = { version = "0.1.6" }
itertools = { version = "0.10.0" }
serde = { version = "1.0", features = ["derive"] }

View File

@ -4,11 +4,27 @@ use crate::prelude::*;
use crate::identifier::ReferentName;
use const_format::concatcp;
use serde::Deserialize;
use serde::Serialize;
// =================
// === Constants ===
// =================
/// The namespace of the standard library.
pub const STANDARD_NAMESPACE: &str = "Standard";
/// The name of the project in the [`STANDARD_NAMESPACE`] containing the base standard library.
pub const BASE_LIBRARY_NAME: &str = "Base";
/// The full path of the [`BASE_LIBRARY_NAME`] project in the [`STANDARD_NAMESPACE`].
pub const STANDARD_BASE_LIBRARY_PATH: &str = concatcp!(STANDARD_NAMESPACE, ".", BASE_LIBRARY_NAME);
// ==============
// === Errors ===
// ==============
@ -76,6 +92,12 @@ impl QualifiedName {
}
}
/// Return the fully qualified name of the [`BASE_LIBRARY_NAME`] project in the
/// [`STANDARD_NAMESPACE`].
pub fn standard_base_library() -> Self {
Self::from_segments(STANDARD_NAMESPACE, BASE_LIBRARY_NAME).unwrap()
}
/// The iterator over name's segments: the namespace and project name.
pub fn segments(&self) -> impl Iterator<Item = &str> {
std::iter::once(self.namespace.as_ref()).chain(std::iter::once(self.project.as_ref()))
@ -152,4 +174,9 @@ mod test {
invalid_case("namespace.");
invalid_case(".");
}
#[test]
fn qualified_name_of_standard_base_library_does_not_panic() {
let _ = QualifiedName::standard_base_library();
}
}

View File

@ -13,6 +13,7 @@ use crate::model::suggestion_database;
use crate::model::suggestion_database::entry::CodeToInsert;
use crate::notification;
use const_format::concatcp;
use double_representation::graph::GraphInfo;
use double_representation::graph::LocationHint;
use double_representation::module::QualifiedName;
@ -47,7 +48,9 @@ pub const ASSIGN_NAMES_FOR_NODES: bool = true;
/// The special module used for mock `Enso_Project.data` entry.
/// See also [`Searcher::add_enso_project_entries`].
const ENSO_PROJECT_SPECIAL_MODULE: &str = "Standard.Base.Enso_Project";
const ENSO_PROJECT_SPECIAL_MODULE: &str =
concatcp!(project::STANDARD_BASE_LIBRARY_PATH, ".Enso_Project");
// ==============
@ -1191,6 +1194,10 @@ fn component_list_builder_with_favorites<'a>(
builder = builder.with_local_scope_module_id(id);
}
builder.set_grouping_and_order_of_favorites(suggestion_db, groups);
let base_lib_qn = project::QualifiedName::standard_base_library();
let input_group_name = component::hardcoded::INPUT_GROUP_NAME;
let snippets = component::hardcoded::INPUT_SNIPPETS.with(|s| s.clone());
builder.insert_virtual_components_in_favorites_group(input_group_name, base_lib_qn, snippets);
builder
}
@ -1845,7 +1852,7 @@ pub mod test {
// Prepare a sample component group to be returned by a mock Language Server client.
let module_qualified_name = crate::test::mock::data::module_qualified_name().to_string();
let sample_ls_component_group = language_server::LibraryComponentGroup {
library: "".to_string(),
library: project::QualifiedName::standard_base_library().to_string(),
name: "Test Group 1".to_string(),
color: None,
icon: None,
@ -1878,17 +1885,19 @@ pub mod test {
format!("{}.{}", entry1.module.project_name.project, entry1.module.name());
assert_eq!(module_group.name, expected_group_name);
let entries = module_group.entries.borrow();
assert_matches!(entries.as_slice(), [e1, e2] if e1.suggestion.name == entry1.name && e2.suggestion.name == entry9.name);
assert_matches!(entries.as_slice(), [e1, e2] if e1.name() == entry1.name && e2.name() == entry9.name);
} else {
ipanic!("Wrong top modules in Component List: {components.top_modules():?}");
}
let favorites = &components.favorites;
assert_eq!(favorites.len(), 1);
let favorites_group = &favorites[0];
assert_eq!(favorites_group.name, "Test Group 1");
let favorites_entries = favorites_group.entries.borrow();
assert_eq!(favorites.len(), 2);
let favorites_group_0 = &favorites[0];
assert_eq!(favorites_group_0.name, component::hardcoded::INPUT_GROUP_NAME);
let favorites_group_1 = &favorites[1];
assert_eq!(favorites_group_1.name, "Test Group 1");
let favorites_entries = favorites_group_1.entries.borrow();
assert_eq!(favorites_entries.len(), 1);
assert_eq!(*favorites_entries[0].id, 1);
assert_eq!(favorites_entries[0].id().unwrap(), 1);
}
#[wasm_bindgen_test]

View File

@ -57,10 +57,11 @@ impl Suggestion {
/// Return the documentation assigned to the suggestion.
pub fn documentation_html(&self) -> Option<&str> {
match self {
Suggestion::FromDatabase(s) => s.documentation_html.as_ref().map(AsRef::<str>::as_ref),
Suggestion::Hardcoded(s) => s.documentation_html,
}
let doc_html = match self {
Suggestion::FromDatabase(s) => &s.documentation_html,
Suggestion::Hardcoded(s) => &s.documentation_html,
};
doc_html.as_ref().map(AsRef::<str>::as_ref)
}
/// The Id of the method called by a suggestion, or [`None`] if the suggestion is not a method

View File

@ -102,7 +102,7 @@ pub struct Suggestion {
/// An import required by the suggestion.
pub imports: Vec<module::QualifiedName>,
/// The documentation bound to the suggestion.
pub documentation_html: Option<&'static str>,
pub documentation_html: Option<String>,
/// The id of the method called by the suggestion.
pub method_id: Option<MethodId>,
/// The name of the icon bound to this entry.
@ -110,7 +110,8 @@ pub struct Suggestion {
}
impl Suggestion {
fn new(name: &'static str, code: &'static str, icon: &ImString) -> Self {
/// Construct a hardcoded suggestion with given name, code, and icon.
pub(crate) fn new(name: &'static str, code: &'static str, icon: &ImString) -> Self {
let icon = icon.clone_ref();
Self { name, code, icon, ..default() }
}
@ -130,7 +131,10 @@ impl Suggestion {
self
}
fn with_return_type(
/// Returns a modified suggestion with [`Suggestion::return_type`] field set. This method is
/// only intended to be used when defining hardcoded suggestions and panics if the argument
/// fails to convert to a valid type name.
pub(crate) fn with_return_type(
mut self,
return_type: impl TryInto<tp::QualifiedName, Error: Debug>,
) -> Self {
@ -146,6 +150,18 @@ impl Suggestion {
self
}
/// Returns a modified suggestion with [`Suggestion::documentation_html`] field set. This
/// method is only intended to be used when defining hardcoded suggestions and panics if a
/// documentation parser cannot be created or the argument fails to parse as valid
/// documentation.
pub(crate) fn with_documentation(mut self, documentation: &str) -> Self {
let doc_parser = parser::DocParser::new().unwrap();
let doc_string = documentation.to_string();
let documentation_html = doc_parser.generate_html_doc_pure(doc_string);
self.documentation_html = Some(documentation_html.unwrap());
self
}
fn marked_as_method_call(
mut self,
name: &'static str,

View File

@ -16,6 +16,7 @@ use convert_case::Casing;
pub mod builder;
pub mod group;
pub mod hardcoded;
pub use group::Group;
@ -54,6 +55,34 @@ pub enum Order {
// ============
// === Data ===
// ============
/// Contains detailed data of a [`Component`]. The storage of the details differs depending on
/// where the data originates from (either from the [`suggestion_database`] or from a
/// [`hardcoded::Snippet`]).
#[derive(Clone, CloneRef, Debug)]
pub enum Data {
/// A component from the [`suggestion_database`]. When this component is picked in the
/// Component Browser, the code returned by [`suggestion_database::Entry::code_to_insert`] will
/// be inserted into the program.
FromDatabase {
/// The ID of the component in the [`suggestion_database`].
id: Immutable<Id>,
/// The component's entry in the [`suggestion_database`].
entry: Rc<suggestion_database::Entry>,
},
/// A virtual component containing a hardcoded snippet of code. When this component is picked
/// in the Component Browser, the [`Snippet::code`] will be inserted into the program.
Virtual {
/// A hardcoded snippet of code.
snippet: Rc<hardcoded::Snippet>,
},
}
// =================
// === Component ===
// =================
@ -64,22 +93,22 @@ pub enum Order {
/// The components are usually stored in [`List`], which may be filtered; the single component keeps
/// then information how it matches the current filtering pattern.
///
/// The component corresponds to some Suggestion Database Entry, and the entry will be used to
/// properly insert code into the program.
/// See the documentation of the [`Data`] variants for information on what will happen when the
/// component is picked in the Component Browser panel.
#[allow(missing_docs)]
#[derive(Clone, CloneRef, Debug)]
pub struct Component {
pub id: Immutable<Id>,
pub suggestion: Rc<suggestion_database::Entry>,
pub data: Data,
pub match_info: Rc<RefCell<MatchInfo>>,
}
impl Component {
/// Construct a new component.
/// Construct a new component from a [`suggestion_database`] entry.
///
/// The matching info will be filled for an empty pattern.
pub fn new(id: Id, suggestion: Rc<suggestion_database::Entry>) -> Self {
Self { id: Immutable(id), suggestion, match_info: default() }
pub fn new_from_database_entry(id: Id, entry: Rc<suggestion_database::Entry>) -> Self {
let data = Data::FromDatabase { id: Immutable(id), entry };
Self { data, match_info: default() }
}
/// The label which should be displayed in the Component Browser.
@ -87,6 +116,22 @@ impl Component {
self.to_string()
}
/// The name of the component.
pub fn name(&self) -> &str {
match &self.data {
Data::FromDatabase { entry, .. } => entry.name.as_str(),
Data::Virtual { snippet } => snippet.name,
}
}
/// The [`Id`] of the component in the [`suggestion_database`], or `None` if not applicable.
pub fn id(&self) -> Option<Id> {
match self.data {
Data::FromDatabase { id, .. } => Some(*id),
Data::Virtual { .. } => None,
}
}
/// Checks if component is filtered out.
pub fn is_filtered_out(&self) -> bool {
matches!(*self.match_info.borrow(), MatchInfo::DoesNotMatch)
@ -97,7 +142,8 @@ impl Component {
/// Currently, only modules can be entered, and then the Browser should display content and
/// submodules of the entered module.
pub fn can_be_entered(&self) -> bool {
self.suggestion.kind == suggestion_database::entry::Kind::Module
use suggestion_database::entry::Kind as EntryKind;
matches!(&self.data, Data::FromDatabase { entry, .. } if entry.kind == EntryKind::Module)
}
/// Update matching info.
@ -117,16 +163,27 @@ impl Component {
}
}
impl From<Rc<hardcoded::Snippet>> for Component {
fn from(snippet: Rc<hardcoded::Snippet>) -> Self {
Self { data: Data::Virtual { snippet }, match_info: default() }
}
}
impl Display for Component {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let self_type_not_here =
self.suggestion.self_type.as_ref().filter(|t| *t != &self.suggestion.module);
match &self.data {
Data::FromDatabase { entry, .. } => {
let entry_name = entry.name.from_case(Case::Snake).to_case(Case::Lower);
let self_type_ref = entry.self_type.as_ref();
let self_type_not_here = self_type_ref.filter(|t| *t != &entry.module);
if let Some(self_type) = self_type_not_here {
let self_name = self_type.name.from_case(Case::Snake).to_case(Case::Title);
let name = self.suggestion.name.from_case(Case::Snake).to_case(Case::Lower);
write!(f, "{} {}", self_name, name)
write!(f, "{} {}", self_name, entry_name)
} else {
write!(f, "{}", self.suggestion.name.from_case(Case::Snake).to_case(Case::Lower))
write!(f, "{}", entry_name)
}
}
Data::Virtual { snippet } => write!(f, "{}", snippet.name),
}
}
}
@ -240,6 +297,7 @@ pub(crate) mod tests {
use crate::model::suggestion_database::entry::Kind;
use double_representation::module;
use double_representation::project;
use engine_protocol::language_server;
@ -312,6 +370,7 @@ pub(crate) mod tests {
) -> Vec<crate::model::execution_context::ComponentGroup> {
let db_entries = component_ids.iter().map(|id| db.lookup(*id).unwrap());
let group = crate::model::execution_context::ComponentGroup {
project: project::QualifiedName::standard_base_library(),
name: "Test Group 1".into(),
color: None,
components: db_entries.into_iter().map(|e| e.qualified_name()).collect(),
@ -331,7 +390,7 @@ pub(crate) mod tests {
.borrow()
.iter()
.take_while(|c| matches!(*c.match_info.borrow(), MatchInfo::Matches { .. }))
.map(|c| *c.id)
.map(|c| c.id().unwrap())
.collect_vec();
assert_eq!(ids_of_matches, expected_ids);
}
@ -408,7 +467,7 @@ pub(crate) mod tests {
// ("test.Test.TopModule1.SubModule2").
let content = list.get_module_content(3).unwrap();
let expected_content_ids = vec![9, 4];
let content_ids = content.entries.borrow().iter().map(|entry| *entry.id).collect_vec();
let content_ids = content.entries.borrow().iter().map(|e| e.id().unwrap()).collect_vec();
assert_eq!(content_ids, expected_content_ids);
let direct_submodules = list.submodules_of(3).unwrap();
let expected_direct_submodules_ids = vec![Some(4)];
@ -419,7 +478,7 @@ pub(crate) mod tests {
// ("test.Test.TopModule1.SubModule1.SubSubModule").
let content = list.get_module_content(4).unwrap();
let expected_content_ids = vec![10];
let content_ids = content.entries.borrow().iter().map(|entry| *entry.id).collect_vec();
let content_ids = content.entries.borrow().iter().map(|e| e.id().unwrap()).collect_vec();
assert_eq!(content_ids, expected_content_ids);
}
}

View File

@ -9,9 +9,10 @@
//!
//! When using the methods of the [`List`] type to build a [`component::List`]:
//! - The components and groups are sorted once.
//! - The [`component::List::favorites`] contain only components with IDs that were passed both to
//! [`List::set_grouping_and_order_of_favorites`] and to
//! [`List::extend_list_and_allow_favorites_with_ids`].
//! - The [`component::List::favorites`] will contain:
//! - components with IDs that were passed both to [`List::set_grouping_and_order_of_favorites`]
//! and to [`List::extend_list_and_allow_favorites_with_ids`],
//! - virtual components inserted with [`List::insert_virtual_components_in_favorites_group`].
//! - Empty component groups are allowed in favorites. (This simplifies distributing groups of
//! favorites over columns in [Component Browser](crate::controller::Searcher) consistently.
//! That's because for the same input to [`List::set_grouping_and_order_of_favorites`], the same
@ -25,6 +26,7 @@ use crate::model::execution_context;
use crate::model::suggestion_database;
use double_representation::module;
use double_representation::project;
@ -99,9 +101,10 @@ impl List {
/// returned object, components passed to the method which have their parent module ID equal
/// to `module_id` will be cloned into [`component::List::local_scope`].
pub fn with_local_scope_module_id(self, module_id: component::Id) -> Self {
use crate::controller::searcher::component::Group;
const LOCAL_SCOPE_GROUP_NAME: &str = "Local Scope";
let id = Some(module_id);
let local_scope = component::Group::from_name_and_id(LOCAL_SCOPE_GROUP_NAME, id);
let local_scope = Group::from_name_and_project_and_id(LOCAL_SCOPE_GROUP_NAME, None, id);
Self { local_scope, ..self }
}
@ -111,22 +114,23 @@ impl List {
pub fn extend_list_and_allow_favorites_with_ids(
&mut self,
db: &model::SuggestionDatabase,
entries: impl IntoIterator<Item = component::Id>,
entry_ids: impl IntoIterator<Item = component::Id>,
) {
use suggestion_database::entry::Kind;
let local_scope_id = self.local_scope.component_id;
let lookup_component_by_id = |id| Some(Component::new(id, db.lookup(id).ok()?));
let components = entries.into_iter().filter_map(lookup_component_by_id);
for component in components {
self.allowed_favorites.insert(*component.id);
let id_and_looked_up_entry = |id| Some((id, db.lookup(id).ok()?));
let ids_and_entries = entry_ids.into_iter().filter_map(id_and_looked_up_entry);
for (id, entry) in ids_and_entries {
self.allowed_favorites.insert(id);
let component = Component::new_from_database_entry(id, entry.clone_ref());
let mut component_inserted_somewhere = false;
if let Some(parent_module) = component.suggestion.parent_module() {
if let Some(parent_module) = entry.parent_module() {
if let Some(parent_group) = self.lookup_module_group(db, &parent_module) {
parent_group.content.entries.borrow_mut().push(component.clone_ref());
component_inserted_somewhere = true;
let parent_id = parent_group.content.component_id;
let in_local_scope = parent_id == local_scope_id && local_scope_id.is_some();
let not_module = component.suggestion.kind != Kind::Module;
let not_module = entry.kind != Kind::Module;
if in_local_scope && not_module {
self.local_scope.entries.borrow_mut().push(component.clone_ref());
}
@ -157,6 +161,33 @@ impl List {
.collect();
}
fn take_grouping_and_order_of_favorites_as_vec(&mut self) -> Vec<component::Group> {
std::mem::take(&mut self.grouping_and_order_of_favorites).into_iter().collect_vec()
}
/// Insert virtual components at the beginning of a favorites group with given name defined in
/// given project. If a group with that name and project does not exist, it is created. The
/// virtual components are created from the given snippets.
pub fn insert_virtual_components_in_favorites_group(
&mut self,
group_name: &str,
project: project::QualifiedName,
snippets: impl IntoIterator<Item = Rc<component::hardcoded::Snippet>>,
) {
use component::Group;
let mut favorites_grouping = self.take_grouping_and_order_of_favorites_as_vec();
let name_and_project_match =
|g: &&mut Group| g.name == group_name && g.project.as_ref() == Some(&project);
let group_with_matching_name = favorites_grouping.iter_mut().find(name_and_project_match);
if let Some(group) = group_with_matching_name {
group.insert_entries(&snippets.into_iter().map(Into::into).collect_vec());
} else {
let group = Group::from_name_and_project_and_snippets(group_name, project, snippets);
favorites_grouping.insert(0, group);
}
self.grouping_and_order_of_favorites = component::group::List::new(favorites_grouping);
}
fn lookup_module_group(
&mut self,
db: &model::SuggestionDatabase,
@ -212,10 +243,12 @@ impl List {
}
fn build_favorites_and_add_to_all_components(&mut self) -> component::group::List {
let grouping_and_order = std::mem::take(&mut self.grouping_and_order_of_favorites);
let mut favorites_groups = grouping_and_order.into_iter().collect_vec();
let mut favorites_groups = self.take_grouping_and_order_of_favorites_as_vec();
for group in favorites_groups.iter_mut() {
group.retain_entries(|e| self.allowed_favorites.contains(&e.id));
group.retain_entries(|e| match e.data {
component::Data::FromDatabase { id, .. } => self.allowed_favorites.contains(&id),
component::Data::Virtual { .. } => true,
});
self.all_components.extend(group.entries.borrow().iter().cloned());
}
component::group::List::new(favorites_groups)
@ -234,6 +267,8 @@ mod tests {
use crate::controller::searcher::component::tests::mock_suggestion_db;
use double_representation::project;
#[derive(Clone, Debug, Eq, PartialEq)]
struct ComparableGroupData<'a> {
@ -247,7 +282,7 @@ mod tests {
Self {
name: component.name.as_str(),
component_id: component.component_id,
entries: component.entries.borrow().iter().map(|e| *e.id).collect(),
entries: component.entries.borrow().iter().map(|e| e.id().unwrap()).collect(),
}
}
}
@ -350,7 +385,8 @@ mod tests {
assert_eq!(module_subgroups, expected);
let local_scope_entries = &list.local_scope.entries;
let local_scope_ids = local_scope_entries.borrow().iter().map(|e| *e.id).collect_vec();
let component_id = |c: &Component| c.id().unwrap();
let local_scope_ids = local_scope_entries.borrow().iter().map(component_id).collect_vec();
let expected_ids = vec![5, 6];
assert_eq!(local_scope_ids, expected_ids);
}
@ -369,6 +405,7 @@ mod tests {
assert_eq!(db.lookup_by_qualified_name_str(QN_NOT_IN_DB), None);
let groups = [
execution_context::ComponentGroup {
project: project::QualifiedName::standard_base_library(),
name: "Group 1".into(),
color: None,
components: vec![
@ -382,6 +419,7 @@ mod tests {
],
},
execution_context::ComponentGroup {
project: project::QualifiedName::standard_base_library(),
name: "Group 2".into(),
color: None,
components: vec![
@ -410,4 +448,71 @@ mod tests {
];
assert_eq!(favorites, expected);
}
fn check_names_and_order_of_group_entries(group: &component::Group, expected_names: &[&str]) {
let entries = group.entries.borrow();
let entry_names = entries.iter().map(|c| c.name()).collect_vec();
assert_eq!(&entry_names, expected_names);
}
/// Test building a component list with a virtual component. The virtual component will be
/// inserted into an existing favorites group.
#[test]
fn building_component_list_with_virtual_component_in_existing_favorites_group() {
let logger = Logger::new("tests::virtual_component_in_existing_favorites_group");
let db = mock_suggestion_db(logger);
let mut builder = List::new();
let qn_of_db_entry_0 = db.lookup(0).unwrap().qualified_name();
let project = project::QualifiedName::standard_base_library();
const GROUP_NAME: &str = "Group";
let groups = [execution_context::ComponentGroup {
project: project.clone(),
name: GROUP_NAME.into(),
color: None,
components: vec![qn_of_db_entry_0],
}];
builder.set_grouping_and_order_of_favorites(&db, &groups);
let snippet = component::hardcoded::Snippet { name: "test snippet", ..default() };
let snippet_iter = std::iter::once(Rc::new(snippet));
builder.insert_virtual_components_in_favorites_group(GROUP_NAME, project, snippet_iter);
builder.extend_list_and_allow_favorites_with_ids(&db, std::iter::once(0));
let list = builder.build();
let favorites = list.favorites;
assert_eq!(favorites.len(), 1, "Expected one group of favorites, got: {:?}.", favorites);
let expected_entry_names = ["test snippet", "TopModule1"];
check_names_and_order_of_group_entries(&favorites[0], &expected_entry_names);
}
/// Test building a component list with a virtual component. The virtual component will be
/// inserted into a new favorites group.
#[test]
fn building_component_list_with_virtual_component_in_new_favorites_group() {
let logger = Logger::new("tests::virtual_component_in_new_favorites_group");
let db = mock_suggestion_db(logger);
let mut builder = List::new();
let qn_of_db_entry_0 = db.lookup(0).unwrap().qualified_name();
let project = project::QualifiedName::standard_base_library();
const GROUP_1_NAME: &str = "Group 1";
let groups = [execution_context::ComponentGroup {
project: project.clone(),
name: GROUP_1_NAME.into(),
color: None,
components: vec![qn_of_db_entry_0],
}];
builder.set_grouping_and_order_of_favorites(&db, &groups);
let snippet = component::hardcoded::Snippet { name: "test snippet", ..default() };
let snippet_iter = std::iter::once(Rc::new(snippet));
const GROUP_2_NAME: &str = "Group 2";
builder.insert_virtual_components_in_favorites_group(GROUP_2_NAME, project, snippet_iter);
builder.extend_list_and_allow_favorites_with_ids(&db, std::iter::once(0));
let list = builder.build();
let favorites = list.favorites;
assert_eq!(favorites.len(), 2, "Expected two groups of favorites, got: {:?}.", favorites);
let group_at_0 = &favorites[0];
assert_eq!(group_at_0.name, "Group 2");
check_names_and_order_of_group_entries(group_at_0, &["test snippet"]);
let group_at_1 = &favorites[1];
assert_eq!(group_at_1.name, "Group 1");
check_names_and_order_of_group_entries(group_at_1, &["TopModule1"]);
}
}

View File

@ -9,6 +9,7 @@ use crate::controller::searcher::component::MatchInfo;
use crate::model::execution_context;
use crate::model::suggestion_database;
use double_representation::project;
use ensogl::data::color;
use std::cmp;
@ -22,6 +23,7 @@ use std::cmp;
#[allow(missing_docs)]
#[derive(Clone, Debug, Default)]
pub struct Data {
pub project: Option<project::QualifiedName>,
pub name: ImString,
pub color: Option<color::Rgb>,
/// A component corresponding to this group, e.g. the module of whose content the group
@ -36,8 +38,13 @@ pub struct Data {
}
impl Data {
fn from_name_and_id(name: impl Into<ImString>, component_id: Option<component::Id>) -> Self {
fn from_name_and_project_and_id(
name: impl Into<ImString>,
project: Option<project::QualifiedName>,
component_id: Option<component::Id>,
) -> Self {
Data {
project,
name: name.into(),
color: None,
component_id,
@ -74,12 +81,14 @@ impl Deref for Group {
}
impl Group {
/// Create a named empty group referring to module with specified component ID.
pub fn from_name_and_id(
/// Create a named empty group referring to module with specified component ID and in the given
/// project.
pub fn from_name_and_project_and_id(
name: impl Into<ImString>,
project: Option<project::QualifiedName>,
component_id: Option<component::Id>,
) -> Self {
Self { data: Rc::new(Data::from_name_and_id(name, component_id)) }
Self { data: Rc::new(Data::from_name_and_project_and_id(name, project, component_id)) }
}
/// Create empty group referring to some module component.
@ -91,7 +100,28 @@ impl Group {
} else {
entry.module.name().into()
};
Self::from_name_and_id(name, Some(component_id))
let project_name = entry.module.project_name.clone();
Self::from_name_and_project_and_id(name, Some(project_name), Some(component_id))
}
/// Create a group with given name in given project and containing virtual components created
/// from given snippets.
pub fn from_name_and_project_and_snippets(
name: impl Into<ImString>,
project: project::QualifiedName,
snippets: impl IntoIterator<Item = Rc<component::hardcoded::Snippet>>,
) -> Self {
let entries = snippets.into_iter().map(Into::into).collect_vec();
let group_data = Data {
project: Some(project),
name: name.into(),
color: None,
component_id: None,
matched_items: Cell::new(entries.len()),
initial_entries_order: entries.clone(),
entries: RefCell::new(entries),
};
Group { data: Rc::new(group_data) }
}
/// Construct from [`execution_context::ComponentGroup`] components looked up in the suggestion
@ -104,13 +134,14 @@ impl Group {
) -> Option<Self> {
let lookup_component = |qualified_name| {
let (id, suggestion) = suggestion_db.lookup_by_qualified_name(qualified_name)?;
Some(Component::new(id, suggestion))
Some(Component::new_from_database_entry(id, suggestion))
};
let components = &group.components;
let looked_up_components = components.iter().filter_map(lookup_component).collect_vec();
let any_components_found_in_db = !looked_up_components.is_empty();
any_components_found_in_db.then(|| {
let group_data = Data {
project: Some(group.project.clone()),
name: group.name.clone(),
color: group.color,
component_id: None,
@ -122,6 +153,14 @@ impl Group {
})
}
/// Insert given entries as first entries in the group.
pub fn insert_entries(&mut self, entries: &[Component]) {
let group_data = Rc::make_mut(&mut self.data);
group_data.entries.borrow_mut().splice(0..0, entries.iter().cloned());
group_data.initial_entries_order.splice(0..0, entries.iter().cloned());
group_data.update_matched_items();
}
/// Modify the group keeping only the [`Component`]s for which `f` returns [`true`].
pub fn retain_entries<F>(&mut self, mut f: F)
where F: FnMut(&Component) -> bool {
@ -301,6 +340,7 @@ mod tests {
// order. Some of the names correspond to entries present in the suggestion database,
// some do not.
let ec_group = execution_context::ComponentGroup {
project: project::QualifiedName::standard_base_library(),
name: "Test Group 1".into(),
color: color::Rgb::from_css_hex("#aabbcc"),
components: vec![
@ -326,7 +366,7 @@ mod tests {
.entries
.borrow()
.iter()
.map(|e| (*e.id, e.suggestion.name.to_string()))
.map(|e| (e.id().unwrap(), e.name().to_string()))
.collect_vec();
let expected_ids_and_names =
vec![(6, "fun2".to_string()), (10, "fun6".to_string()), (5, "fun1".to_string())];
@ -340,6 +380,7 @@ mod tests {
let logger = Logger::new("tests::constructing_component_group_from_names_not_found_in_db");
let suggestion_db = Rc::new(mock_suggestion_db(logger));
let ec_group = execution_context::ComponentGroup {
project: project::QualifiedName::standard_base_library(),
name: "Input".into(),
color: None,
components: vec!["NAME.NOT.FOUND.IN.DB".into()],

View File

@ -0,0 +1,63 @@
//! A module containing definitions of hardcoded [`Snippet`]s displayed as virtual components in
//! the [Component Browser](crate::controller::Searcher). The module also defines names of the
//! favorites component groups where the virtual components should be displayed.
//!
//! To learn more about favorites component groups, see:
//! [`crate::controller::searcher::component::List::favorites`].
use crate::prelude::*;
use ide_view_component_group::icon::Id as IconId;
// ====================
// === Type Aliases ===
// ====================
/// A hardcoded snippet of code with a description and syntactic metadata.
pub type Snippet = controller::searcher::action::hardcoded::Suggestion;
// =================
// === Constants ===
// =================
/// Name of the favorites component group in the `Standard.Base` library where virtual components
/// created from the [`INPUT_SNIPPETS`] should be added.
pub const INPUT_GROUP_NAME: &str = "Input";
thread_local! {
/// Snippets describing virtual components displayed in the [`INPUT_GROUP_NAME`] favorites
/// component group. The snippets wrap default literal values of Text and Number types. When
/// displayed in the Component Browser as virtual components they allow the users to easily
/// enter primitive literals in code.
pub static INPUT_SNIPPETS: Vec<Rc<Snippet>> = vec![
snippet_with_name_and_code_and_icon("text input", "\"\"", IconId::TextInput)
.with_return_type("Standard.Base.Data.Text.Text")
.with_documentation(
"A text input node.\n\n\
An empty text. The value can be edited and used as an input for other nodes."
)
.into(),
snippet_with_name_and_code_and_icon("number input", "0", IconId::NumberInput)
.with_return_type("Standard.Base.Data.Numbers.Number")
.with_documentation(
"A number input node.\n\n\
A zero number. The value can be edited and used as an input for other nodes."
)
.into(),
];
}
// === Constants helpers ===
fn snippet_with_name_and_code_and_icon(
name: &'static str,
code: &'static str,
icon: IconId,
) -> Snippet {
Snippet::new(name, code, &ImString::new(icon.as_str()))
}

View File

@ -40,6 +40,7 @@
#![feature(option_result_contains)]
#![feature(trait_alias)]
#![feature(result_into_ok_or_err)]
#![feature(result_option_inspect)]
#![feature(map_try_insert)]
#![feature(assert_matches)]
#![feature(cell_filter_map)]

View File

@ -6,6 +6,7 @@ use crate::model::module::QualifiedName as ModuleQualifiedName;
use crate::model::suggestion_database::entry as suggestion;
use crate::notification::Publisher;
use double_representation::project;
use engine_protocol::language_server;
use engine_protocol::language_server::ExpressionUpdate;
use engine_protocol::language_server::ExpressionUpdatePayload;
@ -287,6 +288,10 @@ pub struct AttachedVisualization {
#[allow(missing_docs)]
#[derive(Clone, Debug, PartialEq)]
pub struct ComponentGroup {
/// The fully qualified name of the library project.
pub project: project::QualifiedName,
/// The group name without the library project name prefix. E.g. given the `Standard.Base.Group
/// 1` group reference, the `name` field contains `Group 1`.
pub name: ImString,
/// An optional color to use when displaying the component group.
pub color: Option<color::Rgb>,
@ -297,16 +302,18 @@ impl ComponentGroup {
/// Construct from a [`language_server::LibraryComponentGroup`].
pub fn from_language_server_protocol_struct(
group: language_server::LibraryComponentGroup,
) -> Self {
) -> FallibleResult<Self> {
let project = group.library.try_into()?;
let name = group.name.into();
let color = group.color.as_ref().and_then(|c| color::Rgb::from_css_hex(c));
let components = group.exports.into_iter().map(|e| e.name.into()).collect();
ComponentGroup { name, color, components }
Ok(ComponentGroup { project, name, color, components })
}
}
impl From<language_server::LibraryComponentGroup> for ComponentGroup {
fn from(group: language_server::LibraryComponentGroup) -> Self {
impl TryFrom<language_server::LibraryComponentGroup> for ComponentGroup {
type Error = failure::Error;
fn try_from(group: language_server::LibraryComponentGroup) -> FallibleResult<Self> {
Self::from_language_server_protocol_struct(group)
}
}

View File

@ -313,7 +313,9 @@ pub mod test {
}
fn component_groups(&self) -> RefCell<Rc<Vec<ComponentGroup>>> {
let groups = self.component_groups.iter().map(|g| g.clone().into()).collect();
use engine_protocol::language_server::LibraryComponentGroup;
let group_from_lib_group = |g: &LibraryComponentGroup| g.clone().try_into().unwrap();
let groups = self.component_groups.iter().map(group_from_lib_group).collect();
RefCell::new(Rc::new(groups))
}

View File

@ -99,10 +99,20 @@ impl ExecutionContext {
/// Load the component groups defined in libraries imported into the execution context.
async fn load_component_groups(&self) {
let log_group_parsing_error = |err: &failure::Error| {
let msg = iformat!(
"Failed to parse a component group returned by the Engine. The group will not \
appear in the Favorites section of the Component Browser. Error: {err}"
);
error!(self.logger, "{msg}");
};
match self.language_server.get_component_groups(&self.id).await {
Ok(ls_response) => {
let ls_groups = ls_response.component_groups;
let groups = ls_groups.into_iter().map(|group| group.into()).collect();
let groups = ls_groups
.into_iter()
.filter_map(|group| group.try_into().inspect_err(log_group_parsing_error).ok())
.collect();
*self.model.component_groups.borrow_mut() = Rc::new(groups);
info!(self.logger, "Loaded component groups.");
}
@ -312,6 +322,7 @@ pub mod test {
use crate::model::module::QualifiedName;
use crate::model::traits::*;
use double_representation::project;
use engine_protocol::language_server::response;
use engine_protocol::language_server::CapabilityRegistration;
use engine_protocol::language_server::ExpressionUpdates;
@ -608,6 +619,7 @@ pub mod test {
// Verify that the second component group was parsed and has expected contents.
assert_eq!(groups[1], ComponentGroup {
project: project::QualifiedName::standard_base_library(),
name: "Input".into(),
color: None,
components: vec!["Standard.Base.System.File.new".into(),],

View File

@ -4,6 +4,7 @@
use crate::prelude::*;
use crate::controller::searcher::action::Suggestion;
use crate::controller::searcher::component;
use crate::controller::searcher::Notification;
use crate::controller::searcher::UserAction;
use crate::executor::global::spawn_stream_handler;
@ -134,7 +135,11 @@ impl Model {
let component: FallibleResult<_> =
self.component_by_view_id(id).ok_or_else(|| NoSuchComponent(id).into());
let new_code = component.and_then(|component| {
let suggestion = Suggestion::FromDatabase(component.suggestion.clone_ref());
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 {
@ -201,11 +206,24 @@ impl Model {
) -> String {
let component = id.and_then(|id| self.component_by_view_id(id));
if let Some(component) = component {
if let Some(documentation) = &component.suggestion.documentation_html {
let title = title_for_docs(&component.suggestion);
format!("<div class=\"enso docs summary\"><p />{title}</div>{documentation}")
match component.data {
component::Data::FromDatabase { entry, .. } => {
if let Some(documentation) = &entry.documentation_html {
let title = title_for_docs(&entry);
format!(
"<div class=\"enso docs summary\"><p />{title}</div>{documentation}"
)
} else {
doc_placeholder_for(&component.suggestion)
doc_placeholder_for(&entry)
}
}
component::Data::Virtual { snippet } => {
if let Some(documentation) = &snippet.documentation_html {
documentation.to_string()
} else {
default()
}
}
}
} else {
default()

View File

@ -3,6 +3,7 @@
use crate::prelude::*;
use crate::controller::searcher::action::MatchInfo;
use crate::controller::searcher::component;
use crate::presenter;
use enso_text as text;
@ -185,11 +186,29 @@ impl list_view::entry::ModelProvider<component_group_view::Entry> for Component
let match_info = component.match_info.borrow();
let label = component.label();
let highlighted = bytes_of_matched_letters(&*match_info, &label);
let kind = component.suggestion.kind;
let icon_name = component.suggestion.icon_name.as_ref();
let icon = match component.data {
component::Data::FromDatabase { entry, .. } => {
let kind = entry.kind;
let icon_name = entry.icon_name.as_ref();
let icon = icon_name.and_then(|n| n.to_pascal_case().parse().ok());
icon.unwrap_or_else(|| for_each_kind_variant!(kind_to_icon(kind)))
}
component::Data::Virtual { snippet } => {
let icon = &snippet.icon;
let parsed_icon = component_group_view::icon::Id::from_str(icon);
parsed_icon.unwrap_or_else(|_| {
let msg = iformat!(
"A virtual component named " snippet.name;?
" uses an icon name " icon
" which is not found among predefined icons. A default icon will be used."
);
event!(ERROR, "{msg}");
default()
})
}
};
Some(component_group_view::entry::Model {
icon: icon.unwrap_or_else(|| for_each_kind_variant!(kind_to_icon(kind))),
icon,
highlighted_text: list_view::entry::GlyphHighlightedLabelModel { label, highlighted },
})
}

View File

@ -97,12 +97,12 @@ pub mod mock {
}
pub fn suggestion_entry_foo() -> suggestion_database::Entry {
let project_name = project::QualifiedName::from_segments("std", "Base").unwrap();
let project_name = project::QualifiedName::standard_base_library();
suggestion_database::Entry {
name: "foo".to_owned(),
module: module::QualifiedName::from_segments(project_name, &["Main"])
.unwrap(),
self_type: Some("std.Base.Main".to_owned().try_into().unwrap()),
self_type: Some("Standard.Base.Main".to_owned().try_into().unwrap()),
arguments: vec![foo_method_parameter(), foo_method_parameter2()],
return_type: "Any".to_owned(),
kind: suggestion_database::entry::Kind::Method,
@ -113,12 +113,12 @@ pub mod mock {
}
pub fn suggestion_entry_bar() -> suggestion_database::Entry {
let project_name = project::QualifiedName::from_segments("std", "Base").unwrap();
let project_name = project::QualifiedName::standard_base_library();
suggestion_database::Entry {
name: "bar".to_owned(),
module: module::QualifiedName::from_segments(project_name, &["Other"])
.unwrap(),
self_type: Some("std.Base.Other".to_owned().try_into().unwrap()),
self_type: Some("Standard.Base.Other".to_owned().try_into().unwrap()),
arguments: vec![bar_method_parameter()],
return_type: "Any".to_owned(),
kind: suggestion_database::entry::Kind::Method,

View File

@ -98,6 +98,13 @@ macro_rules! define_icons {
pub fn for_each<F: FnMut(Self)>(mut f: F) {
$(f(Self::$variant);)*
}
/// Get a string identifier with the icon's name.
pub fn as_str(&self) -> &'static str {
match self {
$(Self::$variant => stringify!($variant),)*
}
}
}
impl FromStr for Id {

View File

@ -1,6 +1,6 @@
# Options intended to be common for all developers.
wasm-size-limit: 5.23 MiB
wasm-size-limit: 5.24 MiB
required-versions:
cargo-watch: ^8.1.1