Implement navigation between project search matches

Nathan Sobo created

Change summary

crates/search/src/buffer_search.rs  |   9 -
crates/search/src/project_search.rs | 173 +++++++++++++++++++++++++++---
crates/search/src/search.rs         |  11 +
3 files changed, 167 insertions(+), 26 deletions(-)

Detailed changes

crates/search/src/buffer_search.rs 🔗

@@ -1,4 +1,4 @@
-use crate::SearchOption;
+use crate::{Direction, SearchOption, SelectMatch};
 use collections::HashMap;
 use editor::{display_map::ToDisplayPoint, Anchor, Autoscroll, Bias, Editor};
 use gpui::{
@@ -18,13 +18,6 @@ action!(Deploy, bool);
 action!(Dismiss);
 action!(FocusEditor);
 action!(ToggleSearchOption, SearchOption);
-action!(SelectMatch, Direction);
-
-#[derive(Clone, Copy, PartialEq, Eq)]
-pub enum Direction {
-    Prev,
-    Next,
-}
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_bindings([

crates/search/src/project_search.rs 🔗

@@ -1,5 +1,5 @@
-use crate::SearchOption;
-use editor::{Anchor, Autoscroll, Editor, MultiBuffer, SelectAll};
+use crate::{Direction, SearchOption, SelectMatch, ToggleSearchOption};
+use editor::{Anchor, Autoscroll, Editor, MultiBuffer, SelectAll, SelectNext};
 use gpui::{
     action, elements::*, keymap::Binding, platform::CursorStyle, AppContext, ElementBox, Entity,
     ModelContext, ModelHandle, MutableAppContext, RenderContext, Task, View, ViewContext,
@@ -9,6 +9,7 @@ use postage::watch;
 use project::{search::SearchQuery, Project};
 use std::{
     any::{Any, TypeId},
+    cmp::{self, Ordering},
     ops::Range,
     path::PathBuf,
 };
@@ -18,7 +19,6 @@ use workspace::{Item, ItemHandle, ItemNavHistory, ItemView, Settings, Workspace}
 action!(Deploy);
 action!(Search);
 action!(SearchInNew);
-action!(ToggleSearchOption, SearchOption);
 action!(ToggleFocus);
 
 const MAX_TAB_TITLE_LEN: usize = 24;
@@ -30,19 +30,30 @@ pub fn init(cx: &mut MutableAppContext) {
         Binding::new("cmd-shift-F", Deploy, Some("Workspace")),
         Binding::new("enter", Search, Some("ProjectSearchView")),
         Binding::new("cmd-enter", SearchInNew, Some("ProjectSearchView")),
+        Binding::new(
+            "cmd-g",
+            SelectMatch(Direction::Next),
+            Some("ProjectSearchView"),
+        ),
+        Binding::new(
+            "cmd-shift-G",
+            SelectMatch(Direction::Prev),
+            Some("ProjectSearchView"),
+        ),
     ]);
     cx.add_action(ProjectSearchView::deploy);
     cx.add_action(ProjectSearchView::search);
     cx.add_action(ProjectSearchView::search_in_new);
     cx.add_action(ProjectSearchView::toggle_search_option);
     cx.add_action(ProjectSearchView::toggle_focus);
+    cx.add_action(ProjectSearchView::select_match);
 }
 
 struct ProjectSearch {
     project: ModelHandle<Project>,
     excerpts: ModelHandle<MultiBuffer>,
     pending_search: Option<Task<Option<()>>>,
-    highlighted_ranges: Vec<Range<Anchor>>,
+    match_ranges: Vec<Range<Anchor>>,
     active_query: Option<SearchQuery>,
 }
 
@@ -54,6 +65,7 @@ struct ProjectSearchView {
     whole_word: bool,
     regex: bool,
     query_contains_error: bool,
+    active_match_index: Option<usize>,
     settings: watch::Receiver<Settings>,
 }
 
@@ -68,7 +80,7 @@ impl ProjectSearch {
             project,
             excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
             pending_search: Default::default(),
-            highlighted_ranges: Default::default(),
+            match_ranges: Default::default(),
             active_query: None,
         }
     }
@@ -80,7 +92,7 @@ impl ProjectSearch {
                 .excerpts
                 .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
             pending_search: Default::default(),
-            highlighted_ranges: self.highlighted_ranges.clone(),
+            match_ranges: self.match_ranges.clone(),
             active_query: self.active_query.clone(),
         })
     }
