diff --git a/Cargo.lock b/Cargo.lock index dca182a38f3deb03bb6270a8615602a67be1eca5..10cfb997adf925df617bbc01d56c9951ee3265e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6469,6 +6469,7 @@ name = "search" version = "0.1.0" dependencies = [ "anyhow", + "bitflags 1.3.2", "client", "collections", "editor", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index e6421114ec800ec80face8b97f9e4ebaa608027b..2c406e3eb03d3cccd24dd92b3c3e1d5fa40d4ca8 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -60,6 +60,8 @@ "ignorePunctuation": true } ], + "n": "search::SelectNextMatch", + "shift-n": "search::SelectPrevMatch", "%": "vim::Matching", "f": [ "vim::PushOperator", @@ -103,6 +105,8 @@ "vim::SwitchMode", "Normal" ], + "*": "vim::MoveToNext", + "#": "vim::MoveToPrev", "0": "vim::StartOfLine", // When no number operator present, use start of line motion "1": [ "vim::Number", @@ -197,10 +201,11 @@ "p": "vim::Paste", "u": "editor::Undo", "ctrl-r": "editor::Redo", - "/": [ - "buffer_search::Deploy", + "/": "vim::Search", + "?": [ + "vim::Search", { - "focus": true + "backwards": true, } ], "ctrl-f": "vim::PageDown", @@ -238,7 +243,20 @@ "h": "editor::Hover", "t": "pane::ActivateNextItem", "shift-t": "pane::ActivatePrevItem", - "d": "editor::GoToDefinition" + "d": "editor::GoToDefinition", + "shift-d": "editor::GoToTypeDefinition", + "*": [ + "vim::MoveToNext", + { + "partialWord": true + } + ], + "#": [ + "vim::MoveToPrev", + { + "partialWord": true + } + ] } }, { @@ -310,8 +328,8 @@ "vim::SwitchMode", "Normal" ], - "> >": "editor::Indent", - "< <": "editor::Outdent" + ">": "editor::Indent", + "<": "editor::Outdent" } }, { @@ -336,5 +354,12 @@ "Normal" ] } + }, + { + "context": "BufferSearchBar", + "bindings": { + "enter": "vim::SearchSubmit", + "escape": "buffer_search::Dismiss" + } } ] diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 35c88486f79633033e7549feec28cc1287292a8b..8a4c04d3387784e0e2b5e4e7d745c690f72c02aa 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -298,12 +298,22 @@ impl AssistantPanel { } fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext) { + let mut propagate_action = true; if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { - if search_bar.update(cx, |search_bar, cx| search_bar.show(action.focus, true, cx)) { - return; - } + search_bar.update(cx, |search_bar, cx| { + if search_bar.show(cx) { + search_bar.search_suggested(cx); + if action.focus { + search_bar.select_query(cx); + cx.focus_self(); + } + propagate_action = false + } + }); + } + if propagate_action { + cx.propagate_action(); } - cx.propagate_action(); } fn handle_editor_cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { @@ -320,13 +330,13 @@ impl AssistantPanel { fn select_next_match(&mut self, _: &search::SelectNextMatch, cx: &mut ViewContext) { if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { - search_bar.update(cx, |bar, cx| bar.select_match(Direction::Next, cx)); + search_bar.update(cx, |bar, cx| bar.select_match(Direction::Next, 1, cx)); } } fn select_prev_match(&mut self, _: &search::SelectPrevMatch, cx: &mut ViewContext) { if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { - search_bar.update(cx, |bar, cx| bar.select_match(Direction::Prev, cx)); + search_bar.update(cx, |bar, cx| bar.select_match(Direction::Prev, 1, cx)); } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 379f43e2e5be3b91533296b9f3ca3e4ad9795ac7..cbf3d1a17371685babca6834afeab1fc59b9352d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -549,6 +549,7 @@ pub struct Editor { pending_rename: Option, searchable: bool, cursor_shape: CursorShape, + collapse_matches: bool, workspace: Option<(WeakViewHandle, i64)>, keymap_context_layers: BTreeMap, input_enabled: bool, @@ -1381,6 +1382,7 @@ impl Editor { searchable: true, override_text_style: None, cursor_shape: Default::default(), + collapse_matches: false, workspace: None, keymap_context_layers: Default::default(), input_enabled: true, @@ -1520,6 +1522,17 @@ impl Editor { cx.notify(); } + pub fn set_collapse_matches(&mut self, collapse_matches: bool) { + self.collapse_matches = collapse_matches; + } + + fn range_for_match(&self, range: &Range) -> Range { + if self.collapse_matches { + return range.start..range.start; + } + range.clone() + } + pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut ViewContext) { if self.display_map.read(cx).clip_at_line_ends != clip { self.display_map @@ -6261,6 +6274,7 @@ impl Editor { .to_offset(definition.target.buffer.read(cx)); if Some(&definition.target.buffer) == self.buffer.read(cx).as_singleton().as_ref() { + let range = self.range_for_match(&range); self.change_selections(Some(Autoscroll::fit()), cx, |s| { s.select_ranges([range]); }); @@ -6277,6 +6291,7 @@ impl Editor { // When selecting a definition in a different buffer, disable the nav history // to avoid creating a history entry at the previous cursor location. pane.update(cx, |pane, _| pane.disable_history()); + let range = target_editor.range_for_match(&range); target_editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.select_ranges([range]); }); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 9498be184480f4b740ff15a1b6c2c08210d0aad1..0ce41a97c96416af9bd4d45b1255c8f4b7ae0301 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -946,21 +946,27 @@ impl SearchableItem for Editor { cx: &mut ViewContext, ) { self.unfold_ranges([matches[index].clone()], false, true, cx); + let range = self.range_for_match(&matches[index]); self.change_selections(Some(Autoscroll::fit()), cx, |s| { - s.select_ranges([matches[index].clone()]) - }); + s.select_ranges([range]); + }) } fn select_matches(&mut self, matches: Vec, cx: &mut ViewContext) { self.unfold_ranges(matches.clone(), false, false, cx); - self.change_selections(None, cx, |s| s.select_ranges(matches)); + let mut ranges = Vec::new(); + for m in &matches { + ranges.push(self.range_for_match(&m)) + } + self.change_selections(None, cx, |s| s.select_ranges(ranges)); } fn match_index_for_direction( &mut self, matches: &Vec>, - mut current_index: usize, + current_index: usize, direction: Direction, + count: usize, cx: &mut ViewContext, ) -> usize { let buffer = self.buffer().read(cx).snapshot(cx); @@ -969,40 +975,39 @@ impl SearchableItem for Editor { } 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; - } else { - current_index -= 1; + + let mut count = count % matches.len(); + if count == 0 { + return current_index; + } + match direction { + Direction::Next => { + if matches[current_index] + .start + .cmp(¤t_index_position, &buffer) + .is_gt() + { + count = count - 1 } + + (current_index + count) % matches.len() } - } else if matches[current_index] - .end - .cmp(¤t_index_position, &buffer) - .is_lt() - { - if direction == Direction::Next { - current_index = 0; - } - } else if direction == Direction::Prev { - if current_index == 0 { - current_index = matches.len() - 1; - } else { - current_index -= 1; - } - } else if direction == Direction::Next { - if current_index == matches.len() - 1 { - current_index = 0 - } else { - current_index += 1; + Direction::Prev => { + if matches[current_index] + .end + .cmp(¤t_index_position, &buffer) + .is_lt() + { + count = count - 1; + } + + if current_index >= count { + current_index - count + } else { + matches.len() - (count - current_index) + } } - }; - current_index + } } fn find_matches( diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 7ef388f7c087638c1ee3f5c2002ab3d2c3371dc7..b37d0a46adb7c5a7142e5b1c4e9ee5516bee3072 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -9,6 +9,7 @@ path = "src/search.rs" doctest = false [dependencies] +bitflags = "1" collections = { path = "../collections" } editor = { path = "../editor" } gpui = { path = "../gpui" } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index f6466c85af3100b6160c6b978e269af59b85af85..7fade13a509dabd633b7da26601f90b770df3b6f 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -1,15 +1,17 @@ use crate::{ - SearchOption, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, + SearchOptions, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, ToggleWholeWord, }; use collections::HashMap; use editor::Editor; +use futures::channel::oneshot; use gpui::{ actions, elements::*, impl_actions, platform::{CursorStyle, MouseButton}, Action, AnyViewHandle, AppContext, Entity, Subscription, Task, View, ViewContext, ViewHandle, + WindowContext, }; use project::search::SearchQuery; use serde::Deserialize; @@ -44,20 +46,19 @@ pub fn init(cx: &mut AppContext) { 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::(SearchOption::CaseSensitive, cx); - add_toggle_option_action::(SearchOption::WholeWord, cx); - add_toggle_option_action::(SearchOption::Regex, cx); + add_toggle_option_action::(SearchOptions::CASE_SENSITIVE, cx); + add_toggle_option_action::(SearchOptions::WHOLE_WORD, cx); + add_toggle_option_action::(SearchOptions::REGEX, cx); } -fn add_toggle_option_action(option: SearchOption, cx: &mut AppContext) { +fn add_toggle_option_action(option: SearchOptions, cx: &mut AppContext) { cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { - if search_bar.update(cx, |search_bar, cx| search_bar.show(false, false, cx)) { - search_bar.update(cx, |search_bar, cx| { + search_bar.update(cx, |search_bar, cx| { + if search_bar.show(cx) { search_bar.toggle_search_option(option, cx); - }); - return; - } + } + }); } cx.propagate_action(); }); @@ -71,9 +72,8 @@ pub struct BufferSearchBar { searchable_items_with_matches: HashMap, Vec>>, pending_search: Option>, - case_sensitive: bool, - whole_word: bool, - regex: bool, + search_options: SearchOptions, + default_options: SearchOptions, query_contains_error: bool, dismissed: bool, } @@ -156,19 +156,19 @@ impl View for BufferSearchBar { .with_children(self.render_search_option( supported_options.case, "Case", - SearchOption::CaseSensitive, + SearchOptions::CASE_SENSITIVE, cx, )) .with_children(self.render_search_option( supported_options.word, "Word", - SearchOption::WholeWord, + SearchOptions::WHOLE_WORD, cx, )) .with_children(self.render_search_option( supported_options.regex, "Regex", - SearchOption::Regex, + SearchOptions::REGEX, cx, )) .contained() @@ -212,7 +212,7 @@ impl ToolbarItemView for BufferSearchBar { )); self.active_searchable_item = Some(searchable_item_handle); - self.update_matches(false, cx); + let _ = self.update_matches(cx); if !self.dismissed { return ToolbarItemLocation::Secondary; } @@ -253,9 +253,8 @@ impl BufferSearchBar { active_searchable_item_subscription: None, active_match_index: None, searchable_items_with_matches: Default::default(), - case_sensitive: false, - whole_word: false, - regex: false, + default_options: SearchOptions::NONE, + search_options: SearchOptions::NONE, pending_search: None, query_contains_error: false, dismissed: true, @@ -282,48 +281,86 @@ impl BufferSearchBar { cx.notify(); } - pub fn show(&mut self, focus: bool, suggest_query: bool, cx: &mut ViewContext) -> bool { - let searchable_item = if let Some(searchable_item) = &self.active_searchable_item { - SearchableItemHandle::boxed_clone(searchable_item.as_ref()) - } else { + pub fn show(&mut self, cx: &mut ViewContext) -> bool { + if self.active_searchable_item.is_none() { return false; - }; - - if suggest_query { - let text = searchable_item.query_suggestion(cx); - if !text.is_empty() { - self.set_query(&text, cx); - } } - - if focus { - let query_editor = self.query_editor.clone(); - query_editor.update(cx, |query_editor, cx| { - query_editor.select_all(&editor::SelectAll, cx); - }); - cx.focus_self(); - } - self.dismissed = false; cx.notify(); cx.emit(Event::UpdateLocation); true } - fn set_query(&mut self, query: &str, cx: &mut ViewContext) { + pub fn search_suggested(&mut self, cx: &mut ViewContext) { + let search = self + .query_suggestion(cx) + .map(|suggestion| self.search(&suggestion, Some(self.default_options), cx)); + + if let Some(search) = search { + cx.spawn(|this, mut cx| async move { + search.await?; + this.update(&mut cx, |this, cx| this.activate_current_match(cx)) + }) + .detach_and_log_err(cx); + } + } + + pub fn activate_current_match(&mut self, cx: &mut ViewContext) { + if let Some(match_ix) = self.active_match_index { + if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { + if let Some(matches) = self + .searchable_items_with_matches + .get(&active_searchable_item.downgrade()) + { + active_searchable_item.activate_match(match_ix, matches, cx) + } + } + } + } + + pub fn select_query(&mut self, cx: &mut ViewContext) { self.query_editor.update(cx, |query_editor, cx| { - query_editor.buffer().update(cx, |query_buffer, cx| { - let len = query_buffer.len(cx); - query_buffer.edit([(0..len, query)], None, cx); - }); + query_editor.select_all(&Default::default(), cx); }); } + pub fn query(&self, cx: &WindowContext) -> String { + self.query_editor.read(cx).text(cx) + } + + pub fn query_suggestion(&mut self, cx: &mut ViewContext) -> Option { + self.active_searchable_item + .as_ref() + .map(|searchable_item| searchable_item.query_suggestion(cx)) + } + + pub fn search( + &mut self, + query: &str, + options: Option, + cx: &mut ViewContext, + ) -> oneshot::Receiver<()> { + let options = options.unwrap_or(self.default_options); + if query != self.query_editor.read(cx).text(cx) || self.search_options != options { + self.query_editor.update(cx, |query_editor, cx| { + query_editor.buffer().update(cx, |query_buffer, cx| { + let len = query_buffer.len(cx); + query_buffer.edit([(0..len, query)], None, cx); + }); + }); + self.search_options = options; + self.query_contains_error = false; + self.clear_matches(cx); + cx.notify(); + } + self.update_matches(cx) + } + fn render_search_option( &self, option_supported: bool, icon: &'static str, - option: SearchOption, + option: SearchOptions, cx: &mut ViewContext, ) -> Option> { if !option_supported { @@ -331,9 +368,9 @@ impl BufferSearchBar { } let tooltip_style = theme::current(cx).tooltip.clone(); - let is_active = self.is_search_option_enabled(option); + let is_active = self.search_options.contains(option); Some( - MouseEventHandler::::new(option as usize, cx, |state, cx| { + MouseEventHandler::::new(option.bits as usize, cx, |state, cx| { let theme = theme::current(cx); let style = theme .search @@ -349,7 +386,7 @@ impl BufferSearchBar { }) .with_cursor_style(CursorStyle::PointingHand) .with_tooltip::( - option as usize, + option.bits as usize, format!("Toggle {}", option.label()), Some(option.to_toggle_action()), tooltip_style, @@ -471,12 +508,23 @@ impl BufferSearchBar { } fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext) { + let mut propagate_action = true; if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { - if search_bar.update(cx, |search_bar, cx| search_bar.show(action.focus, true, cx)) { - return; - } + search_bar.update(cx, |search_bar, cx| { + if search_bar.show(cx) { + search_bar.search_suggested(cx); + if action.focus { + search_bar.select_query(cx); + cx.focus_self(); + } + propagate_action = false; + } + }); + } + + if propagate_action { + cx.propagate_action(); } - cx.propagate_action(); } fn handle_editor_cancel(pane: &mut Pane, _: &editor::Cancel, cx: &mut ViewContext) { @@ -489,37 +537,34 @@ impl BufferSearchBar { cx.propagate_action(); } - fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext) { + pub fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext) { if let Some(active_editor) = self.active_searchable_item.as_ref() { cx.focus(active_editor.as_any()); } } - fn is_search_option_enabled(&self, search_option: SearchOption) -> bool { - match search_option { - SearchOption::WholeWord => self.whole_word, - SearchOption::CaseSensitive => self.case_sensitive, - SearchOption::Regex => self.regex, - } + fn toggle_search_option(&mut self, search_option: SearchOptions, cx: &mut ViewContext) { + self.search_options.toggle(search_option); + self.default_options = self.search_options; + let _ = self.update_matches(cx); + cx.notify(); } - fn toggle_search_option(&mut self, search_option: SearchOption, cx: &mut ViewContext) { - let value = match search_option { - SearchOption::WholeWord => &mut self.whole_word, - SearchOption::CaseSensitive => &mut self.case_sensitive, - SearchOption::Regex => &mut self.regex, - }; - *value = !*value; - self.update_matches(false, cx); + pub fn set_search_options( + &mut self, + search_options: SearchOptions, + cx: &mut ViewContext, + ) { + self.search_options = search_options; cx.notify(); } fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext) { - self.select_match(Direction::Next, cx); + self.select_match(Direction::Next, 1, cx); } fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext) { - self.select_match(Direction::Prev, cx); + self.select_match(Direction::Prev, 1, cx); } fn select_all_matches(&mut self, _: &SelectAllMatches, cx: &mut ViewContext) { @@ -536,15 +581,15 @@ impl BufferSearchBar { } } - pub fn select_match(&mut self, direction: Direction, cx: &mut ViewContext) { + pub fn select_match(&mut self, direction: Direction, count: usize, cx: &mut ViewContext) { if let Some(index) = self.active_match_index { if let Some(searchable_item) = self.active_searchable_item.as_ref() { if let Some(matches) = self .searchable_items_with_matches .get(&searchable_item.downgrade()) { - let new_match_index = - searchable_item.match_index_for_direction(matches, index, direction, cx); + let new_match_index = searchable_item + .match_index_for_direction(matches, index, direction, count, cx); searchable_item.update_matches(matches, cx); searchable_item.activate_match(new_match_index, matches, cx); } @@ -588,17 +633,23 @@ impl BufferSearchBar { event: &editor::Event, cx: &mut ViewContext, ) { - if let editor::Event::BufferEdited { .. } = event { + if let editor::Event::Edited { .. } = event { self.query_contains_error = false; self.clear_matches(cx); - self.update_matches(true, cx); - cx.notify(); + let search = self.update_matches(cx); + cx.spawn(|this, mut cx| async move { + search.await?; + this.update(&mut cx, |this, cx| this.activate_current_match(cx)) + }) + .detach_and_log_err(cx); } } fn on_active_searchable_item_event(&mut self, event: SearchEvent, cx: &mut ViewContext) { match event { - SearchEvent::MatchesInvalidated => self.update_matches(false, cx), + SearchEvent::MatchesInvalidated => { + let _ = self.update_matches(cx); + } SearchEvent::ActiveMatchChanged => self.update_match_index(cx), } } @@ -621,19 +672,21 @@ impl BufferSearchBar { .extend(active_item_matches); } - fn update_matches(&mut self, select_closest_match: bool, cx: &mut ViewContext) { + fn update_matches(&mut self, cx: &mut ViewContext) -> oneshot::Receiver<()> { + let (done_tx, done_rx) = oneshot::channel(); let query = self.query_editor.read(cx).text(cx); self.pending_search.take(); if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { if query.is_empty() { self.active_match_index.take(); active_searchable_item.clear_matches(cx); + let _ = done_tx.send(()); } else { - let query = if self.regex { + let query = if self.search_options.contains(SearchOptions::REGEX) { match SearchQuery::regex( query, - self.whole_word, - self.case_sensitive, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), Vec::new(), Vec::new(), ) { @@ -641,14 +694,14 @@ impl BufferSearchBar { Err(_) => { self.query_contains_error = true; cx.notify(); - return; + return done_rx; } } } else { SearchQuery::text( query, - self.whole_word, - self.case_sensitive, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), Vec::new(), Vec::new(), ) @@ -673,12 +726,7 @@ impl BufferSearchBar { .get(&active_searchable_item.downgrade()) .unwrap(); active_searchable_item.update_matches(matches, cx); - if select_closest_match { - if let Some(match_ix) = this.active_match_index { - active_searchable_item - .activate_match(match_ix, matches, cx); - } - } + let _ = done_tx.send(()); } cx.notify(); } @@ -687,6 +735,7 @@ impl BufferSearchBar { })); } } + done_rx } fn update_match_index(&mut self, cx: &mut ViewContext) { @@ -714,8 +763,7 @@ mod tests { use language::Buffer; use unindent::Unindent as _; - #[gpui::test] - async fn test_search_simple(cx: &mut TestAppContext) { + fn init_test(cx: &mut TestAppContext) -> (ViewHandle, ViewHandle) { crate::project_search::tests::init_test(cx); let buffer = cx.add_model(|cx| { @@ -738,16 +786,23 @@ mod tests { 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.show(cx); search_bar }); + (editor, search_bar) + } + + #[gpui::test] + async fn test_search_simple(cx: &mut TestAppContext) { + let (editor, search_bar) = init_test(cx); + // Search for a string that appears with different casing. // By default, search is case-insensitive. - search_bar.update(cx, |search_bar, cx| { - search_bar.set_query("us", cx); - }); - editor.next_notification(cx).await; + search_bar + .update(cx, |search_bar, cx| search_bar.search("us", None, cx)) + .await + .unwrap(); editor.update(cx, |editor, cx| { assert_eq!( editor.all_background_highlights(cx), @@ -766,7 +821,7 @@ mod tests { // Switch to a case sensitive search. search_bar.update(cx, |search_bar, cx| { - search_bar.toggle_search_option(SearchOption::CaseSensitive, cx); + search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx); }); editor.next_notification(cx).await; editor.update(cx, |editor, cx| { @@ -781,10 +836,10 @@ mod tests { // Search for a string that appears both as a whole word and // within other words. By default, all results are found. - search_bar.update(cx, |search_bar, cx| { - search_bar.set_query("or", cx); - }); - editor.next_notification(cx).await; + search_bar + .update(cx, |search_bar, cx| search_bar.search("or", None, cx)) + .await + .unwrap(); editor.update(cx, |editor, cx| { assert_eq!( editor.all_background_highlights(cx), @@ -823,7 +878,7 @@ mod tests { // Switch to a whole word search. search_bar.update(cx, |search_bar, cx| { - search_bar.toggle_search_option(SearchOption::WholeWord, cx); + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx); }); editor.next_notification(cx).await; editor.update(cx, |editor, cx| { @@ -1025,6 +1080,65 @@ mod tests { }); } + #[gpui::test] + async fn test_search_option_handling(cx: &mut TestAppContext) { + let (editor, search_bar) = init_test(cx); + + // show with options should make current search case sensitive + search_bar + .update(cx, |search_bar, cx| { + search_bar.show(cx); + search_bar.search("us", Some(SearchOptions::CASE_SENSITIVE), cx) + }) + .await + .unwrap(); + editor.update(cx, |editor, cx| { + assert_eq!( + editor.all_background_highlights(cx), + &[( + DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45), + Color::red(), + )] + ); + }); + + // search_suggested should restore default options + search_bar.update(cx, |search_bar, cx| { + search_bar.search_suggested(cx); + assert_eq!(search_bar.search_options, SearchOptions::NONE) + }); + + // toggling a search option should update the defaults + search_bar + .update(cx, |search_bar, cx| { + search_bar.search("regex", Some(SearchOptions::CASE_SENSITIVE), cx) + }) + .await + .unwrap(); + search_bar.update(cx, |search_bar, cx| { + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx) + }); + editor.next_notification(cx).await; + editor.update(cx, |editor, cx| { + assert_eq!( + editor.all_background_highlights(cx), + &[( + DisplayPoint::new(0, 35)..DisplayPoint::new(0, 40), + Color::red(), + ),] + ); + }); + + // defaults should still include whole word + search_bar.update(cx, |search_bar, cx| { + search_bar.search_suggested(cx); + assert_eq!( + search_bar.search_options, + SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD + ) + }); + } + #[gpui::test] async fn test_search_select_all_matches(cx: &mut TestAppContext) { crate::project_search::tests::init_test(cx); @@ -1052,15 +1166,18 @@ mod tests { 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.show(cx); search_bar }); + search_bar + .update(cx, |search_bar, cx| search_bar.search("a", None, cx)) + .await + .unwrap(); search_bar.update(cx, |search_bar, cx| { - search_bar.set_query("a", cx); + search_bar.activate_current_match(cx); }); - editor.next_notification(cx).await; let initial_selections = editor.update(cx, |editor, cx| { let initial_selections = editor.selections.display_ranges(cx); assert_eq!( diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index ebd504d02c2334aa6876a478937718cb1aa4d496..abebb9a48f1a853097248d796550f00bb3e6cd70 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1,5 +1,5 @@ use crate::{ - SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, + SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, ToggleWholeWord, }; use anyhow::Result; @@ -51,12 +51,12 @@ pub fn init(cx: &mut AppContext) { cx.add_action(ProjectSearchBar::select_prev_match); cx.capture_action(ProjectSearchBar::tab); cx.capture_action(ProjectSearchBar::tab_previous); - add_toggle_option_action::(SearchOption::CaseSensitive, cx); - add_toggle_option_action::(SearchOption::WholeWord, cx); - add_toggle_option_action::(SearchOption::Regex, cx); + add_toggle_option_action::(SearchOptions::CASE_SENSITIVE, cx); + add_toggle_option_action::(SearchOptions::WHOLE_WORD, cx); + add_toggle_option_action::(SearchOptions::REGEX, cx); } -fn add_toggle_option_action(option: SearchOption, cx: &mut AppContext) { +fn add_toggle_option_action(option: SearchOptions, cx: &mut AppContext) { cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { if search_bar.update(cx, |search_bar, cx| { @@ -89,9 +89,7 @@ pub struct ProjectSearchView { model: ModelHandle, query_editor: ViewHandle, results_editor: ViewHandle, - case_sensitive: bool, - whole_word: bool, - regex: bool, + search_options: SearchOptions, panels_with_errors: HashSet, active_match_index: Option, search_id: usize, @@ -408,9 +406,7 @@ impl ProjectSearchView { let project; let excerpts; let mut query_text = String::new(); - let mut regex = false; - let mut case_sensitive = false; - let mut whole_word = false; + let mut options = SearchOptions::NONE; { let model = model.read(cx); @@ -418,9 +414,7 @@ impl ProjectSearchView { excerpts = model.excerpts.clone(); if let Some(active_query) = model.active_query.as_ref() { query_text = active_query.as_str().to_string(); - regex = active_query.is_regex(); - case_sensitive = active_query.case_sensitive(); - whole_word = active_query.whole_word(); + options = SearchOptions::from_query(active_query); } } cx.observe(&model, |this, _, cx| this.model_changed(cx)) @@ -496,9 +490,7 @@ impl ProjectSearchView { model, query_editor, results_editor, - case_sensitive, - whole_word, - regex, + search_options: options, panels_with_errors: HashSet::new(), active_match_index: None, query_editor_was_focused: false, @@ -594,11 +586,11 @@ impl ProjectSearchView { return None; } }; - if self.regex { + if self.search_options.contains(SearchOptions::REGEX) { match SearchQuery::regex( text, - self.whole_word, - self.case_sensitive, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), included_files, excluded_files, ) { @@ -615,8 +607,8 @@ impl ProjectSearchView { } else { Some(SearchQuery::text( text, - self.whole_word, - self.case_sensitive, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), included_files, excluded_files, )) @@ -635,7 +627,7 @@ impl ProjectSearchView { if let Some(index) = self.active_match_index { let match_ranges = self.model.read(cx).match_ranges.clone(); let new_index = self.results_editor.update(cx, |editor, cx| { - editor.match_index_for_direction(&match_ranges, index, direction, cx) + editor.match_index_for_direction(&match_ranges, index, direction, 1, cx) }); let range_to_select = match_ranges[new_index].clone(); @@ -676,7 +668,6 @@ impl ProjectSearchView { self.active_match_index = None; } else { self.active_match_index = Some(0); - self.select_match(Direction::Next, cx); self.update_match_index(cx); let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id); let is_new_search = self.search_id != prev_search_id; @@ -768,9 +759,7 @@ impl ProjectSearchBar { search_view.query_editor.update(cx, |editor, cx| { editor.set_text(old_query.as_str(), cx); }); - search_view.regex = old_query.is_regex(); - search_view.whole_word = old_query.whole_word(); - search_view.case_sensitive = old_query.case_sensitive(); + search_view.search_options = SearchOptions::from_query(&old_query); } } new_query @@ -858,15 +847,10 @@ impl ProjectSearchBar { }); } - fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext) -> bool { + fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext) -> bool { if let Some(search_view) = self.active_project_search.as_ref() { search_view.update(cx, |search_view, cx| { - let value = match option { - SearchOption::WholeWord => &mut search_view.whole_word, - SearchOption::CaseSensitive => &mut search_view.case_sensitive, - SearchOption::Regex => &mut search_view.regex, - }; - *value = !*value; + search_view.search_options.toggle(option); search_view.search(cx); }); cx.notify(); @@ -923,12 +907,12 @@ impl ProjectSearchBar { fn render_option_button( &self, icon: &'static str, - option: SearchOption, + option: SearchOptions, cx: &mut ViewContext, ) -> AnyElement { let tooltip_style = theme::current(cx).tooltip.clone(); let is_active = self.is_option_enabled(option, cx); - MouseEventHandler::::new(option as usize, cx, |state, cx| { + MouseEventHandler::::new(option.bits as usize, cx, |state, cx| { let theme = theme::current(cx); let style = theme .search @@ -944,7 +928,7 @@ impl ProjectSearchBar { }) .with_cursor_style(CursorStyle::PointingHand) .with_tooltip::( - option as usize, + option.bits as usize, format!("Toggle {}", option.label()), Some(option.to_toggle_action()), tooltip_style, @@ -953,14 +937,9 @@ impl ProjectSearchBar { .into_any() } - fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool { + fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool { if let Some(search) = self.active_project_search.as_ref() { - let search = search.read(cx); - match option { - SearchOption::WholeWord => search.whole_word, - SearchOption::CaseSensitive => search.case_sensitive, - SearchOption::Regex => search.regex, - } + search.read(cx).search_options.contains(option) } else { false } @@ -1051,17 +1030,17 @@ impl View for ProjectSearchBar { Flex::row() .with_child(self.render_option_button( "Case", - SearchOption::CaseSensitive, + SearchOptions::CASE_SENSITIVE, cx, )) .with_child(self.render_option_button( "Word", - SearchOption::WholeWord, + SearchOptions::WHOLE_WORD, cx, )) .with_child(self.render_option_button( "Regex", - SearchOption::Regex, + SearchOptions::REGEX, cx, )) .contained() diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 7080b4c07e26496fb0c5f2059fd058a031163cb0..1303f81e2ca20a0416bbb0cdf680471779d2aee1 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -1,5 +1,7 @@ +use bitflags::bitflags; pub use buffer_search::BufferSearchBar; use gpui::{actions, Action, AppContext}; +use project::search::SearchQuery; pub use project_search::{ProjectSearchBar, ProjectSearchView}; pub mod buffer_search; @@ -22,27 +24,40 @@ actions!( ] ); -#[derive(Clone, Copy, PartialEq)] -pub enum SearchOption { - WholeWord, - CaseSensitive, - Regex, +bitflags! { + #[derive(Default)] + pub struct SearchOptions: u8 { + const NONE = 0b000; + const WHOLE_WORD = 0b001; + const CASE_SENSITIVE = 0b010; + const REGEX = 0b100; + } } -impl SearchOption { +impl SearchOptions { pub fn label(&self) -> &'static str { - match self { - SearchOption::WholeWord => "Match Whole Word", - SearchOption::CaseSensitive => "Match Case", - SearchOption::Regex => "Use Regular Expression", + match *self { + SearchOptions::WHOLE_WORD => "Match Whole Word", + SearchOptions::CASE_SENSITIVE => "Match Case", + SearchOptions::REGEX => "Use Regular Expression", + _ => panic!("{:?} is not a named SearchOption", self), } } pub fn to_toggle_action(&self) -> Box { - match self { - SearchOption::WholeWord => Box::new(ToggleWholeWord), - SearchOption::CaseSensitive => Box::new(ToggleCaseSensitive), - SearchOption::Regex => Box::new(ToggleRegex), + match *self { + SearchOptions::WHOLE_WORD => Box::new(ToggleWholeWord), + SearchOptions::CASE_SENSITIVE => Box::new(ToggleCaseSensitive), + SearchOptions::REGEX => Box::new(ToggleRegex), + _ => panic!("{:?} is not a named SearchOption", self), } } + + pub fn from_query(query: &SearchQuery) -> SearchOptions { + let mut options = SearchOptions::NONE; + options.set(SearchOptions::WHOLE_WORD, query.whole_word()); + options.set(SearchOptions::CASE_SENSITIVE, query.case_sensitive()); + options.set(SearchOptions::REGEX, query.is_regex()); + options + } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 1227afbb85e4f8d78b5dab54d9b9f5410f588863..8dcaa5008ef60f1e184ff2b251569d20a5b68943 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -2,6 +2,7 @@ mod case; mod change; mod delete; mod scroll; +mod search; mod substitute; mod yank; @@ -57,6 +58,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(insert_line_above); cx.add_action(insert_line_below); cx.add_action(change_case); + search::init(cx); cx.add_action(|_: &mut Workspace, _: &Substitute, cx| { Vim::update(cx, |vim, cx| { let times = vim.pop_number_operator(cx); diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs new file mode 100644 index 0000000000000000000000000000000000000000..cae64a40a6b0cdb290014539b7c10ead6a7a0b82 --- /dev/null +++ b/crates/vim/src/normal/search.rs @@ -0,0 +1,285 @@ +use gpui::{actions, impl_actions, AppContext, ViewContext}; +use search::{buffer_search, BufferSearchBar, SearchOptions}; +use serde_derive::Deserialize; +use workspace::{searchable::Direction, Pane, Workspace}; + +use crate::{state::SearchState, Vim}; + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MoveToNext { + #[serde(default)] + partial_word: bool, +} + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MoveToPrev { + #[serde(default)] + partial_word: bool, +} + +#[derive(Clone, Deserialize, PartialEq)] +pub(crate) struct Search { + #[serde(default)] + backwards: bool, +} + +impl_actions!(vim, [MoveToNext, MoveToPrev, Search]); +actions!(vim, [SearchSubmit]); + +pub(crate) fn init(cx: &mut AppContext) { + cx.add_action(move_to_next); + cx.add_action(move_to_prev); + cx.add_action(search); + cx.add_action(search_submit); + cx.add_action(search_deploy); +} + +fn move_to_next(workspace: &mut Workspace, action: &MoveToNext, cx: &mut ViewContext) { + move_to_internal(workspace, Direction::Next, !action.partial_word, cx) +} + +fn move_to_prev(workspace: &mut Workspace, action: &MoveToPrev, cx: &mut ViewContext) { + move_to_internal(workspace, Direction::Prev, !action.partial_word, cx) +} + +fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext) { + let pane = workspace.active_pane().clone(); + let direction = if action.backwards { + Direction::Prev + } else { + Direction::Next + }; + Vim::update(cx, |vim, cx| { + let count = vim.pop_number_operator(cx).unwrap_or(1); + pane.update(cx, |pane, cx| { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + search_bar.update(cx, |search_bar, cx| { + if !search_bar.show(cx) { + return; + } + let query = search_bar.query(cx); + + search_bar.select_query(cx); + cx.focus_self(); + + if query.is_empty() { + search_bar.set_search_options( + SearchOptions::CASE_SENSITIVE | SearchOptions::REGEX, + cx, + ); + } + vim.state.search = SearchState { + direction, + count, + initial_query: query, + }; + }); + } + }) + }) +} + +// hook into the existing to clear out any vim search state on cmd+f or edit -> find. +fn search_deploy(_: &mut Pane, _: &buffer_search::Deploy, cx: &mut ViewContext) { + Vim::update(cx, |vim, _| vim.state.search = Default::default()); + cx.propagate_action(); +} + +fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + let pane = workspace.active_pane().clone(); + pane.update(cx, |pane, cx| { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + search_bar.update(cx, |search_bar, cx| { + let mut state = &mut vim.state.search; + let mut count = state.count; + + // in the case that the query has changed, the search bar + // will have selected the next match already. + if (search_bar.query(cx) != state.initial_query) + && state.direction == Direction::Next + { + count = count.saturating_sub(1) + } + search_bar.select_match(state.direction, count, cx); + state.count = 1; + search_bar.focus_editor(&Default::default(), cx); + }); + } + }); + }) +} + +pub fn move_to_internal( + workspace: &mut Workspace, + direction: Direction, + whole_word: bool, + cx: &mut ViewContext, +) { + Vim::update(cx, |vim, cx| { + let pane = workspace.active_pane().clone(); + let count = vim.pop_number_operator(cx).unwrap_or(1); + pane.update(cx, |pane, cx| { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + let search = search_bar.update(cx, |search_bar, cx| { + let mut options = SearchOptions::CASE_SENSITIVE; + options.set(SearchOptions::WHOLE_WORD, whole_word); + if search_bar.show(cx) { + search_bar + .query_suggestion(cx) + .map(|query| search_bar.search(&query, Some(options), cx)) + } else { + None + } + }); + + if let Some(search) = search { + let search_bar = search_bar.downgrade(); + cx.spawn(|_, mut cx| async move { + search.await?; + search_bar.update(&mut cx, |search_bar, cx| { + search_bar.select_match(direction, count, cx) + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + } + }); + vim.clear_operator(cx); + }); +} + +#[cfg(test)] +mod test { + use std::sync::Arc; + + use editor::DisplayPoint; + use search::BufferSearchBar; + + use crate::{state::Mode, test::VimTestContext}; + + #[gpui::test] + async fn test_move_to_next( + cx: &mut gpui::TestAppContext, + deterministic: Arc, + ) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes(["*"]); + deterministic.run_until_parked(); + cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); + + cx.simulate_keystrokes(["*"]); + deterministic.run_until_parked(); + cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes(["#"]); + deterministic.run_until_parked(); + cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); + + cx.simulate_keystrokes(["#"]); + deterministic.run_until_parked(); + cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes(["2", "*"]); + deterministic.run_until_parked(); + cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes(["g", "*"]); + deterministic.run_until_parked(); + cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal); + + cx.simulate_keystrokes(["n"]); + cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); + + cx.simulate_keystrokes(["g", "#"]); + deterministic.run_until_parked(); + cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal); + } + + #[gpui::test] + async fn test_search( + cx: &mut gpui::TestAppContext, + deterministic: Arc, + ) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal); + cx.simulate_keystrokes(["/", "c", "c"]); + + let search_bar = cx.workspace(|workspace, cx| { + workspace + .active_pane() + .read(cx) + .toolbar() + .read(cx) + .item_of_type::() + .expect("Buffer search bar should be deployed") + }); + + search_bar.read_with(cx.cx, |bar, cx| { + assert_eq!(bar.query_editor.read(cx).text(cx), "cc"); + }); + + deterministic.run_until_parked(); + + cx.update_editor(|editor, cx| { + let highlights = editor.all_background_highlights(cx); + assert_eq!(3, highlights.len()); + assert_eq!( + DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2), + highlights[0].0 + ) + }); + + cx.simulate_keystrokes(["enter"]); + cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal); + + // n to go to next/N to go to previous + cx.simulate_keystrokes(["n"]); + cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal); + cx.simulate_keystrokes(["shift-n"]); + cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal); + + // ? to go to previous + cx.simulate_keystrokes(["?", "enter"]); + deterministic.run_until_parked(); + cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal); + cx.simulate_keystrokes(["?", "enter"]); + deterministic.run_until_parked(); + cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal); + + // / to go to next + cx.simulate_keystrokes(["/", "enter"]); + deterministic.run_until_parked(); + cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal); + + // ?{search} to search backwards + cx.simulate_keystrokes(["?", "b", "enter"]); + deterministic.run_until_parked(); + cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal); + + // works with counts + cx.simulate_keystrokes(["4", "/", "c"]); + deterministic.run_until_parked(); + cx.simulate_keystrokes(["enter"]); + cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal); + + // check that searching resumes from cursor, not previous match + cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal); + cx.simulate_keystrokes(["/", "d"]); + deterministic.run_until_parked(); + cx.simulate_keystrokes(["enter"]); + cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal); + cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx)); + cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal); + cx.simulate_keystrokes(["/", "b"]); + deterministic.run_until_parked(); + cx.simulate_keystrokes(["enter"]); + cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal); + } +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index e1a06fce59d6ff5f9529552e422fbd0643418de1..6434b710b2a37f75bc0687069bec9b69431b6405 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,6 +1,7 @@ use gpui::keymap_matcher::KeymapContext; use language::CursorShape; use serde::{Deserialize, Serialize}; +use workspace::searchable::Direction; #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] pub enum Mode { @@ -38,6 +39,23 @@ pub enum Operator { pub struct VimState { pub mode: Mode, pub operator_stack: Vec, + pub search: SearchState, +} + +pub struct SearchState { + pub direction: Direction, + pub count: usize, + pub initial_query: String, +} + +impl Default for SearchState { + fn default() -> Self { + Self { + direction: Direction::Next, + count: 1, + initial_query: "".to_string(), + } + } } impl VimState { diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index a6efbd4da1de34447d7165040bd6da32a79faa9c..8ed649e61bef510d8cf9fa76eb8d2edf3eca4e69 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -5,6 +5,7 @@ mod vim_binding_test_context; mod vim_test_context; use command_palette::CommandPalette; +use editor::DisplayPoint; pub use neovim_backed_binding_test_context::*; pub use neovim_backed_test_context::*; pub use vim_binding_test_context::*; @@ -96,7 +97,7 @@ async fn test_buffer_search(cx: &mut gpui::TestAppContext) { }); search_bar.read_with(cx.cx, |bar, cx| { - assert_eq!(bar.query_editor.read(cx).text(cx), "jumps"); + assert_eq!(bar.query_editor.read(cx).text(cx), ""); }) } @@ -137,7 +138,7 @@ async fn test_indent_outdent(cx: &mut gpui::TestAppContext) { cx.assert_editor_state("aa\nbˇb\ncc"); // works in visuial mode - cx.simulate_keystrokes(["shift-v", "down", ">", ">"]); + cx.simulate_keystrokes(["shift-v", "down", ">"]); cx.assert_editor_state("aa\n b«b\n cˇ»c"); } @@ -153,3 +154,44 @@ async fn test_escape_command_palette(cx: &mut gpui::TestAppContext) { assert!(!cx.workspace(|workspace, _| workspace.modal::().is_some())); cx.assert_state("aˇbc\n", Mode::Insert); } + +#[gpui::test] +async fn test_selection_on_search(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state(indoc! {"aa\nbˇb\ncc\ncc\ncc\n"}, Mode::Normal); + cx.simulate_keystrokes(["/", "c", "c"]); + + let search_bar = cx.workspace(|workspace, cx| { + workspace + .active_pane() + .read(cx) + .toolbar() + .read(cx) + .item_of_type::() + .expect("Buffer search bar should be deployed") + }); + + search_bar.read_with(cx.cx, |bar, cx| { + assert_eq!(bar.query_editor.read(cx).text(cx), "cc"); + }); + + // wait for the query editor change event to fire. + search_bar.next_notification(&cx).await; + + cx.update_editor(|editor, cx| { + let highlights = editor.all_background_highlights(cx); + assert_eq!(3, highlights.len()); + assert_eq!( + DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2), + highlights[0].0 + ) + }); + cx.simulate_keystrokes(["enter"]); + + cx.assert_state(indoc! {"aa\nbb\nˇcc\ncc\ncc\n"}, Mode::Normal); + cx.simulate_keystrokes(["n"]); + cx.assert_state(indoc! {"aa\nbb\ncc\nˇcc\ncc\n"}, Mode::Normal); + cx.simulate_keystrokes(["shift-n"]); + cx.assert_state(indoc! {"aa\nbb\nˇcc\ncc\ncc\n"}, Mode::Normal); +} diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index f9ba577231f5de6317f1194e14a6ad4cf139e06b..56ca654644c5277fc8eca5e8fcd769815a6a356c 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -90,6 +90,7 @@ impl<'a> VimTestContext<'a> { self.cx.set_state(text) } + #[track_caller] pub fn assert_state(&mut self, text: &str, mode: Mode) { self.assert_editor_state(text); assert_eq!(self.mode(), mode, "{}", self.assertion_context()); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 2bcc2254eed1d75dd88ec446428c306cd1d677ba..ada8f2c1defdd5014d673239b9c3f57d5e8124d9 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -295,11 +295,15 @@ impl Vim { if self.enabled && editor.mode() == EditorMode::Full { editor.set_cursor_shape(cursor_shape, cx); editor.set_clip_at_line_ends(state.clip_at_line_end(), cx); + editor.set_collapse_matches(true); editor.set_input_enabled(!state.vim_controlled()); editor.selections.line_mode = matches!(state.mode, Mode::Visual { line: true }); let context_layer = state.keymap_context_layer(); editor.set_keymap_context_layer::(context_layer, cx); } else { + // Note: set_collapse_matches is not in unhook_vim_settings, as that method is called on blur, + // but we need collapse_matches to persist when the search bar is focused. + editor.set_collapse_matches(false); Self::unhook_vim_settings(editor, cx); } }); diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 3a3ba02e06653361e90f9136c70b4047ac82d031..ae95838a742bb623a9376947e905d6054a82fff4 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -55,26 +55,21 @@ pub trait SearchableItem: Item { fn match_index_for_direction( &mut self, matches: &Vec, - mut current_index: usize, + current_index: usize, direction: Direction, + count: usize, _: &mut ViewContext, ) -> usize { match direction { Direction::Prev => { - if current_index == 0 { - matches.len() - 1 - } else { - current_index - 1 - } - } - Direction::Next => { - current_index += 1; - if current_index == matches.len() { - 0 + let count = count % matches.len(); + if current_index >= count { + current_index - count } else { - current_index + matches.len() - (count - current_index) } } + Direction::Next => (current_index + count) % matches.len(), } } fn find_matches( @@ -113,6 +108,7 @@ pub trait SearchableItemHandle: ItemHandle { matches: &Vec>, current_index: usize, direction: Direction, + count: usize, cx: &mut WindowContext, ) -> usize; fn find_matches( @@ -183,11 +179,12 @@ impl SearchableItemHandle for ViewHandle { matches: &Vec>, current_index: usize, direction: Direction, + count: usize, cx: &mut WindowContext, ) -> usize { let matches = downcast_matches(matches); self.update(cx, |this, cx| { - this.match_index_for_direction(&matches, current_index, direction, cx) + this.match_index_for_direction(&matches, current_index, direction, count, cx) }) } fn find_matches(