Detailed changes
@@ -194,8 +194,8 @@
{
"context": "Editor && mode == auto_height",
"bindings": {
- "alt-enter": "editor::Newline",
- "cmd-alt-enter": "editor::NewlineBelow"
+ "shift-enter": "editor::Newline",
+ "cmd-shift-enter": "editor::NewlineBelow"
}
},
{
@@ -221,7 +221,8 @@
"escape": "buffer_search::Dismiss",
"tab": "buffer_search::FocusEditor",
"enter": "search::SelectNextMatch",
- "shift-enter": "search::SelectPrevMatch"
+ "shift-enter": "search::SelectPrevMatch",
+ "alt-enter": "search::SelectAllMatches"
}
},
{
@@ -242,6 +243,7 @@
"cmd-f": "project_search::ToggleFocus",
"cmd-g": "search::SelectNextMatch",
"cmd-shift-g": "search::SelectPrevMatch",
+ "alt-enter": "search::SelectAllMatches",
"alt-cmd-c": "search::ToggleCaseSensitive",
"alt-cmd-w": "search::ToggleWholeWord",
"alt-cmd-r": "search::ToggleRegex"
@@ -887,10 +887,20 @@ pub(crate) enum BufferSearchHighlights {}
impl SearchableItem for Editor {
type Match = Range<Anchor>;
- fn to_search_event(event: &Self::Event) -> Option<SearchEvent> {
+ fn to_search_event(
+ &mut self,
+ event: &Self::Event,
+ _: &mut ViewContext<Self>,
+ ) -> Option<SearchEvent> {
match event {
Event::BufferEdited => Some(SearchEvent::MatchesInvalidated),
- Event::SelectionsChanged { .. } => Some(SearchEvent::ActiveMatchChanged),
+ Event::SelectionsChanged { .. } => {
+ if self.selections.disjoint_anchors().len() == 1 {
+ Some(SearchEvent::ActiveMatchChanged)
+ } else {
+ None
+ }
+ }
_ => None,
}
}
@@ -941,6 +951,11 @@ impl SearchableItem for Editor {
});
}
+ fn select_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
+ self.unfold_ranges(matches.clone(), false, false, cx);
+ self.change_selections(None, cx, |s| s.select_ranges(matches));
+ }
+
fn match_index_for_direction(
&mut self,
matches: &Vec<Range<Anchor>>,
@@ -949,8 +964,16 @@ impl SearchableItem for Editor {
cx: &mut ViewContext<Self>,
) -> usize {
let buffer = self.buffer().read(cx).snapshot(cx);
- let cursor = self.selections.newest_anchor().head();
- if matches[current_index].start.cmp(&cursor, &buffer).is_gt() {
+ let current_index_position = if self.selections.disjoint_anchors().len() == 1 {
+ self.selections.newest_anchor().head()
+ } else {
+ matches[current_index].start
+ };
+ if matches[current_index]
+ .start
+ .cmp(¤t_index_position, &buffer)
+ .is_gt()
+ {
if direction == Direction::Prev {
if current_index == 0 {
current_index = matches.len() - 1;
@@ -958,7 +981,11 @@ impl SearchableItem for Editor {
current_index -= 1;
}
}
- } else if matches[current_index].end.cmp(&cursor, &buffer).is_lt() {
+ } else if matches[current_index]
+ .end
+ .cmp(¤t_index_position, &buffer)
+ .is_lt()
+ {
if direction == Direction::Next {
current_index = 0;
}
@@ -16,13 +16,13 @@ use crate::{
Anchor, DisplayPoint, ExcerptId, MultiBuffer, MultiBufferSnapshot, SelectMode, ToOffset,
};
-#[derive(Clone)]
+#[derive(Debug, Clone)]
pub struct PendingSelection {
pub selection: Selection<Anchor>,
pub mode: SelectMode,
}
-#[derive(Clone)]
+#[derive(Debug, Clone)]
pub struct SelectionsCollection {
display_map: ModelHandle<DisplayMap>,
buffer: ModelHandle<MultiBuffer>,
@@ -362,8 +362,13 @@ impl Item for FeedbackEditor {
impl SearchableItem for FeedbackEditor {
type Match = Range<Anchor>;
- fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
- Editor::to_search_event(event)
+ fn to_search_event(
+ &mut self,
+ event: &Self::Event,
+ cx: &mut ViewContext<Self>,
+ ) -> Option<workspace::searchable::SearchEvent> {
+ self.editor
+ .update(cx, |editor, cx| editor.to_search_event(event, cx))
}
fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
@@ -391,6 +396,11 @@ impl SearchableItem for FeedbackEditor {
.update(cx, |editor, cx| editor.activate_match(index, matches, cx))
}
+ fn select_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
+ self.editor
+ .update(cx, |e, cx| e.select_matches(matches, cx))
+ }
+
fn find_matches(
&mut self,
query: project::search::SearchQuery,
@@ -467,8 +467,13 @@ impl Item for LspLogView {
impl SearchableItem for LspLogView {
type Match = <Editor as SearchableItem>::Match;
- fn to_search_event(event: &Self::Event) -> Option<workspace::searchable::SearchEvent> {
- Editor::to_search_event(event)
+ fn to_search_event(
+ &mut self,
+ event: &Self::Event,
+ cx: &mut ViewContext<Self>,
+ ) -> Option<workspace::searchable::SearchEvent> {
+ self.editor
+ .update(cx, |editor, cx| editor.to_search_event(event, cx))
}
fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
@@ -494,6 +499,11 @@ impl SearchableItem for LspLogView {
.update(cx, |e, cx| e.activate_match(index, matches, cx))
}
+ fn select_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
+ self.editor
+ .update(cx, |e, cx| e.select_matches(matches, cx))
+ }
+
fn find_matches(
&mut self,
query: project::search::SearchQuery,
@@ -1,6 +1,6 @@
use crate::{
- SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
- ToggleWholeWord,
+ SearchOption, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive,
+ ToggleRegex, ToggleWholeWord,
};
use collections::HashMap;
use editor::Editor;
@@ -39,8 +39,10 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(BufferSearchBar::focus_editor);
cx.add_action(BufferSearchBar::select_next_match);
cx.add_action(BufferSearchBar::select_prev_match);
+ cx.add_action(BufferSearchBar::select_all_matches);
cx.add_action(BufferSearchBar::select_next_match_on_pane);
cx.add_action(BufferSearchBar::select_prev_match_on_pane);
+ cx.add_action(BufferSearchBar::select_all_matches_on_pane);
cx.add_action(BufferSearchBar::handle_editor_cancel);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
@@ -66,7 +68,7 @@ pub struct BufferSearchBar {
active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
active_match_index: Option<usize>,
active_searchable_item_subscription: Option<Subscription>,
- seachable_items_with_matches:
+ searchable_items_with_matches:
HashMap<Box<dyn WeakSearchableItemHandle>, Vec<Box<dyn Any + Send>>>,
pending_search: Option<Task<()>>,
case_sensitive: bool,
@@ -118,7 +120,7 @@ impl View for BufferSearchBar {
.with_children(self.active_searchable_item.as_ref().and_then(
|searchable_item| {
let matches = self
- .seachable_items_with_matches
+ .searchable_items_with_matches
.get(&searchable_item.downgrade())?;
let message = if let Some(match_ix) = self.active_match_index {
format!("{}/{}", match_ix + 1, matches.len())
@@ -146,6 +148,7 @@ impl View for BufferSearchBar {
Flex::row()
.with_child(self.render_nav_button("<", Direction::Prev, cx))
.with_child(self.render_nav_button(">", Direction::Next, cx))
+ .with_child(self.render_action_button("Select All", cx))
.aligned(),
)
.with_child(
@@ -249,7 +252,7 @@ impl BufferSearchBar {
active_searchable_item: None,
active_searchable_item_subscription: None,
active_match_index: None,
- seachable_items_with_matches: Default::default(),
+ searchable_items_with_matches: Default::default(),
case_sensitive: false,
whole_word: false,
regex: false,
@@ -265,7 +268,7 @@ impl BufferSearchBar {
pub fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
self.dismissed = true;
- for searchable_item in self.seachable_items_with_matches.keys() {
+ for searchable_item in self.searchable_items_with_matches.keys() {
if let Some(searchable_item) =
WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx)
{
@@ -401,6 +404,37 @@ impl BufferSearchBar {
.into_any()
}
+ fn render_action_button(
+ &self,
+ icon: &'static str,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ let tooltip = "Select All Matches";
+ let tooltip_style = theme::current(cx).tooltip.clone();
+ let action_type_id = 0_usize;
+
+ enum ActionButton {}
+ MouseEventHandler::<ActionButton, _>::new(action_type_id, cx, |state, cx| {
+ let theme = theme::current(cx);
+ let style = theme.search.action_button.style_for(state);
+ Label::new(icon, style.text.clone())
+ .contained()
+ .with_style(style.container)
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.select_all_matches(&SelectAllMatches, cx)
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .with_tooltip::<ActionButton>(
+ action_type_id,
+ tooltip.to_string(),
+ Some(Box::new(SelectAllMatches)),
+ tooltip_style,
+ cx,
+ )
+ .into_any()
+ }
+
fn render_close_button(
&self,
theme: &theme::Search,
@@ -488,11 +522,25 @@ impl BufferSearchBar {
self.select_match(Direction::Prev, cx);
}
+ fn select_all_matches(&mut self, _: &SelectAllMatches, cx: &mut ViewContext<Self>) {
+ if !self.dismissed {
+ if let Some(searchable_item) = self.active_searchable_item.as_ref() {
+ if let Some(matches) = self
+ .searchable_items_with_matches
+ .get(&searchable_item.downgrade())
+ {
+ searchable_item.select_matches(matches, cx);
+ self.focus_editor(&FocusEditor, cx);
+ }
+ }
+ }
+ }
+
pub fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
if let Some(index) = self.active_match_index {
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
if let Some(matches) = self
- .seachable_items_with_matches
+ .searchable_items_with_matches
.get(&searchable_item.downgrade())
{
let new_match_index =
@@ -524,6 +572,16 @@ impl BufferSearchBar {
}
}
+ fn select_all_matches_on_pane(
+ pane: &mut Pane,
+ action: &SelectAllMatches,
+ cx: &mut ViewContext<Pane>,
+ ) {
+ if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
+ search_bar.update(cx, |bar, cx| bar.select_all_matches(action, cx));
+ }
+ }
+
fn on_query_editor_event(
&mut self,
_: ViewHandle<Editor>,
@@ -547,7 +605,7 @@ impl BufferSearchBar {
fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
let mut active_item_matches = None;
- for (searchable_item, matches) in self.seachable_items_with_matches.drain() {
+ for (searchable_item, matches) in self.searchable_items_with_matches.drain() {
if let Some(searchable_item) =
WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx)
{
@@ -559,7 +617,7 @@ impl BufferSearchBar {
}
}
- self.seachable_items_with_matches
+ self.searchable_items_with_matches
.extend(active_item_matches);
}
@@ -605,13 +663,13 @@ impl BufferSearchBar {
if let Some(active_searchable_item) =
WeakSearchableItemHandle::upgrade(active_searchable_item.as_ref(), cx)
{
- this.seachable_items_with_matches
+ this.searchable_items_with_matches
.insert(active_searchable_item.downgrade(), matches);
this.update_match_index(cx);
if !this.dismissed {
let matches = this
- .seachable_items_with_matches
+ .searchable_items_with_matches
.get(&active_searchable_item.downgrade())
.unwrap();
active_searchable_item.update_matches(matches, cx);
@@ -637,7 +695,7 @@ impl BufferSearchBar {
.as_ref()
.and_then(|searchable_item| {
let matches = self
- .seachable_items_with_matches
+ .searchable_items_with_matches
.get(&searchable_item.downgrade())?;
searchable_item.active_match_index(matches, cx)
});
@@ -966,4 +1024,133 @@ mod tests {
assert_eq!(search_bar.active_match_index, Some(2));
});
}
+
+ #[gpui::test]
+ async fn test_search_select_all_matches(cx: &mut TestAppContext) {
+ crate::project_search::tests::init_test(cx);
+
+ let buffer_text = r#"
+ A regular expression (shortened as regex or regexp;[1] also referred to as
+ rational expression[2][3]) is a sequence of characters that specifies a search
+ pattern in text. Usually such patterns are used by string-searching algorithms
+ for "find" or "find and replace" operations on strings, or for input validation.
+ "#
+ .unindent();
+ let expected_query_matches_count = buffer_text
+ .chars()
+ .filter(|c| c.to_ascii_lowercase() == 'a')
+ .count();
+ assert!(
+ expected_query_matches_count > 1,
+ "Should pick a query with multiple results"
+ );
+ let buffer = cx.add_model(|cx| Buffer::new(0, buffer_text, cx));
+ let (window_id, _root_view) = cx.add_window(|_| EmptyView);
+
+ let editor = cx.add_view(window_id, |cx| Editor::for_buffer(buffer.clone(), None, cx));
+
+ let search_bar = cx.add_view(window_id, |cx| {
+ let mut search_bar = BufferSearchBar::new(cx);
+ search_bar.set_active_pane_item(Some(&editor), cx);
+ search_bar.show(false, true, cx);
+ search_bar
+ });
+
+ search_bar.update(cx, |search_bar, cx| {
+ search_bar.set_query("a", cx);
+ });
+
+ editor.next_notification(cx).await;
+ let initial_selections = editor.update(cx, |editor, cx| {
+ let initial_selections = editor.selections.display_ranges(cx);
+ assert_eq!(
+ initial_selections.len(), 1,
+ "Expected to have only one selection before adding carets to all matches, but got: {initial_selections:?}",
+ );
+ initial_selections
+ });
+ search_bar.update(cx, |search_bar, _| {
+ assert_eq!(search_bar.active_match_index, Some(0));
+ });
+
+ search_bar.update(cx, |search_bar, cx| {
+ search_bar.select_all_matches(&SelectAllMatches, cx);
+ let all_selections =
+ editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
+ assert_eq!(
+ all_selections.len(),
+ expected_query_matches_count,
+ "Should select all `a` characters in the buffer, but got: {all_selections:?}"
+ );
+ });
+ search_bar.update(cx, |search_bar, _| {
+ assert_eq!(
+ search_bar.active_match_index,
+ Some(0),
+ "Match index should not change after selecting all matches"
+ );
+ });
+
+ search_bar.update(cx, |search_bar, cx| {
+ search_bar.select_next_match(&SelectNextMatch, cx);
+ let all_selections =
+ editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
+ assert_eq!(
+ all_selections.len(),
+ 1,
+ "On next match, should deselect items and select the next match"
+ );
+ assert_ne!(
+ all_selections, initial_selections,
+ "Next match should be different from the first selection"
+ );
+ });
+ search_bar.update(cx, |search_bar, _| {
+ assert_eq!(
+ search_bar.active_match_index,
+ Some(1),
+ "Match index should be updated to the next one"
+ );
+ });
+
+ search_bar.update(cx, |search_bar, cx| {
+ search_bar.select_all_matches(&SelectAllMatches, cx);
+ let all_selections =
+ editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
+ assert_eq!(
+ all_selections.len(),
+ expected_query_matches_count,
+ "Should select all `a` characters in the buffer, but got: {all_selections:?}"
+ );
+ });
+ search_bar.update(cx, |search_bar, _| {
+ assert_eq!(
+ search_bar.active_match_index,
+ Some(1),
+ "Match index should not change after selecting all matches"
+ );
+ });
+
+ search_bar.update(cx, |search_bar, cx| {
+ search_bar.select_prev_match(&SelectPrevMatch, cx);
+ let all_selections =
+ editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
+ assert_eq!(
+ all_selections.len(),
+ 1,
+ "On previous match, should deselect items and select the previous item"
+ );
+ assert_eq!(
+ all_selections, initial_selections,
+ "Previous match should be the same as the first selection"
+ );
+ });
+ search_bar.update(cx, |search_bar, _| {
+ assert_eq!(
+ search_bar.active_match_index,
+ Some(0),
+ "Match index should be updated to the previous one"
+ );
+ });
+ }
}
@@ -17,7 +17,8 @@ actions!(
ToggleCaseSensitive,
ToggleRegex,
SelectNextMatch,
- SelectPrevMatch
+ SelectPrevMatch,
+ SelectAllMatches,
]
);
@@ -908,6 +908,21 @@ impl Terminal {
}
}
+ pub fn select_matches(&mut self, matches: Vec<RangeInclusive<Point>>) {
+ let matches_to_select = self
+ .matches
+ .iter()
+ .filter(|self_match| matches.contains(self_match))
+ .cloned()
+ .collect::<Vec<_>>();
+ for match_to_select in matches_to_select {
+ self.set_selection(Some((
+ make_selection(&match_to_select),
+ *match_to_select.end(),
+ )));
+ }
+ }
+
fn set_selection(&mut self, selection: Option<(Selection, Point)>) {
self.events
.push_back(InternalEvent::SetSelection(selection));
@@ -647,7 +647,11 @@ impl SearchableItem for TerminalView {
}
/// Convert events raised by this item into search-relevant events (if applicable)
- fn to_search_event(event: &Self::Event) -> Option<SearchEvent> {
+ fn to_search_event(
+ &mut self,
+ event: &Self::Event,
+ _: &mut ViewContext<Self>,
+ ) -> Option<SearchEvent> {
match event {
Event::Wakeup => Some(SearchEvent::MatchesInvalidated),
Event::SelectionsChanged => Some(SearchEvent::ActiveMatchChanged),
@@ -682,6 +686,13 @@ impl SearchableItem for TerminalView {
cx.notify();
}
+ /// Add selections for all matches given.
+ fn select_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
+ self.terminal()
+ .update(cx, |term, _| term.select_matches(matches));
+ cx.notify();
+ }
+
/// Get all of the matches for this query, should be done on the background
fn find_matches(
&mut self,
@@ -379,6 +379,7 @@ pub struct Search {
pub invalid_include_exclude_editor: ContainerStyle,
pub include_exclude_inputs: ContainedText,
pub option_button: Toggleable<Interactive<ContainedText>>,
+ pub action_button: Interactive<ContainedText>,
pub match_background: Color,
pub match_index: ContainedText,
pub results_status: TextStyle,
@@ -37,7 +37,11 @@ pub trait SearchableItem: Item {
regex: true,
}
}
- fn to_search_event(event: &Self::Event) -> Option<SearchEvent>;
+ fn to_search_event(
+ &mut self,
+ event: &Self::Event,
+ cx: &mut ViewContext<Self>,
+ ) -> Option<SearchEvent>;
fn clear_matches(&mut self, cx: &mut ViewContext<Self>);
fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>);
fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String;
@@ -47,6 +51,7 @@ pub trait SearchableItem: Item {
matches: Vec<Self::Match>,
cx: &mut ViewContext<Self>,
);
+ fn select_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>);
fn match_index_for_direction(
&mut self,
matches: &Vec<Self::Match>,
@@ -102,6 +107,7 @@ pub trait SearchableItemHandle: ItemHandle {
matches: &Vec<Box<dyn Any + Send>>,
cx: &mut WindowContext,
);
+ fn select_matches(&self, matches: &Vec<Box<dyn Any + Send>>, cx: &mut WindowContext);
fn match_index_for_direction(
&self,
matches: &Vec<Box<dyn Any + Send>>,
@@ -139,8 +145,9 @@ impl<T: SearchableItem> SearchableItemHandle for ViewHandle<T> {
cx: &mut WindowContext,
handler: Box<dyn Fn(SearchEvent, &mut WindowContext)>,
) -> Subscription {
- cx.subscribe(self, move |_, event, cx| {
- if let Some(search_event) = T::to_search_event(event) {
+ cx.subscribe(self, move |handle, event, cx| {
+ let search_event = handle.update(cx, |handle, cx| handle.to_search_event(event, cx));
+ if let Some(search_event) = search_event {
handler(search_event, cx)
}
})
@@ -165,6 +172,12 @@ impl<T: SearchableItem> SearchableItemHandle for ViewHandle<T> {
let matches = downcast_matches(matches);
self.update(cx, |this, cx| this.activate_match(index, matches, cx));
}
+
+ fn select_matches(&self, matches: &Vec<Box<dyn Any + Send>>, cx: &mut WindowContext) {
+ let matches = downcast_matches(matches);
+ self.update(cx, |this, cx| this.select_matches(matches, cx));
+ }
+
fn match_index_for_direction(
&self,
matches: &Vec<Box<dyn Any + Send>>,
@@ -83,6 +83,35 @@ export default function search(): any {
},
},
}),
+ action_button: interactive({
+ base: {
+ ...text(theme.highest, "mono", "on"),
+ background: background(theme.highest, "on"),
+ corner_radius: 6,
+ border: border(theme.highest, "on"),
+ margin: {
+ right: 4,
+ },
+ padding: {
+ bottom: 2,
+ left: 10,
+ right: 10,
+ top: 2,
+ },
+ },
+ state: {
+ hovered: {
+ ...text(theme.highest, "mono", "on", "hovered"),
+ background: background(theme.highest, "on", "hovered"),
+ border: border(theme.highest, "on", "hovered"),
+ },
+ clicked: {
+ ...text(theme.highest, "mono", "on", "pressed"),
+ background: background(theme.highest, "on", "pressed"),
+ border: border(theme.highest, "on", "pressed"),
+ },
+ },
+ }),
editor,
invalid_editor: {
...editor,