From 64d22925c2feeb8db92a1f17f540c009381c249c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 27 Feb 2022 14:18:04 -0700 Subject: [PATCH] Implement navigation between project search matches --- 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(-) diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 7a5e3d1514..c15f81ecf5 100644 --- a/crates/search/src/buffer_search.rs +++ b/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([ diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 4e61df1368..cf6a5653c8 100644 --- a/crates/search/src/project_search.rs +++ b/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, excerpts: ModelHandle, pending_search: Option>>, - highlighted_ranges: Vec>, + match_ranges: Vec>, active_query: Option, } @@ -54,6 +65,7 @@ struct ProjectSearchView { whole_word: bool, regex: bool, query_contains_error: bool, + active_match_index: Option, settings: watch::Receiver, } @@ -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::>(); 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) -> 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) { - 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) { + 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) { 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.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) { - 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::(highlighted_ranges, theme.match_background, cx); + editor.highlight_ranges::(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) { + 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) -> 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, + ) -> ElementBox { + let theme = &self.settings.borrow().theme.search; + enum NavButton {} + MouseEventHandler::new::(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() + } } diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 05049c328c..6e8e7e00f3 100644 --- a/crates/search/src/search.rs +++ b/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, +}