@@ -90,12 +102,12 @@ impl ProjectSearch {
             .project
             .update(cx, |project, cx| project.search(query.clone(), cx));
         self.active_query = Some(query);
-        self.highlighted_ranges.clear();
+        self.match_ranges.clear();
         self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
             let matches = search.await.log_err()?;
             if let Some(this) = this.upgrade(&cx) {
                 this.update(&mut cx, |this, cx| {
-                    this.highlighted_ranges.clear();
+                    this.match_ranges.clear();
                     let mut matches = matches.into_iter().collect::<Vec<_>>();
                     matches
                         .sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
@@ -108,7 +120,7 @@ impl ProjectSearch {
                                 1,
                                 cx,
                             );
-                            this.highlighted_ranges.extend(ranges_to_highlight);
+                            this.match_ranges.extend(ranges_to_highlight);
                         }
                     });
                     this.pending_search.take();
@@ -153,7 +165,7 @@ impl View for ProjectSearchView {
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
         let model = &self.model.read(cx);
-        let results = if model.highlighted_ranges.is_empty() {
+        let results = if model.match_ranges.is_empty() {
             let theme = &self.settings.borrow().theme;
             let text = if self.query_editor.read(cx).text(cx).is_empty() {
                 ""
@@ -181,7 +193,7 @@ impl View for ProjectSearchView {
     }
 
     fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
-        if self.model.read(cx).highlighted_ranges.is_empty() {
+        if self.model.read(cx).match_ranges.is_empty() {
             cx.focus(&self.query_editor);
         } else {
             self.focus_results_editor(cx);
@@ -348,6 +360,12 @@ impl ProjectSearchView {
         });
         cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
             .detach();
+        cx.subscribe(&results_editor, |this, _, event, cx| {
+            if matches!(event, editor::Event::SelectionsChanged) {
+                this.update_match_index(cx);
+            }
+        })
+        .detach();
 
         let mut this = ProjectSearchView {
             model,
@@ -357,6 +375,7 @@ impl ProjectSearchView {
             whole_word,
             regex,
             query_contains_error: false,
+            active_match_index: None,
             settings,
         };
         this.model_changed(false, cx);
@@ -446,9 +465,52 @@ impl ProjectSearchView {
         cx.notify();
     }
 
+    fn select_match(&mut self, &SelectMatch(direction): &SelectMatch, cx: &mut ViewContext<Self>) {
+        if let Some(mut index) = self.active_match_index {
+            let range_to_select = {
+                let model = self.model.read(cx);
+                let results_editor = self.results_editor.read(cx);
+                let buffer = results_editor.buffer().read(cx).read(cx);
+                let cursor = results_editor.newest_anchor_selection().head();
+                let ranges = &model.match_ranges;
+
+                if ranges[index].start.cmp(&cursor, &buffer).unwrap().is_gt() {
+                    if direction == Direction::Prev {
+                        if index == 0 {
+                            index = ranges.len() - 1;
+                        } else {
+                            index -= 1;
+                        }
+                    }
+                } else if ranges[index].end.cmp(&cursor, &buffer).unwrap().is_lt() {
+                    if direction == Direction::Next {
+                        index = 0;
+                    }
+                } else if direction == Direction::Prev {
+                    if index == 0 {
+                        index = ranges.len() - 1;
+                    } else {
+                        index -= 1;
+                    }
+                } else if direction == Direction::Next {
+                    if index == ranges.len() - 1 {
+                        index = 0
+                    } else {
+                        index += 1;
+                    }
+                };
+                ranges[index].clone()
+            };
+
+            self.results_editor.update(cx, |editor, cx| {
+                editor.select_ranges([range_to_select], Some(Autoscroll::Fit), cx);
+            });
+        }
+    }
+
     fn toggle_focus(&mut self, _: &ToggleFocus, cx: &mut ViewContext<Self>) {
         if self.query_editor.is_focused(cx) {
-            if !self.model.read(cx).highlighted_ranges.is_empty() {
+            if !self.model.read(cx).match_ranges.is_empty() {
                 self.focus_results_editor(cx);
             }
         } else {
@@ -461,18 +523,20 @@ impl ProjectSearchView {
 
     fn focus_results_editor(&self, cx: &mut ViewContext<Self>) {
         self.query_editor.update(cx, |query_editor, cx| {
-            let head = query_editor.newest_anchor_selection().head();
-            query_editor.select_ranges([head.clone()..head], None, cx);
+            let cursor = query_editor.newest_anchor_selection().head();
+            query_editor.select_ranges([cursor.clone()..cursor], None, cx);
         });
         cx.focus(&self.results_editor);
     }
 
     fn model_changed(&mut self, reset_selections: bool, cx: &mut ViewContext<Self>) {
-        let highlighted_ranges = self.model.read(cx).highlighted_ranges.clone();
-        if !highlighted_ranges.is_empty() {
+        let match_ranges = self.model.read(cx).match_ranges.clone();
+        if match_ranges.is_empty() {
+            self.active_match_index = None;
+        } else {
             let theme = &self.settings.borrow().theme.search;
             self.results_editor.update(cx, |editor, cx| {
-                editor.highlight_ranges::<Self>(highlighted_ranges, theme.match_background, cx);
+                editor.highlight_ranges::<Self>(match_ranges, theme.match_background, cx);
                 if reset_selections {
                     editor.select_ranges([0..0], Some(Autoscroll::Fit), cx);
                 }
@@ -486,6 +550,34 @@ impl ProjectSearchView {
         cx.notify();
     }
 
+    fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
+        let match_ranges = self.model.read(cx).match_ranges.clone();
+        if match_ranges.is_empty() {
+            self.active_match_index = None;
+        } else {
+            let results_editor = &self.results_editor.read(cx);
+            let cursor = results_editor.newest_anchor_selection().head();
+            let new_index = {
+                let buffer = results_editor.buffer().read(cx).read(cx);
+                match match_ranges.binary_search_by(|probe| {
+                    if probe.end.cmp(&cursor, &*buffer).unwrap().is_lt() {
+                        Ordering::Less
+                    } else if probe.start.cmp(&cursor, &*buffer).unwrap().is_gt() {
+                        Ordering::Greater
+                    } else {
+                        Ordering::Equal
+                    }
+                }) {
+                    Ok(i) | Err(i) => Some(cmp::min(i, match_ranges.len() - 1)),
+                }
+            };
+            if self.active_match_index != new_index {
+                self.active_match_index = new_index;
+                cx.notify();
+            }
+        }
+    }
+
     fn render_query_editor(&self, cx: &mut RenderContext<Self>) -> ElementBox {
         let theme = &self.settings.borrow().theme;
         let editor_container = if self.query_contains_error {
@@ -513,6 +605,29 @@ impl ProjectSearchView {
                     .aligned()
                     .boxed(),
             )
+            .with_children({
+                self.active_match_index.into_iter().flat_map(|match_ix| {
+                    [
+                        Flex::row()
+                            .with_child(self.render_nav_button("<", Direction::Prev, cx))
+                            .with_child(self.render_nav_button(">", Direction::Next, cx))
+                            .aligned()
+                            .boxed(),
+                        Label::new(
+                            format!(
+                                "{}/{}",
+                                match_ix + 1,
+                                self.model.read(cx).match_ranges.len()
+                            ),
+                            theme.search.match_index.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.search.match_index.container)
+                        .aligned()
+                        .boxed(),
+                    ]
+                })
+            })
             .contained()
             .with_style(theme.search.container)
             .constrained()
@@ -552,4 +667,28 @@ impl ProjectSearchView {
             SearchOption::Regex => self.regex,
         }
     }
+
+    fn render_nav_button(
+        &self,
+        icon: &str,
+        direction: Direction,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let theme = &self.settings.borrow().theme.search;
+        enum NavButton {}
+        MouseEventHandler::new::<NavButton, _, _>(direction as usize, cx, |state, _| {
+            let style = if state.hovered {
+                &theme.hovered_option_button
+            } else {
+                &theme.option_button
+            };
+            Label::new(icon.to_string(), style.text.clone())
+                .contained()
+                .with_style(style.container)
+                .boxed()
+        })
+        .on_click(move |cx| cx.dispatch_action(SelectMatch(direction)))
+        .with_cursor_style(CursorStyle::PointingHand)
+        .boxed()
+    }
 }

crates/search/src/search.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::MutableAppContext;
+use gpui::{action, MutableAppContext};
 
 mod buffer_search;
 mod project_search;
@@ -8,9 +8,18 @@ pub fn init(cx: &mut MutableAppContext) {
     project_search::init(cx);
 }
 
+action!(ToggleSearchOption, SearchOption);
+action!(SelectMatch, Direction);
+
 #[derive(Clone, Copy)]
 pub enum SearchOption {
     WholeWord,
     CaseSensitive,
     Regex,
 }
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Direction {
+    Prev,
+    Next,
+}