Detailed changes
@@ -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",
@@ -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": {
@@ -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();
@@ -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 {
@@ -126,7 +126,6 @@ pub struct ContextMenu {
selected_index: Option<usize>,
visible: bool,
previously_focused_view_id: Option<usize>,
- 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) => {
@@ -58,6 +58,7 @@ similar = "1.3"
smol.workspace = true
thiserror.workspace = true
toml = "0.5"
+itertools = "0.10"
[dev-dependencies]
ctor.workspace = true
@@ -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::<Vec<_>>();
+ 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);
@@ -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<Project>,
- query: SearchQuery,
- cx: &mut gpui::TestAppContext,
- ) -> Result<HashMap<String, Vec<Range<usize>>>> {
- 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::<Vec<_>>();
- (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<Project>,
+ query: SearchQuery,
+ cx: &mut gpui::TestAppContext,
+) -> Result<HashMap<String, Vec<Range<usize>>>> {
+ 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::<Vec<_>>();
+ (path, ranges)
})
- .collect())
- }
+ })
+ .collect())
}
@@ -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<AhoCorasick<usize>>,
query: Arc<str>,
whole_word: bool,
case_sensitive: bool,
+ files_to_include: Vec<glob::Pattern>,
+ files_to_exclude: Vec<glob::Pattern>,
},
Regex {
regex: Regex,
@@ -24,11 +28,19 @@ pub enum SearchQuery {
multiline: bool,
whole_word: bool,
case_sensitive: bool,
+ files_to_include: Vec<glob::Pattern>,
+ files_to_exclude: Vec<glob::Pattern>,
},
}
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<glob::Pattern>,
+ files_to_exclude: Vec<glob::Pattern>,
+ ) -> 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<Self> {
+ pub fn regex(
+ query: impl ToString,
+ whole_word: bool,
+ case_sensitive: bool,
+ files_to_include: Vec<glob::Pattern>,
+ files_to_exclude: Vec<glob::Pattern>,
+ ) -> Result<Self> {
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<Self> {
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::<Result<_, _>>()?,
+ message
+ .files_to_exclude
+ .split(',')
+ .map(str::trim)
+ .filter(|glob_str| !glob_str.is_empty())
+ .map(|glob_str| glob::Pattern::new(glob_str))
+ .collect::<Result<_, _>>()?,
+ )
} 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::<Result<_, _>>()?,
+ message
+ .files_to_exclude
+ .split(',')
+ .map(str::trim)
+ .filter(|glob_str| !glob_str.is_empty())
+ .map(|glob_str| glob::Pattern::new(glob_str))
+ .collect::<Result<_, _>>()?,
))
}
}
@@ -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(),
+ }
+ }
}
@@ -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 {
@@ -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"] }
@@ -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);
@@ -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<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
@@ -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::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
add_toggle_option_action::<ToggleRegex>(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<ProjectSearch>,
query_editor: ViewHandle<Editor>,
@@ -82,10 +91,12 @@ pub struct ProjectSearchView {
case_sensitive: bool,
whole_word: bool,
regex: bool,
- query_contains_error: bool,
+ panels_with_errors: HashSet<InputPanel>,
active_match_index: Option<usize>,
search_id: usize,
query_editor_was_focused: bool,
+ included_files_editor: ViewHandle<Editor>,
+ excluded_files_editor: ViewHandle<Editor>,
}
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<Self>) -> Option<SearchQuery> {
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::<Result<_, _>>()
+ {
+ 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::<Result<_, _>>()
+ {
+ 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<Self>) {
- 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>) {
+ self.cycle_field(Direction::Prev, cx);
+ }
+
+ fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
+ 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<Self>) -> 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::<Settings>().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)]
@@ -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<ContainedText>,
pub match_background: Color,
pub match_index: ContainedText,
@@ -22,6 +22,13 @@ pub trait ToolbarItemView: View {
}
fn pane_focus_update(&mut self, _pane_focused: bool, _cx: &mut ViewContext<Self>) {}
+
+ /// 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::<Settings>().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<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>)>(
svg_path: &'static str,
style: theme::Interactive<theme::IconButton>,
+ nav_button_height: f32,
tooltip_style: TooltipStyle,
enabled: bool,
spacing: f32,
@@ -219,8 +234,9 @@ fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>
.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<T: ToolbarItemView> ToolbarItemViewHandle for ViewHandle<T> {
cx.notify();
});
}
+
+ fn row_count(&self, cx: &WindowContext) -> usize {
+ self.read(cx).row_count()
+ }
}
impl From<&dyn ToolbarItemViewHandle> for AnyViewHandle {
@@ -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,