diff --git a/Cargo.lock b/Cargo.lock index e268218d569d28884da82bba48088d4d85c258f7..eee0873e5b15eed8b0db8c27dfd457bedbe5a8e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4719,6 +4719,7 @@ dependencies = [ "glob", "gpui", "ignore", + "itertools", "language", "lazy_static", "log", @@ -5771,6 +5772,7 @@ dependencies = [ "collections", "editor", "futures 0.3.25", + "glob", "gpui", "language", "log", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index cc36f79e9f1bcf6b82ead7c5f19dfdb33d31a94c..2e4fa62e8ed1b7bdade780e366f3c7e92b445264 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -199,6 +199,18 @@ "shift-enter": "search::SelectPrevMatch" } }, + { + "context": "ProjectSearchBar > Editor", + "bindings": { + "escape": "project_search::ToggleFocus" + } + }, + { + "context": "ProjectSearchView > Editor", + "bindings": { + "escape": "project_search::ToggleFocus" + } + }, { "context": "Pane", "bindings": { diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 9f04642e30855a79aa6ac91e674c62e3c23c4adc..e3b5b0be7e651eb95daefddaf1e2e9c8e34e0c58 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -4548,7 +4548,10 @@ async fn test_project_search( // Perform a search as the guest. let results = project_b .update(cx_b, |project, cx| { - project.search(SearchQuery::text("world", false, false), cx) + project.search( + SearchQuery::text("world", false, false, Vec::new(), Vec::new()), + cx, + ) }) .await .unwrap(); diff --git a/crates/collab/src/tests/randomized_integration_tests.rs b/crates/collab/src/tests/randomized_integration_tests.rs index d5bd0033f7d53e6b18766db6d58642b5472201c4..c4326be101306997e6b9def9faad631a500b5690 100644 --- a/crates/collab/src/tests/randomized_integration_tests.rs +++ b/crates/collab/src/tests/randomized_integration_tests.rs @@ -716,7 +716,10 @@ async fn apply_client_operation( ); let search = project.update(cx, |project, cx| { - project.search(SearchQuery::text(query, false, false), cx) + project.search( + SearchQuery::text(query, false, false, Vec::new(), Vec::new()), + cx, + ) }); drop(project); let search = cx.background().spawn(async move { diff --git a/crates/context_menu/src/context_menu.rs b/crates/context_menu/src/context_menu.rs index 7f821c06e272a9afd50a055b1552cbbe6f8ca540..f0d477e42f84748550dfaa51f157d69e9bb94d51 100644 --- a/crates/context_menu/src/context_menu.rs +++ b/crates/context_menu/src/context_menu.rs @@ -126,7 +126,6 @@ pub struct ContextMenu { selected_index: Option, visible: bool, previously_focused_view_id: Option, - clicked: bool, parent_view_id: usize, _actions_observation: Subscription, } @@ -187,7 +186,6 @@ impl ContextMenu { selected_index: Default::default(), visible: Default::default(), previously_focused_view_id: Default::default(), - clicked: false, parent_view_id, _actions_observation: cx.observe_actions(Self::action_dispatched), } @@ -203,18 +201,14 @@ impl ContextMenu { .iter() .position(|item| item.action_id() == Some(action_id)) { - if self.clicked { - self.cancel(&Default::default(), cx); - } else { - self.selected_index = Some(ix); - cx.notify(); - cx.spawn(|this, mut cx| async move { - cx.background().timer(Duration::from_millis(50)).await; - this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx))?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } + self.selected_index = Some(ix); + cx.notify(); + cx.spawn(|this, mut cx| async move { + cx.background().timer(Duration::from_millis(50)).await; + this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx))?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); } } @@ -254,7 +248,6 @@ impl ContextMenu { self.items.clear(); self.visible = false; self.selected_index.take(); - self.clicked = false; cx.notify(); } @@ -454,7 +447,7 @@ impl ContextMenu { .on_up(MouseButton::Left, |_, _, _| {}) // Capture these events .on_down(MouseButton::Left, |_, _, _| {}) // Capture these events .on_click(MouseButton::Left, move |_, menu, cx| { - menu.clicked = true; + menu.cancel(&Default::default(), cx); let window_id = cx.window_id(); match &action { ContextMenuItemAction::Action(action) => { diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 46b00fc6eeb05a7942836e2c3fa109dcab43bde6..2b4892aab9414047c4a7a9343b82f8df82298691 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -58,6 +58,7 @@ similar = "1.3" smol.workspace = true thiserror.workspace = true toml = "0.5" +itertools = "0.10" [dev-dependencies] ctor.workspace = true diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index b3d432763e52f52dfaef111f26cbb8e1cf1a6b48..94872708c4525ce9d5cbc8ace636be918bdbb1d4 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4208,14 +4208,19 @@ impl Project { if matching_paths_tx.is_closed() { break; } - - abs_path.clear(); - abs_path.push(&snapshot.abs_path()); - abs_path.push(&entry.path); - let matches = if let Some(file) = - fs.open_sync(&abs_path).await.log_err() + let matches = if query + .file_matches(Some(&entry.path)) { - query.detect(file).unwrap_or(false) + abs_path.clear(); + abs_path.push(&snapshot.abs_path()); + abs_path.push(&entry.path); + if let Some(file) = + fs.open_sync(&abs_path).await.log_err() + { + query.detect(file).unwrap_or(false) + } else { + false + } } else { false }; @@ -4299,15 +4304,21 @@ impl Project { let mut buffers_rx = buffers_rx.clone(); scope.spawn(async move { while let Some((buffer, snapshot)) = buffers_rx.next().await { - let buffer_matches = query - .search(snapshot.as_rope()) - .await - .iter() - .map(|range| { - snapshot.anchor_before(range.start) - ..snapshot.anchor_after(range.end) - }) - .collect::>(); + let buffer_matches = if query.file_matches( + snapshot.file().map(|file| file.path().as_ref()), + ) { + query + .search(snapshot.as_rope()) + .await + .iter() + .map(|range| { + snapshot.anchor_before(range.start) + ..snapshot.anchor_after(range.end) + }) + .collect() + } else { + Vec::new() + }; if !buffer_matches.is_empty() { worker_matched_buffers .insert(buffer.clone(), buffer_matches); diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index fc530b51224eaffa8e069aa20dd5f7946aec35c2..894b27f2ee94d9081f19a68d2385e352a9eee8a9 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -3297,9 +3297,13 @@ async fn test_search(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; assert_eq!( - search(&project, SearchQuery::text("TWO", false, true), cx) - .await - .unwrap(), + search( + &project, + SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()), + cx + ) + .await + .unwrap(), HashMap::from_iter([ ("two.rs".to_string(), vec![6..9]), ("three.rs".to_string(), vec![37..40]) @@ -3318,37 +3322,361 @@ async fn test_search(cx: &mut gpui::TestAppContext) { }); assert_eq!( - search(&project, SearchQuery::text("TWO", false, true), cx) - .await - .unwrap(), + search( + &project, + SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()), + cx + ) + .await + .unwrap(), HashMap::from_iter([ ("two.rs".to_string(), vec![6..9]), ("three.rs".to_string(), vec![37..40]), ("four.rs".to_string(), vec![25..28, 36..39]) ]) ); +} - async fn search( - project: &ModelHandle, - query: SearchQuery, - cx: &mut gpui::TestAppContext, - ) -> Result>>> { - let results = project - .update(cx, |project, cx| project.search(query, cx)) - .await?; +#[gpui::test] +async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { + let search_query = "file"; - Ok(results - .into_iter() - .map(|(buffer, ranges)| { - buffer.read_with(cx, |buffer, _| { - let path = buffer.file().unwrap().path().to_string_lossy().to_string(); - let ranges = ranges - .into_iter() - .map(|range| range.to_offset(buffer)) - .collect::>(); - (path, ranges) - }) + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": r#"// Rust file one"#, + "one.ts": r#"// TypeScript file one"#, + "two.rs": r#"// Rust file two"#, + "two.ts": r#"// TypeScript file two"#, + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + + assert!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + vec![glob::Pattern::new("*.odd").unwrap()], + Vec::new() + ), + cx + ) + .await + .unwrap() + .is_empty(), + "If no inclusions match, no files should be returned" + ); + + assert_eq!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + vec![glob::Pattern::new("*.rs").unwrap()], + Vec::new() + ), + cx + ) + .await + .unwrap(), + HashMap::from_iter([ + ("one.rs".to_string(), vec![8..12]), + ("two.rs".to_string(), vec![8..12]), + ]), + "Rust only search should give only Rust files" + ); + + assert_eq!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + vec![ + glob::Pattern::new("*.ts").unwrap(), + glob::Pattern::new("*.odd").unwrap(), + ], + Vec::new() + ), + cx + ) + .await + .unwrap(), + HashMap::from_iter([ + ("one.ts".to_string(), vec![14..18]), + ("two.ts".to_string(), vec![14..18]), + ]), + "TypeScript only search should give only TypeScript files, even if other inclusions don't match anything" + ); + + assert_eq!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + vec![ + glob::Pattern::new("*.rs").unwrap(), + glob::Pattern::new("*.ts").unwrap(), + glob::Pattern::new("*.odd").unwrap(), + ], + Vec::new() + ), + cx + ) + .await + .unwrap(), + HashMap::from_iter([ + ("one.rs".to_string(), vec![8..12]), + ("one.ts".to_string(), vec![14..18]), + ("two.rs".to_string(), vec![8..12]), + ("two.ts".to_string(), vec![14..18]), + ]), + "Rust and typescript search should give both Rust and TypeScript files, even if other inclusions don't match anything" + ); +} + +#[gpui::test] +async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { + let search_query = "file"; + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": r#"// Rust file one"#, + "one.ts": r#"// TypeScript file one"#, + "two.rs": r#"// Rust file two"#, + "two.ts": r#"// TypeScript file two"#, + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + + assert_eq!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + Vec::new(), + vec![glob::Pattern::new("*.odd").unwrap()], + ), + cx + ) + .await + .unwrap(), + HashMap::from_iter([ + ("one.rs".to_string(), vec![8..12]), + ("one.ts".to_string(), vec![14..18]), + ("two.rs".to_string(), vec![8..12]), + ("two.ts".to_string(), vec![14..18]), + ]), + "If no exclusions match, all files should be returned" + ); + + assert_eq!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + Vec::new(), + vec![glob::Pattern::new("*.rs").unwrap()], + ), + cx + ) + .await + .unwrap(), + HashMap::from_iter([ + ("one.ts".to_string(), vec![14..18]), + ("two.ts".to_string(), vec![14..18]), + ]), + "Rust exclusion search should give only TypeScript files" + ); + + assert_eq!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + Vec::new(), + vec![ + glob::Pattern::new("*.ts").unwrap(), + glob::Pattern::new("*.odd").unwrap(), + ], + ), + cx + ) + .await + .unwrap(), + HashMap::from_iter([ + ("one.rs".to_string(), vec![8..12]), + ("two.rs".to_string(), vec![8..12]), + ]), + "TypeScript exclusion search should give only Rust files, even if other exclusions don't match anything" + ); + + assert!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + Vec::new(), + vec![ + glob::Pattern::new("*.rs").unwrap(), + glob::Pattern::new("*.ts").unwrap(), + glob::Pattern::new("*.odd").unwrap(), + ], + ), + cx + ) + .await + .unwrap().is_empty(), + "Rust and typescript exclusion should give no files, even if other exclusions don't match anything" + ); +} + +#[gpui::test] +async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContext) { + let search_query = "file"; + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": r#"// Rust file one"#, + "one.ts": r#"// TypeScript file one"#, + "two.rs": r#"// Rust file two"#, + "two.ts": r#"// TypeScript file two"#, + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + + assert!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + vec![glob::Pattern::new("*.odd").unwrap()], + vec![glob::Pattern::new("*.odd").unwrap()], + ), + cx + ) + .await + .unwrap() + .is_empty(), + "If both no exclusions and inclusions match, exclusions should win and return nothing" + ); + + assert!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + vec![glob::Pattern::new("*.ts").unwrap()], + vec![glob::Pattern::new("*.ts").unwrap()], + ), + cx + ) + .await + .unwrap() + .is_empty(), + "If both TypeScript exclusions and inclusions match, exclusions should win and return nothing files." + ); + + assert!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + vec![ + glob::Pattern::new("*.ts").unwrap(), + glob::Pattern::new("*.odd").unwrap() + ], + vec![ + glob::Pattern::new("*.ts").unwrap(), + glob::Pattern::new("*.odd").unwrap() + ], + ), + cx + ) + .await + .unwrap() + .is_empty(), + "Non-matching inclusions and exclusions should not change that." + ); + + assert_eq!( + search( + &project, + SearchQuery::text( + search_query, + false, + true, + vec![ + glob::Pattern::new("*.ts").unwrap(), + glob::Pattern::new("*.odd").unwrap() + ], + vec![ + glob::Pattern::new("*.rs").unwrap(), + glob::Pattern::new("*.odd").unwrap() + ], + ), + cx + ) + .await + .unwrap(), + HashMap::from_iter([ + ("one.ts".to_string(), vec![14..18]), + ("two.ts".to_string(), vec![14..18]), + ]), + "Non-intersecting TypeScript inclusions and Rust exclusions should return TypeScript files" + ); +} + +async fn search( + project: &ModelHandle, + query: SearchQuery, + cx: &mut gpui::TestAppContext, +) -> Result>>> { + let results = project + .update(cx, |project, cx| project.search(query, cx)) + .await?; + + Ok(results + .into_iter() + .map(|(buffer, ranges)| { + buffer.read_with(cx, |buffer, _| { + let path = buffer.file().unwrap().path().to_string_lossy().to_string(); + let ranges = ranges + .into_iter() + .map(|range| range.to_offset(buffer)) + .collect::>(); + (path, ranges) }) - .collect()) - } + }) + .collect()) } diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index b526ab0c18bb1ddede2dc9ed83e2a5f3c6e7cc14..ed139c97d37032969af5c042e1ed8a27ad7ef7b7 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -1,22 +1,26 @@ use aho_corasick::{AhoCorasick, AhoCorasickBuilder}; use anyhow::Result; use client::proto; +use itertools::Itertools; use language::{char_kind, Rope}; use regex::{Regex, RegexBuilder}; use smol::future::yield_now; use std::{ io::{BufRead, BufReader, Read}, ops::Range, + path::Path, sync::Arc, }; -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum SearchQuery { Text { search: Arc>, query: Arc, whole_word: bool, case_sensitive: bool, + files_to_include: Vec, + files_to_exclude: Vec, }, Regex { regex: Regex, @@ -24,11 +28,19 @@ pub enum SearchQuery { multiline: bool, whole_word: bool, case_sensitive: bool, + files_to_include: Vec, + files_to_exclude: Vec, }, } impl SearchQuery { - pub fn text(query: impl ToString, whole_word: bool, case_sensitive: bool) -> Self { + pub fn text( + query: impl ToString, + whole_word: bool, + case_sensitive: bool, + files_to_include: Vec, + files_to_exclude: Vec, + ) -> Self { let query = query.to_string(); let search = AhoCorasickBuilder::new() .auto_configure(&[&query]) @@ -39,10 +51,18 @@ impl SearchQuery { query: Arc::from(query), whole_word, case_sensitive, + files_to_include, + files_to_exclude, } } - pub fn regex(query: impl ToString, whole_word: bool, case_sensitive: bool) -> Result { + pub fn regex( + query: impl ToString, + whole_word: bool, + case_sensitive: bool, + files_to_include: Vec, + files_to_exclude: Vec, + ) -> Result { let mut query = query.to_string(); let initial_query = Arc::from(query.as_str()); if whole_word { @@ -64,17 +84,51 @@ impl SearchQuery { multiline, whole_word, case_sensitive, + files_to_include, + files_to_exclude, }) } pub fn from_proto(message: proto::SearchProject) -> Result { if message.regex { - Self::regex(message.query, message.whole_word, message.case_sensitive) + Self::regex( + message.query, + message.whole_word, + message.case_sensitive, + message + .files_to_include + .split(',') + .map(str::trim) + .filter(|glob_str| !glob_str.is_empty()) + .map(|glob_str| glob::Pattern::new(glob_str)) + .collect::>()?, + message + .files_to_exclude + .split(',') + .map(str::trim) + .filter(|glob_str| !glob_str.is_empty()) + .map(|glob_str| glob::Pattern::new(glob_str)) + .collect::>()?, + ) } else { Ok(Self::text( message.query, message.whole_word, message.case_sensitive, + message + .files_to_include + .split(',') + .map(str::trim) + .filter(|glob_str| !glob_str.is_empty()) + .map(|glob_str| glob::Pattern::new(glob_str)) + .collect::>()?, + message + .files_to_exclude + .split(',') + .map(str::trim) + .filter(|glob_str| !glob_str.is_empty()) + .map(|glob_str| glob::Pattern::new(glob_str)) + .collect::>()?, )) } } @@ -86,6 +140,16 @@ impl SearchQuery { regex: self.is_regex(), whole_word: self.whole_word(), case_sensitive: self.case_sensitive(), + files_to_include: self + .files_to_include() + .iter() + .map(ToString::to_string) + .join(","), + files_to_exclude: self + .files_to_exclude() + .iter() + .map(ToString::to_string) + .join(","), } } @@ -224,4 +288,43 @@ impl SearchQuery { pub fn is_regex(&self) -> bool { matches!(self, Self::Regex { .. }) } + + pub fn files_to_include(&self) -> &[glob::Pattern] { + match self { + Self::Text { + files_to_include, .. + } => files_to_include, + Self::Regex { + files_to_include, .. + } => files_to_include, + } + } + + pub fn files_to_exclude(&self) -> &[glob::Pattern] { + match self { + Self::Text { + files_to_exclude, .. + } => files_to_exclude, + Self::Regex { + files_to_exclude, .. + } => files_to_exclude, + } + } + + pub fn file_matches(&self, file_path: Option<&Path>) -> bool { + match file_path { + Some(file_path) => { + !self + .files_to_exclude() + .iter() + .any(|exclude_glob| exclude_glob.matches_path(file_path)) + && (self.files_to_include().is_empty() + || self + .files_to_include() + .iter() + .any(|include_glob| include_glob.matches_path(file_path))) + } + None => self.files_to_include().is_empty(), + } + } } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index d3b381bc5c499bdf7d8c0f2dede7cced6bf55af8..220ef22fb729a7842165b9781a18a0dad363e991 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -680,6 +680,8 @@ message SearchProject { bool regex = 3; bool whole_word = 4; bool case_sensitive = 5; + string files_to_include = 6; + string files_to_exclude = 7; } message SearchProjectResponse { diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index eec0dd0e22956eb30710e33b3791220c3b51ccbe..ab3c35c1fe4174626a70a71b3f97c4a11796b243 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -27,6 +27,7 @@ serde.workspace = true serde_derive.workspace = true smallvec.workspace = true smol.workspace = true +glob.workspace = true [dev-dependencies] editor = { path = "../editor", features = ["test-support"] } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 91284a545f3c50b1f19cfb391358ac70b895ae51..b0af51379d02e7dbae0cbb037cd3b62e5e925b5a 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -573,7 +573,13 @@ impl BufferSearchBar { active_searchable_item.clear_matches(cx); } else { let query = if self.regex { - match SearchQuery::regex(query, self.whole_word, self.case_sensitive) { + match SearchQuery::regex( + query, + self.whole_word, + self.case_sensitive, + Vec::new(), + Vec::new(), + ) { Ok(query) => query, Err(_) => { self.query_contains_error = true; @@ -582,7 +588,13 @@ impl BufferSearchBar { } } } else { - SearchQuery::text(query, self.whole_word, self.case_sensitive) + SearchQuery::text( + query, + self.whole_word, + self.case_sensitive, + Vec::new(), + Vec::new(), + ) }; let matches = active_searchable_item.find_matches(query, cx); diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index d68cad71d836a689b42b8a3e4f801107cd20ec29..05d27b824c7bed5a0eb010c60e57d936af735880 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -22,6 +22,7 @@ use smallvec::SmallVec; use std::{ any::{Any, TypeId}, borrow::Cow, + collections::HashSet, mem, ops::Range, path::PathBuf, @@ -34,7 +35,7 @@ use workspace::{ ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, }; -actions!(project_search, [SearchInNew, ToggleFocus]); +actions!(project_search, [SearchInNew, ToggleFocus, NextField]); #[derive(Default)] struct ActiveSearches(HashMap, WeakViewHandle>); @@ -48,6 +49,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(ProjectSearchBar::select_prev_match); cx.add_action(ProjectSearchBar::toggle_focus); cx.capture_action(ProjectSearchBar::tab); + cx.capture_action(ProjectSearchBar::tab_previous); add_toggle_option_action::(SearchOption::CaseSensitive, cx); add_toggle_option_action::(SearchOption::WholeWord, cx); add_toggle_option_action::(SearchOption::Regex, cx); @@ -75,6 +77,13 @@ struct ProjectSearch { search_id: usize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum InputPanel { + Query, + Exclude, + Include, +} + pub struct ProjectSearchView { model: ModelHandle, query_editor: ViewHandle, @@ -82,10 +91,12 @@ pub struct ProjectSearchView { case_sensitive: bool, whole_word: bool, regex: bool, - query_contains_error: bool, + panels_with_errors: HashSet, active_match_index: Option, search_id: usize, query_editor_was_focused: bool, + included_files_editor: ViewHandle, + excluded_files_editor: ViewHandle, } pub struct ProjectSearchBar { @@ -425,7 +436,7 @@ impl ProjectSearchView { editor.set_text(query_text, cx); editor }); - // Subcribe to query_editor in order to reraise editor events for workspace item activation purposes + // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes cx.subscribe(&query_editor, |_, _, event, cx| { cx.emit(ViewEvent::EditorEvent(event.clone())) }) @@ -448,6 +459,40 @@ impl ProjectSearchView { }) .detach(); + let included_files_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| { + theme.search.include_exclude_editor.input.clone() + })), + cx, + ); + editor.set_placeholder_text("Include: crates/**/*.toml", cx); + + editor + }); + // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes + cx.subscribe(&included_files_editor, |_, _, event, cx| { + cx.emit(ViewEvent::EditorEvent(event.clone())) + }) + .detach(); + + let excluded_files_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| { + theme.search.include_exclude_editor.input.clone() + })), + cx, + ); + editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx); + + editor + }); + // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes + cx.subscribe(&excluded_files_editor, |_, _, event, cx| { + cx.emit(ViewEvent::EditorEvent(event.clone())) + }) + .detach(); + let mut this = ProjectSearchView { search_id: model.read(cx).search_id, model, @@ -456,9 +501,11 @@ impl ProjectSearchView { case_sensitive, whole_word, regex, - query_contains_error: false, + panels_with_errors: HashSet::new(), active_match_index: None, query_editor_was_focused: false, + included_files_editor, + excluded_files_editor, }; this.model_changed(cx); this @@ -525,11 +572,60 @@ impl ProjectSearchView { fn build_search_query(&mut self, cx: &mut ViewContext) -> Option { let text = self.query_editor.read(cx).text(cx); + let included_files = match self + .included_files_editor + .read(cx) + .text(cx) + .split(',') + .map(str::trim) + .filter(|glob_str| !glob_str.is_empty()) + .map(|glob_str| glob::Pattern::new(glob_str)) + .collect::>() + { + Ok(included_files) => { + self.panels_with_errors.remove(&InputPanel::Include); + included_files + } + Err(_e) => { + self.panels_with_errors.insert(InputPanel::Include); + cx.notify(); + return None; + } + }; + let excluded_files = match self + .excluded_files_editor + .read(cx) + .text(cx) + .split(',') + .map(str::trim) + .filter(|glob_str| !glob_str.is_empty()) + .map(|glob_str| glob::Pattern::new(glob_str)) + .collect::>() + { + Ok(excluded_files) => { + self.panels_with_errors.remove(&InputPanel::Exclude); + excluded_files + } + Err(_e) => { + self.panels_with_errors.insert(InputPanel::Exclude); + cx.notify(); + return None; + } + }; if self.regex { - match SearchQuery::regex(text, self.whole_word, self.case_sensitive) { - Ok(query) => Some(query), - Err(_) => { - self.query_contains_error = true; + match SearchQuery::regex( + text, + self.whole_word, + self.case_sensitive, + included_files, + excluded_files, + ) { + Ok(query) => { + self.panels_with_errors.remove(&InputPanel::Query); + Some(query) + } + Err(_e) => { + self.panels_with_errors.insert(InputPanel::Query); cx.notify(); None } @@ -539,6 +635,8 @@ impl ProjectSearchView { text, self.whole_word, self.case_sensitive, + included_files, + excluded_files, )) } } @@ -723,19 +821,50 @@ impl ProjectSearchBar { } fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext) { - if let Some(search_view) = self.active_project_search.as_ref() { - search_view.update(cx, |search_view, cx| { - if search_view.query_editor.is_focused(cx) { - if !search_view.model.read(cx).match_ranges.is_empty() { - search_view.focus_results_editor(cx); - } - } else { + self.cycle_field(Direction::Next, cx); + } + + fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext) { + self.cycle_field(Direction::Prev, cx); + } + + fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext) { + let active_project_search = match &self.active_project_search { + Some(active_project_search) => active_project_search, + + None => { + cx.propagate_action(); + return; + } + }; + + active_project_search.update(cx, |project_view, cx| { + let views = &[ + &project_view.query_editor, + &project_view.included_files_editor, + &project_view.excluded_files_editor, + ]; + + let current_index = match views + .iter() + .enumerate() + .find(|(_, view)| view.is_focused(cx)) + { + Some((index, _)) => index, + + None => { cx.propagate_action(); + return; } - }); - } else { - cx.propagate_action(); - } + }; + + let new_index = match direction { + Direction::Next => (current_index + 1) % views.len(), + Direction::Prev if current_index == 0 => views.len() - 1, + Direction::Prev => (current_index - 1) % views.len(), + }; + cx.focus(views[new_index]); + }); } fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext) -> bool { @@ -864,59 +993,121 @@ impl View for ProjectSearchBar { if let Some(search) = self.active_project_search.as_ref() { let search = search.read(cx); let theme = cx.global::().theme.clone(); - let editor_container = if search.query_contains_error { + let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) { theme.search.invalid_editor } else { theme.search.editor.input.container }; - Flex::row() + let include_container_style = + if search.panels_with_errors.contains(&InputPanel::Include) { + theme.search.invalid_include_exclude_editor + } else { + theme.search.include_exclude_editor.input.container + }; + let exclude_container_style = + if search.panels_with_errors.contains(&InputPanel::Exclude) { + theme.search.invalid_include_exclude_editor + } else { + theme.search.include_exclude_editor.input.container + }; + + let included_files_view = ChildView::new(&search.included_files_editor, cx) + .aligned() + .left() + .flex(1.0, true); + let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx) + .aligned() + .right() + .flex(1.0, true); + + let row_spacing = theme.workspace.toolbar.container.padding.bottom; + + Flex::column() .with_child( Flex::row() .with_child( - ChildView::new(&search.query_editor, cx) + Flex::row() + .with_child( + ChildView::new(&search.query_editor, cx) + .aligned() + .left() + .flex(1., true), + ) + .with_children(search.active_match_index.map(|match_ix| { + Label::new( + format!( + "{}/{}", + match_ix + 1, + search.model.read(cx).match_ranges.len() + ), + theme.search.match_index.text.clone(), + ) + .contained() + .with_style(theme.search.match_index.container) + .aligned() + })) + .contained() + .with_style(query_container_style) .aligned() - .left() - .flex(1., true), + .constrained() + .with_min_width(theme.search.editor.min_width) + .with_max_width(theme.search.editor.max_width) + .flex(1., false), + ) + .with_child( + Flex::row() + .with_child(self.render_nav_button("<", Direction::Prev, cx)) + .with_child(self.render_nav_button(">", Direction::Next, cx)) + .aligned(), + ) + .with_child( + Flex::row() + .with_child(self.render_option_button( + "Case", + SearchOption::CaseSensitive, + cx, + )) + .with_child(self.render_option_button( + "Word", + SearchOption::WholeWord, + cx, + )) + .with_child(self.render_option_button( + "Regex", + SearchOption::Regex, + cx, + )) + .contained() + .with_style(theme.search.option_button_group) + .aligned(), ) - .with_children(search.active_match_index.map(|match_ix| { - Label::new( - format!( - "{}/{}", - match_ix + 1, - search.model.read(cx).match_ranges.len() - ), - theme.search.match_index.text.clone(), - ) - .contained() - .with_style(theme.search.match_index.container) - .aligned() - })) .contained() - .with_style(editor_container) - .aligned() - .constrained() - .with_min_width(theme.search.editor.min_width) - .with_max_width(theme.search.editor.max_width) - .flex(1., false), + .with_margin_bottom(row_spacing), ) .with_child( Flex::row() - .with_child(self.render_nav_button("<", Direction::Prev, cx)) - .with_child(self.render_nav_button(">", Direction::Next, cx)) - .aligned(), - ) - .with_child( - Flex::row() - .with_child(self.render_option_button( - "Case", - SearchOption::CaseSensitive, - cx, - )) - .with_child(self.render_option_button("Word", SearchOption::WholeWord, cx)) - .with_child(self.render_option_button("Regex", SearchOption::Regex, cx)) - .contained() - .with_style(theme.search.option_button_group) - .aligned(), + .with_child( + Flex::row() + .with_child(included_files_view) + .contained() + .with_style(include_container_style) + .aligned() + .constrained() + .with_min_width(theme.search.include_exclude_editor.min_width) + .with_max_width(theme.search.include_exclude_editor.max_width) + .flex(1., false), + ) + .with_child( + Flex::row() + .with_child(excluded_files_view) + .contained() + .with_style(exclude_container_style) + .aligned() + .constrained() + .with_min_width(theme.search.include_exclude_editor.min_width) + .with_max_width(theme.search.include_exclude_editor.max_width) + .flex(1., false), + ), ) .contained() .with_style(theme.search.container) @@ -948,6 +1139,10 @@ impl ToolbarItemView for ProjectSearchBar { ToolbarItemLocation::Hidden } } + + fn row_count(&self) -> usize { + 2 + } } #[cfg(test)] diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 2d4c5201270aa8ada60345b5da52ea302c6f3482..29bb51be684ef52be337fd06bb64a37cfe321684 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -309,6 +309,9 @@ pub struct Search { pub editor: FindEditor, pub invalid_editor: ContainerStyle, pub option_button_group: ContainerStyle, + pub include_exclude_editor: FindEditor, + pub invalid_include_exclude_editor: ContainerStyle, + pub include_exclude_inputs: ContainedText, pub option_button: Interactive, pub match_background: Color, pub match_index: ContainedText, diff --git a/crates/workspace/src/toolbar.rs b/crates/workspace/src/toolbar.rs index eac9963d38ddcedbaea32c9722b8df2667d2299e..b2832aa1e810ff7e5fad7e3dc5802de6cf49a8b5 100644 --- a/crates/workspace/src/toolbar.rs +++ b/crates/workspace/src/toolbar.rs @@ -22,6 +22,13 @@ pub trait ToolbarItemView: View { } fn pane_focus_update(&mut self, _pane_focused: bool, _cx: &mut ViewContext) {} + + /// Number of times toolbar's height will be repeated to get the effective height. + /// Useful when multiple rows one under each other are needed. + /// The rows have the same width and act as a whole when reacting to resizes and similar events. + fn row_count(&self) -> usize { + 1 + } } trait ToolbarItemViewHandle { @@ -33,6 +40,7 @@ trait ToolbarItemViewHandle { cx: &mut WindowContext, ) -> ToolbarItemLocation; fn pane_focus_update(&mut self, pane_focused: bool, cx: &mut WindowContext); + fn row_count(&self, cx: &WindowContext) -> usize; } #[derive(Copy, Clone, Debug, PartialEq)] @@ -66,12 +74,14 @@ impl View for Toolbar { let mut primary_right_items = Vec::new(); let mut secondary_item = None; let spacing = theme.item_spacing; + let mut primary_items_row_count = 1; for (item, position) in &self.items { match *position { ToolbarItemLocation::Hidden => {} ToolbarItemLocation::PrimaryLeft { flex } => { + primary_items_row_count = primary_items_row_count.max(item.row_count(cx)); let left_item = ChildView::new(item.as_any(), cx) .aligned() .contained() @@ -84,6 +94,7 @@ impl View for Toolbar { } ToolbarItemLocation::PrimaryRight { flex } => { + primary_items_row_count = primary_items_row_count.max(item.row_count(cx)); let right_item = ChildView::new(item.as_any(), cx) .aligned() .contained() @@ -100,7 +111,7 @@ impl View for Toolbar { secondary_item = Some( ChildView::new(item.as_any(), cx) .constrained() - .with_height(theme.height) + .with_height(theme.height * item.row_count(cx) as f32) .into_any(), ); } @@ -117,7 +128,8 @@ impl View for Toolbar { } let container_style = theme.container; - let height = theme.height; + let height = theme.height * primary_items_row_count as f32; + let nav_button_height = theme.height; let button_style = theme.nav_button; let tooltip_style = cx.global::().theme.tooltip.clone(); @@ -127,6 +139,7 @@ impl View for Toolbar { .with_child(nav_button( "icons/arrow_left_16.svg", button_style, + nav_button_height, tooltip_style.clone(), enable_go_backward, spacing, @@ -155,6 +168,7 @@ impl View for Toolbar { .with_child(nav_button( "icons/arrow_right_16.svg", button_style, + nav_button_height, tooltip_style, enable_go_forward, spacing, @@ -196,6 +210,7 @@ impl View for Toolbar { fn nav_button)>( svg_path: &'static str, style: theme::Interactive, + nav_button_height: f32, tooltip_style: TooltipStyle, enabled: bool, spacing: f32, @@ -219,8 +234,9 @@ fn nav_button .with_style(style.container) .constrained() .with_width(style.button_width) - .with_height(style.button_width) + .with_height(nav_button_height) .aligned() + .top() }) .with_cursor_style(if enabled { CursorStyle::PointingHand @@ -338,6 +354,10 @@ impl ToolbarItemViewHandle for ViewHandle { cx.notify(); }); } + + fn row_count(&self, cx: &WindowContext) -> usize { + self.read(cx).row_count() + } } impl From<&dyn ToolbarItemViewHandle> for AnyViewHandle { diff --git a/styles/src/styleTree/search.ts b/styles/src/styleTree/search.ts index 7a00b932784909fe8bfc8ba20772db7059adece9..6fc8a95d7dbde571103d6d1ebebf4265837ba1b4 100644 --- a/styles/src/styleTree/search.ts +++ b/styles/src/styleTree/search.ts @@ -26,6 +26,12 @@ export default function search(colorScheme: ColorScheme) { }, } + const includeExcludeEditor = { + ...editor, + minWidth: 100, + maxWidth: 250, + }; + return { // TODO: Add an activeMatchBackground on the rust side to differenciate between active and inactive matchBackground: withOpacity(foreground(layer, "accent"), 0.4), @@ -64,9 +70,16 @@ export default function search(colorScheme: ColorScheme) { ...editor, border: border(layer, "negative"), }, + includeExcludeEditor, + invalidIncludeExcludeEditor: { + ...includeExcludeEditor, + border: border(layer, "negative"), + }, matchIndex: { ...text(layer, "mono", "variant"), - padding: 6, + padding: { + left: 6, + }, }, optionButtonGroup: { padding: { @@ -74,6 +87,12 @@ export default function search(colorScheme: ColorScheme) { right: 12, }, }, + includeExcludeInputs: { + ...text(layer, "mono", "variant"), + padding: { + right: 6, + }, + }, resultsStatus: { ...text(layer, "mono", "on"), size: 18,