Merge branch 'main' into panels

This commit is contained in:
Antonio Scandurra 2023-05-10 15:23:37 +02:00
commit cdcb7c8084
16 changed files with 841 additions and 133 deletions

2
Cargo.lock generated
View file

@ -4719,6 +4719,7 @@ dependencies = [
"glob", "glob",
"gpui", "gpui",
"ignore", "ignore",
"itertools",
"language", "language",
"lazy_static", "lazy_static",
"log", "log",
@ -5771,6 +5772,7 @@ dependencies = [
"collections", "collections",
"editor", "editor",
"futures 0.3.25", "futures 0.3.25",
"glob",
"gpui", "gpui",
"language", "language",
"log", "log",

View file

@ -199,6 +199,18 @@
"shift-enter": "search::SelectPrevMatch" "shift-enter": "search::SelectPrevMatch"
} }
}, },
{
"context": "ProjectSearchBar > Editor",
"bindings": {
"escape": "project_search::ToggleFocus"
}
},
{
"context": "ProjectSearchView > Editor",
"bindings": {
"escape": "project_search::ToggleFocus"
}
},
{ {
"context": "Pane", "context": "Pane",
"bindings": { "bindings": {

View file

@ -4548,7 +4548,10 @@ async fn test_project_search(
// Perform a search as the guest. // Perform a search as the guest.
let results = project_b let results = project_b
.update(cx_b, |project, cx| { .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 .await
.unwrap(); .unwrap();

View file

@ -716,7 +716,10 @@ async fn apply_client_operation(
); );
let search = project.update(cx, |project, cx| { 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); drop(project);
let search = cx.background().spawn(async move { let search = cx.background().spawn(async move {

View file

@ -126,7 +126,6 @@ pub struct ContextMenu {
selected_index: Option<usize>, selected_index: Option<usize>,
visible: bool, visible: bool,
previously_focused_view_id: Option<usize>, previously_focused_view_id: Option<usize>,
clicked: bool,
parent_view_id: usize, parent_view_id: usize,
_actions_observation: Subscription, _actions_observation: Subscription,
} }
@ -187,7 +186,6 @@ impl ContextMenu {
selected_index: Default::default(), selected_index: Default::default(),
visible: Default::default(), visible: Default::default(),
previously_focused_view_id: Default::default(), previously_focused_view_id: Default::default(),
clicked: false,
parent_view_id, parent_view_id,
_actions_observation: cx.observe_actions(Self::action_dispatched), _actions_observation: cx.observe_actions(Self::action_dispatched),
} }
@ -203,18 +201,14 @@ impl ContextMenu {
.iter() .iter()
.position(|item| item.action_id() == Some(action_id)) .position(|item| item.action_id() == Some(action_id))
{ {
if self.clicked { self.selected_index = Some(ix);
self.cancel(&Default::default(), cx); cx.notify();
} else { cx.spawn(|this, mut cx| async move {
self.selected_index = Some(ix); cx.background().timer(Duration::from_millis(50)).await;
cx.notify(); this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx))?;
cx.spawn(|this, mut cx| async move { anyhow::Ok(())
cx.background().timer(Duration::from_millis(50)).await; })
this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx))?; .detach_and_log_err(cx);
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
} }
} }
@ -254,7 +248,6 @@ impl ContextMenu {
self.items.clear(); self.items.clear();
self.visible = false; self.visible = false;
self.selected_index.take(); self.selected_index.take();
self.clicked = false;
cx.notify(); cx.notify();
} }
@ -454,7 +447,7 @@ impl ContextMenu {
.on_up(MouseButton::Left, |_, _, _| {}) // Capture these events .on_up(MouseButton::Left, |_, _, _| {}) // Capture these events
.on_down(MouseButton::Left, |_, _, _| {}) // Capture these events .on_down(MouseButton::Left, |_, _, _| {}) // Capture these events
.on_click(MouseButton::Left, move |_, menu, cx| { .on_click(MouseButton::Left, move |_, menu, cx| {
menu.clicked = true; menu.cancel(&Default::default(), cx);
let window_id = cx.window_id(); let window_id = cx.window_id();
match &action { match &action {
ContextMenuItemAction::Action(action) => { ContextMenuItemAction::Action(action) => {

View file

@ -58,6 +58,7 @@ similar = "1.3"
smol.workspace = true smol.workspace = true
thiserror.workspace = true thiserror.workspace = true
toml = "0.5" toml = "0.5"
itertools = "0.10"
[dev-dependencies] [dev-dependencies]
ctor.workspace = true ctor.workspace = true

View file

@ -4208,14 +4208,19 @@ impl Project {
if matching_paths_tx.is_closed() { if matching_paths_tx.is_closed() {
break; break;
} }
let matches = if query
abs_path.clear(); .file_matches(Some(&entry.path))
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()
{ {
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 { } else {
false false
}; };
@ -4299,15 +4304,21 @@ impl Project {
let mut buffers_rx = buffers_rx.clone(); let mut buffers_rx = buffers_rx.clone();
scope.spawn(async move { scope.spawn(async move {
while let Some((buffer, snapshot)) = buffers_rx.next().await { while let Some((buffer, snapshot)) = buffers_rx.next().await {
let buffer_matches = query let buffer_matches = if query.file_matches(
.search(snapshot.as_rope()) snapshot.file().map(|file| file.path().as_ref()),
.await ) {
.iter() query
.map(|range| { .search(snapshot.as_rope())
snapshot.anchor_before(range.start) .await
..snapshot.anchor_after(range.end) .iter()
}) .map(|range| {
.collect::<Vec<_>>(); snapshot.anchor_before(range.start)
..snapshot.anchor_after(range.end)
})
.collect()
} else {
Vec::new()
};
if !buffer_matches.is_empty() { if !buffer_matches.is_empty() {
worker_matched_buffers worker_matched_buffers
.insert(buffer.clone(), buffer_matches); .insert(buffer.clone(), buffer_matches);

View file

@ -3297,9 +3297,13 @@ async fn test_search(cx: &mut gpui::TestAppContext) {
.await; .await;
let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
assert_eq!( assert_eq!(
search(&project, SearchQuery::text("TWO", false, true), cx) search(
.await &project,
.unwrap(), SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()),
cx
)
.await
.unwrap(),
HashMap::from_iter([ HashMap::from_iter([
("two.rs".to_string(), vec![6..9]), ("two.rs".to_string(), vec![6..9]),
("three.rs".to_string(), vec![37..40]) ("three.rs".to_string(), vec![37..40])
@ -3318,37 +3322,361 @@ async fn test_search(cx: &mut gpui::TestAppContext) {
}); });
assert_eq!( assert_eq!(
search(&project, SearchQuery::text("TWO", false, true), cx) search(
.await &project,
.unwrap(), SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()),
cx
)
.await
.unwrap(),
HashMap::from_iter([ HashMap::from_iter([
("two.rs".to_string(), vec![6..9]), ("two.rs".to_string(), vec![6..9]),
("three.rs".to_string(), vec![37..40]), ("three.rs".to_string(), vec![37..40]),
("four.rs".to_string(), vec![25..28, 36..39]) ("four.rs".to_string(), vec![25..28, 36..39])
]) ])
); );
}
async fn search(
project: &ModelHandle<Project>, #[gpui::test]
query: SearchQuery, async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
cx: &mut gpui::TestAppContext, let search_query = "file";
) -> Result<HashMap<String, Vec<Range<usize>>>> {
let results = project let fs = FakeFs::new(cx.background());
.update(cx, |project, cx| project.search(query, cx)) fs.insert_tree(
.await?; "/dir",
json!({
Ok(results "one.rs": r#"// Rust file one"#,
.into_iter() "one.ts": r#"// TypeScript file one"#,
.map(|(buffer, ranges)| { "two.rs": r#"// Rust file two"#,
buffer.read_with(cx, |buffer, _| { "two.ts": r#"// TypeScript file two"#,
let path = buffer.file().unwrap().path().to_string_lossy().to_string(); }),
let ranges = ranges )
.into_iter() .await;
.map(|range| range.to_offset(buffer)) let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
.collect::<Vec<_>>();
(path, ranges) assert!(
}) search(
}) &project,
.collect()) 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())
} }

View file

@ -1,22 +1,26 @@
use aho_corasick::{AhoCorasick, AhoCorasickBuilder}; use aho_corasick::{AhoCorasick, AhoCorasickBuilder};
use anyhow::Result; use anyhow::Result;
use client::proto; use client::proto;
use itertools::Itertools;
use language::{char_kind, Rope}; use language::{char_kind, Rope};
use regex::{Regex, RegexBuilder}; use regex::{Regex, RegexBuilder};
use smol::future::yield_now; use smol::future::yield_now;
use std::{ use std::{
io::{BufRead, BufReader, Read}, io::{BufRead, BufReader, Read},
ops::Range, ops::Range,
path::Path,
sync::Arc, sync::Arc,
}; };
#[derive(Clone)] #[derive(Clone, Debug)]
pub enum SearchQuery { pub enum SearchQuery {
Text { Text {
search: Arc<AhoCorasick<usize>>, search: Arc<AhoCorasick<usize>>,
query: Arc<str>, query: Arc<str>,
whole_word: bool, whole_word: bool,
case_sensitive: bool, case_sensitive: bool,
files_to_include: Vec<glob::Pattern>,
files_to_exclude: Vec<glob::Pattern>,
}, },
Regex { Regex {
regex: Regex, regex: Regex,
@ -24,11 +28,19 @@ pub enum SearchQuery {
multiline: bool, multiline: bool,
whole_word: bool, whole_word: bool,
case_sensitive: bool, case_sensitive: bool,
files_to_include: Vec<glob::Pattern>,
files_to_exclude: Vec<glob::Pattern>,
}, },
} }
impl SearchQuery { 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 query = query.to_string();
let search = AhoCorasickBuilder::new() let search = AhoCorasickBuilder::new()
.auto_configure(&[&query]) .auto_configure(&[&query])
@ -39,10 +51,18 @@ impl SearchQuery {
query: Arc::from(query), query: Arc::from(query),
whole_word, whole_word,
case_sensitive, 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 mut query = query.to_string();
let initial_query = Arc::from(query.as_str()); let initial_query = Arc::from(query.as_str());
if whole_word { if whole_word {
@ -64,17 +84,51 @@ impl SearchQuery {
multiline, multiline,
whole_word, whole_word,
case_sensitive, case_sensitive,
files_to_include,
files_to_exclude,
}) })
} }
pub fn from_proto(message: proto::SearchProject) -> Result<Self> { pub fn from_proto(message: proto::SearchProject) -> Result<Self> {
if message.regex { 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 { } else {
Ok(Self::text( Ok(Self::text(
message.query, message.query,
message.whole_word, message.whole_word,
message.case_sensitive, 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(), regex: self.is_regex(),
whole_word: self.whole_word(), whole_word: self.whole_word(),
case_sensitive: self.case_sensitive(), 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 { pub fn is_regex(&self) -> bool {
matches!(self, Self::Regex { .. }) 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(),
}
}
} }

View file

@ -680,6 +680,8 @@ message SearchProject {
bool regex = 3; bool regex = 3;
bool whole_word = 4; bool whole_word = 4;
bool case_sensitive = 5; bool case_sensitive = 5;
string files_to_include = 6;
string files_to_exclude = 7;
} }
message SearchProjectResponse { message SearchProjectResponse {

View file

@ -27,6 +27,7 @@ serde.workspace = true
serde_derive.workspace = true serde_derive.workspace = true
smallvec.workspace = true smallvec.workspace = true
smol.workspace = true smol.workspace = true
glob.workspace = true
[dev-dependencies] [dev-dependencies]
editor = { path = "../editor", features = ["test-support"] } editor = { path = "../editor", features = ["test-support"] }

View file

@ -573,7 +573,13 @@ impl BufferSearchBar {
active_searchable_item.clear_matches(cx); active_searchable_item.clear_matches(cx);
} else { } else {
let query = if self.regex { 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, Ok(query) => query,
Err(_) => { Err(_) => {
self.query_contains_error = true; self.query_contains_error = true;
@ -582,7 +588,13 @@ impl BufferSearchBar {
} }
} }
} else { } 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); let matches = active_searchable_item.find_matches(query, cx);

View file

@ -22,6 +22,7 @@ use smallvec::SmallVec;
use std::{ use std::{
any::{Any, TypeId}, any::{Any, TypeId},
borrow::Cow, borrow::Cow,
collections::HashSet,
mem, mem,
ops::Range, ops::Range,
path::PathBuf, path::PathBuf,
@ -34,7 +35,7 @@ use workspace::{
ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
}; };
actions!(project_search, [SearchInNew, ToggleFocus]); actions!(project_search, [SearchInNew, ToggleFocus, NextField]);
#[derive(Default)] #[derive(Default)]
struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>); 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::select_prev_match);
cx.add_action(ProjectSearchBar::toggle_focus); cx.add_action(ProjectSearchBar::toggle_focus);
cx.capture_action(ProjectSearchBar::tab); cx.capture_action(ProjectSearchBar::tab);
cx.capture_action(ProjectSearchBar::tab_previous);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx); add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx); add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx); add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx);
@ -75,6 +77,13 @@ struct ProjectSearch {
search_id: usize, search_id: usize,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum InputPanel {
Query,
Exclude,
Include,
}
pub struct ProjectSearchView { pub struct ProjectSearchView {
model: ModelHandle<ProjectSearch>, model: ModelHandle<ProjectSearch>,
query_editor: ViewHandle<Editor>, query_editor: ViewHandle<Editor>,
@ -82,10 +91,12 @@ pub struct ProjectSearchView {
case_sensitive: bool, case_sensitive: bool,
whole_word: bool, whole_word: bool,
regex: bool, regex: bool,
query_contains_error: bool, panels_with_errors: HashSet<InputPanel>,
active_match_index: Option<usize>, active_match_index: Option<usize>,
search_id: usize, search_id: usize,
query_editor_was_focused: bool, query_editor_was_focused: bool,
included_files_editor: ViewHandle<Editor>,
excluded_files_editor: ViewHandle<Editor>,
} }
pub struct ProjectSearchBar { pub struct ProjectSearchBar {
@ -425,7 +436,7 @@ impl ProjectSearchView {
editor.set_text(query_text, cx); editor.set_text(query_text, cx);
editor 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.subscribe(&query_editor, |_, _, event, cx| {
cx.emit(ViewEvent::EditorEvent(event.clone())) cx.emit(ViewEvent::EditorEvent(event.clone()))
}) })
@ -448,6 +459,40 @@ impl ProjectSearchView {
}) })
.detach(); .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 { let mut this = ProjectSearchView {
search_id: model.read(cx).search_id, search_id: model.read(cx).search_id,
model, model,
@ -456,9 +501,11 @@ impl ProjectSearchView {
case_sensitive, case_sensitive,
whole_word, whole_word,
regex, regex,
query_contains_error: false, panels_with_errors: HashSet::new(),
active_match_index: None, active_match_index: None,
query_editor_was_focused: false, query_editor_was_focused: false,
included_files_editor,
excluded_files_editor,
}; };
this.model_changed(cx); this.model_changed(cx);
this this
@ -525,11 +572,60 @@ impl ProjectSearchView {
fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> { fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
let text = self.query_editor.read(cx).text(cx); 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 { if self.regex {
match SearchQuery::regex(text, self.whole_word, self.case_sensitive) { match SearchQuery::regex(
Ok(query) => Some(query), text,
Err(_) => { self.whole_word,
self.query_contains_error = true; 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(); cx.notify();
None None
} }
@ -539,6 +635,8 @@ impl ProjectSearchView {
text, text,
self.whole_word, self.whole_word,
self.case_sensitive, self.case_sensitive,
included_files,
excluded_files,
)) ))
} }
} }
@ -723,19 +821,50 @@ impl ProjectSearchBar {
} }
fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) { fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
if let Some(search_view) = self.active_project_search.as_ref() { self.cycle_field(Direction::Next, cx);
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() { fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
search_view.focus_results_editor(cx); self.cycle_field(Direction::Prev, cx);
} }
} else {
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(); 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 { 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() { if let Some(search) = self.active_project_search.as_ref() {
let search = search.read(cx); let search = search.read(cx);
let theme = cx.global::<Settings>().theme.clone(); 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 theme.search.invalid_editor
} else { } else {
theme.search.editor.input.container 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( .with_child(
Flex::row() Flex::row()
.with_child( .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() .aligned()
.left() .constrained()
.flex(1., true), .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() .contained()
.with_style(editor_container) .with_margin_bottom(row_spacing),
.aligned()
.constrained()
.with_min_width(theme.search.editor.min_width)
.with_max_width(theme.search.editor.max_width)
.flex(1., false),
) )
.with_child( .with_child(
Flex::row() Flex::row()
.with_child(self.render_nav_button("<", Direction::Prev, cx)) .with_child(
.with_child(self.render_nav_button(">", Direction::Next, cx)) Flex::row()
.aligned(), .with_child(included_files_view)
) .contained()
.with_child( .with_style(include_container_style)
Flex::row() .aligned()
.with_child(self.render_option_button( .constrained()
"Case", .with_min_width(theme.search.include_exclude_editor.min_width)
SearchOption::CaseSensitive, .with_max_width(theme.search.include_exclude_editor.max_width)
cx, .flex(1., false),
)) )
.with_child(self.render_option_button("Word", SearchOption::WholeWord, cx)) .with_child(
.with_child(self.render_option_button("Regex", SearchOption::Regex, cx)) Flex::row()
.contained() .with_child(excluded_files_view)
.with_style(theme.search.option_button_group) .contained()
.aligned(), .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() .contained()
.with_style(theme.search.container) .with_style(theme.search.container)
@ -948,6 +1139,10 @@ impl ToolbarItemView for ProjectSearchBar {
ToolbarItemLocation::Hidden ToolbarItemLocation::Hidden
} }
} }
fn row_count(&self) -> usize {
2
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -309,6 +309,9 @@ pub struct Search {
pub editor: FindEditor, pub editor: FindEditor,
pub invalid_editor: ContainerStyle, pub invalid_editor: ContainerStyle,
pub option_button_group: 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 option_button: Interactive<ContainedText>,
pub match_background: Color, pub match_background: Color,
pub match_index: ContainedText, pub match_index: ContainedText,

View file

@ -22,6 +22,13 @@ pub trait ToolbarItemView: View {
} }
fn pane_focus_update(&mut self, _pane_focused: bool, _cx: &mut ViewContext<Self>) {} 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 { trait ToolbarItemViewHandle {
@ -33,6 +40,7 @@ trait ToolbarItemViewHandle {
cx: &mut WindowContext, cx: &mut WindowContext,
) -> ToolbarItemLocation; ) -> ToolbarItemLocation;
fn pane_focus_update(&mut self, pane_focused: bool, cx: &mut WindowContext); fn pane_focus_update(&mut self, pane_focused: bool, cx: &mut WindowContext);
fn row_count(&self, cx: &WindowContext) -> usize;
} }
#[derive(Copy, Clone, Debug, PartialEq)] #[derive(Copy, Clone, Debug, PartialEq)]
@ -66,12 +74,14 @@ impl View for Toolbar {
let mut primary_right_items = Vec::new(); let mut primary_right_items = Vec::new();
let mut secondary_item = None; let mut secondary_item = None;
let spacing = theme.item_spacing; let spacing = theme.item_spacing;
let mut primary_items_row_count = 1;
for (item, position) in &self.items { for (item, position) in &self.items {
match *position { match *position {
ToolbarItemLocation::Hidden => {} ToolbarItemLocation::Hidden => {}
ToolbarItemLocation::PrimaryLeft { flex } => { 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) let left_item = ChildView::new(item.as_any(), cx)
.aligned() .aligned()
.contained() .contained()
@ -84,6 +94,7 @@ impl View for Toolbar {
} }
ToolbarItemLocation::PrimaryRight { flex } => { 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) let right_item = ChildView::new(item.as_any(), cx)
.aligned() .aligned()
.contained() .contained()
@ -100,7 +111,7 @@ impl View for Toolbar {
secondary_item = Some( secondary_item = Some(
ChildView::new(item.as_any(), cx) ChildView::new(item.as_any(), cx)
.constrained() .constrained()
.with_height(theme.height) .with_height(theme.height * item.row_count(cx) as f32)
.into_any(), .into_any(),
); );
} }
@ -117,7 +128,8 @@ impl View for Toolbar {
} }
let container_style = theme.container; 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 button_style = theme.nav_button;
let tooltip_style = cx.global::<Settings>().theme.tooltip.clone(); let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
@ -127,6 +139,7 @@ impl View for Toolbar {
.with_child(nav_button( .with_child(nav_button(
"icons/arrow_left_16.svg", "icons/arrow_left_16.svg",
button_style, button_style,
nav_button_height,
tooltip_style.clone(), tooltip_style.clone(),
enable_go_backward, enable_go_backward,
spacing, spacing,
@ -155,6 +168,7 @@ impl View for Toolbar {
.with_child(nav_button( .with_child(nav_button(
"icons/arrow_right_16.svg", "icons/arrow_right_16.svg",
button_style, button_style,
nav_button_height,
tooltip_style, tooltip_style,
enable_go_forward, enable_go_forward,
spacing, spacing,
@ -196,6 +210,7 @@ impl View for Toolbar {
fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>)>( fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>)>(
svg_path: &'static str, svg_path: &'static str,
style: theme::Interactive<theme::IconButton>, style: theme::Interactive<theme::IconButton>,
nav_button_height: f32,
tooltip_style: TooltipStyle, tooltip_style: TooltipStyle,
enabled: bool, enabled: bool,
spacing: f32, spacing: f32,
@ -219,8 +234,9 @@ fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>
.with_style(style.container) .with_style(style.container)
.constrained() .constrained()
.with_width(style.button_width) .with_width(style.button_width)
.with_height(style.button_width) .with_height(nav_button_height)
.aligned() .aligned()
.top()
}) })
.with_cursor_style(if enabled { .with_cursor_style(if enabled {
CursorStyle::PointingHand CursorStyle::PointingHand
@ -338,6 +354,10 @@ impl<T: ToolbarItemView> ToolbarItemViewHandle for ViewHandle<T> {
cx.notify(); cx.notify();
}); });
} }
fn row_count(&self, cx: &WindowContext) -> usize {
self.read(cx).row_count()
}
} }
impl From<&dyn ToolbarItemViewHandle> for AnyViewHandle { impl From<&dyn ToolbarItemViewHandle> for AnyViewHandle {

View file

@ -26,6 +26,12 @@ export default function search(colorScheme: ColorScheme) {
}, },
} }
const includeExcludeEditor = {
...editor,
minWidth: 100,
maxWidth: 250,
};
return { return {
// TODO: Add an activeMatchBackground on the rust side to differenciate between active and inactive // TODO: Add an activeMatchBackground on the rust side to differenciate between active and inactive
matchBackground: withOpacity(foreground(layer, "accent"), 0.4), matchBackground: withOpacity(foreground(layer, "accent"), 0.4),
@ -64,9 +70,16 @@ export default function search(colorScheme: ColorScheme) {
...editor, ...editor,
border: border(layer, "negative"), border: border(layer, "negative"),
}, },
includeExcludeEditor,
invalidIncludeExcludeEditor: {
...includeExcludeEditor,
border: border(layer, "negative"),
},
matchIndex: { matchIndex: {
...text(layer, "mono", "variant"), ...text(layer, "mono", "variant"),
padding: 6, padding: {
left: 6,
},
}, },
optionButtonGroup: { optionButtonGroup: {
padding: { padding: {
@ -74,6 +87,12 @@ export default function search(colorScheme: ColorScheme) {
right: 12, right: 12,
}, },
}, },
includeExcludeInputs: {
...text(layer, "mono", "variant"),
padding: {
right: 6,
},
},
resultsStatus: { resultsStatus: {
...text(layer, "mono", "on"), ...text(layer, "mono", "on"),
size: 18, size: 18,