From c7c19609b345f112f86268294f86e4a5c30423d5 Mon Sep 17 00:00:00 2001 From: kshokhin Date: Wed, 5 Jun 2024 22:42:51 +0300 Subject: [PATCH] Search in selections (#10831) Release Notes: - Adding [#8617 ](https://github.com/zed-industries/zed/issues/8617) --------- Co-authored-by: Conrad Irwin --- assets/icons/search_selection.svg | 1 + assets/keymaps/default-linux.json | 4 +- assets/keymaps/default-macos.json | 10 +- crates/editor/src/editor.rs | 23 + crates/editor/src/items.rs | 125 +++--- crates/editor/src/selections_collection.rs | 7 + crates/language_tools/src/lsp_log.rs | 1 + crates/multi_buffer/src/multi_buffer.rs | 467 +++++++++++++++++++++ crates/search/src/buffer_search.rs | 229 +++++++++- crates/search/src/search.rs | 1 + crates/terminal_view/src/terminal_view.rs | 1 + crates/ui/src/components/icon.rs | 2 + crates/workspace/src/searchable.rs | 16 +- 13 files changed, 818 insertions(+), 69 deletions(-) create mode 100644 assets/icons/search_selection.svg diff --git a/assets/icons/search_selection.svg b/assets/icons/search_selection.svg new file mode 100644 index 0000000000..b970db1430 --- /dev/null +++ b/assets/icons/search_selection.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 8bb8753d97..c29beb4ffe 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -232,7 +232,8 @@ "shift-enter": "search::SelectPrevMatch", "alt-enter": "search::SelectAllMatches", "ctrl-f": "search::FocusSearch", - "ctrl-h": "search::ToggleReplace" + "ctrl-h": "search::ToggleReplace", + "ctrl-l": "search::ToggleSelection" } }, { @@ -296,6 +297,7 @@ "ctrl-alt-g": "search::SelectNextMatch", "ctrl-alt-shift-g": "search::SelectPrevMatch", "ctrl-alt-shift-h": "search::ToggleReplace", + "ctrl-alt-shift-l": "search::ToggleSelection", "alt-enter": "search::SelectAllMatches", "alt-c": "search::ToggleCaseSensitive", "alt-w": "search::ToggleWholeWord", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index da035261ab..9ad328608f 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -176,6 +176,12 @@ "replace_enabled": true } ], + "cmd-alt-l": [ + "buffer_search::Deploy", + { + "selection_search_enabled": true + } + ], "cmd-e": [ "buffer_search::Deploy", { @@ -250,7 +256,8 @@ "shift-enter": "search::SelectPrevMatch", "alt-enter": "search::SelectAllMatches", "cmd-f": "search::FocusSearch", - "cmd-alt-f": "search::ToggleReplace" + "cmd-alt-f": "search::ToggleReplace", + "cmd-alt-l": "search::ToggleSelection" } }, { @@ -316,6 +323,7 @@ "cmd-g": "search::SelectNextMatch", "cmd-shift-g": "search::SelectPrevMatch", "cmd-shift-h": "search::ToggleReplace", + "cmd-alt-l": "search::ToggleSelection", "alt-enter": "search::SelectAllMatches", "alt-cmd-c": "search::ToggleCaseSensitive", "alt-cmd-w": "search::ToggleWholeWord", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 5ab3293735..e5bfbf5392 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -522,6 +522,7 @@ pub struct Editor { expect_bounds_change: Option>, tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, tasks_update_task: Option>, + previous_search_ranges: Option]>>, } #[derive(Clone)] @@ -1824,6 +1825,7 @@ impl Editor { }), ], tasks_update_task: None, + previous_search_ranges: None, }; this.tasks_update_task = Some(this.refresh_runnables(cx)); this._subscriptions.extend(project_subscriptions); @@ -10264,6 +10266,27 @@ impl Editor { self.background_highlights_in_range(start..end, &snapshot, theme) } + #[cfg(feature = "test-support")] + pub fn search_background_highlights( + &mut self, + cx: &mut ViewContext, + ) -> Vec> { + let snapshot = self.buffer().read(cx).snapshot(cx); + + let highlights = self + .background_highlights + .get(&TypeId::of::()); + + if let Some((_color, ranges)) = highlights { + ranges + .iter() + .map(|range| range.start.to_point(&snapshot)..range.end.to_point(&snapshot)) + .collect_vec() + } else { + vec![] + } + } + fn document_highlights_for_position<'a>( &'a self, position: Anchor, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 2d5ea9be7f..5f637a7337 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -13,8 +13,7 @@ use gpui::{ VisualContext, WeakView, WindowContext, }; use language::{ - proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, OffsetRangeExt, - Point, SelectionGoal, + proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, Point, SelectionGoal, }; use multi_buffer::AnchorRangeExt; use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath}; @@ -1008,6 +1007,25 @@ impl SearchableItem for Editor { self.has_background_highlights::() } + fn toggle_filtered_search_ranges(&mut self, enabled: bool, cx: &mut ViewContext) { + if self.has_filtered_search_ranges() { + self.previous_search_ranges = self + .clear_background_highlights::(cx) + .map(|(_, ranges)| ranges) + } + + if !enabled { + return; + } + + let ranges = self.selections.disjoint_anchor_ranges(); + if ranges.iter().any(|range| range.start != range.end) { + self.set_search_within_ranges(&ranges, cx); + } else if let Some(previous_search_ranges) = self.previous_search_ranges.take() { + self.set_search_within_ranges(&previous_search_ranges, cx) + } + } + fn query_suggestion(&mut self, cx: &mut ViewContext) -> String { let setting = EditorSettings::get_global(cx).seed_search_query_from_cursor; let snapshot = &self.snapshot(cx).buffer_snapshot; @@ -1016,9 +1034,14 @@ impl SearchableItem for Editor { match setting { SeedQuerySetting::Never => String::new(), SeedQuerySetting::Selection | SeedQuerySetting::Always if !selection.is_empty() => { - snapshot + let text: String = snapshot .text_for_range(selection.start..selection.end) - .collect() + .collect(); + if text.contains('\n') { + String::new() + } else { + text + } } SeedQuerySetting::Selection => String::new(), SeedQuerySetting::Always => { @@ -1135,58 +1158,64 @@ impl SearchableItem for Editor { let search_within_ranges = self .background_highlights .get(&TypeId::of::()) - .map(|(_color, ranges)| { - ranges - .iter() - .map(|range| range.to_offset(&buffer)) - .collect::>() + .map_or(vec![], |(_color, ranges)| { + ranges.iter().map(|range| range.clone()).collect::>() }); + cx.background_executor().spawn(async move { let mut ranges = Vec::new(); + if let Some((_, _, excerpt_buffer)) = buffer.as_singleton() { - if let Some(search_within_ranges) = search_within_ranges { - for range in search_within_ranges { - let offset = range.start; - ranges.extend( - query - .search(excerpt_buffer, Some(range)) - .await - .into_iter() - .map(|range| { - buffer.anchor_after(range.start + offset) - ..buffer.anchor_before(range.end + offset) - }), - ); - } + let search_within_ranges = if search_within_ranges.is_empty() { + vec![None] } else { - ranges.extend(query.search(excerpt_buffer, None).await.into_iter().map( - |range| buffer.anchor_after(range.start)..buffer.anchor_before(range.end), - )); + search_within_ranges + .into_iter() + .map(|range| Some(range.to_offset(&buffer))) + .collect::>() + }; + + for range in search_within_ranges { + let buffer = &buffer; + ranges.extend( + query + .search(excerpt_buffer, range.clone()) + .await + .into_iter() + .map(|matched_range| { + let offset = range.clone().map(|r| r.start).unwrap_or(0); + buffer.anchor_after(matched_range.start + offset) + ..buffer.anchor_before(matched_range.end + offset) + }), + ); } } else { - for excerpt in buffer.excerpt_boundaries_in_range(0..buffer.len()) { - if let Some(next_excerpt) = excerpt.next { - let excerpt_range = - next_excerpt.range.context.to_offset(&next_excerpt.buffer); - ranges.extend( - query - .search(&next_excerpt.buffer, Some(excerpt_range.clone())) - .await - .into_iter() - .map(|range| { - let start = next_excerpt - .buffer - .anchor_after(excerpt_range.start + range.start); - let end = next_excerpt - .buffer - .anchor_before(excerpt_range.start + range.end); - buffer.anchor_in_excerpt(next_excerpt.id, start).unwrap() - ..buffer.anchor_in_excerpt(next_excerpt.id, end).unwrap() - }), - ); - } + let search_within_ranges = if search_within_ranges.is_empty() { + vec![buffer.anchor_before(0)..buffer.anchor_after(buffer.len())] + } else { + search_within_ranges + }; + + for (excerpt_id, search_buffer, search_range) in + buffer.excerpts_in_ranges(search_within_ranges) + { + ranges.extend( + query + .search(&search_buffer, Some(search_range.clone())) + .await + .into_iter() + .map(|match_range| { + let start = search_buffer + .anchor_after(search_range.start + match_range.start); + let end = search_buffer + .anchor_before(search_range.start + match_range.end); + buffer.anchor_in_excerpt(excerpt_id, start).unwrap() + ..buffer.anchor_in_excerpt(excerpt_id, end).unwrap() + }), + ); } - } + }; + ranges }) } diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 03859ddf2a..70af8b9489 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -273,6 +273,13 @@ impl SelectionsCollection { self.all(cx).last().unwrap().clone() } + pub fn disjoint_anchor_ranges(&self) -> Vec> { + self.disjoint_anchors() + .iter() + .map(|s| s.start..s.end) + .collect() + } + #[cfg(any(test, feature = "test-support"))] pub fn ranges + std::fmt::Debug>( &self, diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index 44141f89ce..5950b4b629 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -765,6 +765,7 @@ impl SearchableItem for LspLogView { regex: true, // LSP log is read-only. replacement: false, + selection: false, } } fn active_match_index( diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 4eb630e78e..cdba2fe6cf 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -3740,6 +3740,62 @@ impl MultiBufferSnapshot { } } + /// Returns excerpts overlapping the given ranges. If range spans multiple excerpts returns one range for each excerpt + pub fn excerpts_in_ranges( + &self, + ranges: impl IntoIterator>, + ) -> impl Iterator)> { + let mut ranges = ranges.into_iter().map(|range| range.to_offset(self)); + + let mut cursor = self.excerpts.cursor::(); + let mut next_range = move |cursor: &mut Cursor| { + let range = ranges.next(); + if let Some(range) = range.as_ref() { + cursor.seek_forward(&range.start, Bias::Right, &()); + } + + range + }; + let mut range = next_range(&mut cursor); + + iter::from_fn(move || { + if range.is_none() { + return None; + } + + if range.as_ref().unwrap().is_empty() || *cursor.start() >= range.as_ref().unwrap().end + { + range = next_range(&mut cursor); + if range.is_none() { + return None; + } + } + + cursor.item().map(|excerpt| { + let multibuffer_excerpt = MultiBufferExcerpt::new(&excerpt, *cursor.start()); + + let multibuffer_excerpt_range = multibuffer_excerpt + .map_range_from_buffer(excerpt.range.context.to_offset(&excerpt.buffer)); + + let overlap_range = cmp::max( + range.as_ref().unwrap().start, + multibuffer_excerpt_range.start, + ) + ..cmp::min(range.as_ref().unwrap().end, multibuffer_excerpt_range.end); + + let overlap_range = multibuffer_excerpt.map_range_to_buffer(overlap_range); + + if multibuffer_excerpt_range.end <= range.as_ref().unwrap().end { + cursor.next(&()); + } else { + range = next_range(&mut cursor); + } + + (excerpt.id, &excerpt.buffer, overlap_range) + }) + }) + } + pub fn remote_selections_in_range<'a>( &'a self, range: &'a Range, @@ -6076,4 +6132,415 @@ mod tests { assert_eq!(multibuffer.read(cx).text(), "XABCD1234\nAB5678"); }); } + + #[gpui::test] + fn test_excerpts_in_ranges_no_ranges(cx: &mut AppContext) { + let buffer_1 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'a'), cx)); + let buffer_2 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'g'), cx)); + let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite)); + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: 0..buffer_1.read(cx).len(), + primary: None, + }], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange { + context: 0..buffer_2.read(cx).len(), + primary: None, + }], + cx, + ); + }); + + let snapshot = multibuffer.update(cx, |multibuffer, cx| multibuffer.snapshot(cx)); + + let mut excerpts = snapshot.excerpts_in_ranges(iter::from_fn(|| None)); + + assert!(excerpts.next().is_none()); + } + + fn validate_excerpts( + actual: &Vec<(ExcerptId, BufferId, Range)>, + expected: &Vec<(ExcerptId, BufferId, Range)>, + ) { + assert_eq!(actual.len(), expected.len()); + + actual + .into_iter() + .zip(expected) + .map(|(actual, expected)| { + assert_eq!(actual.0, expected.0); + assert_eq!(actual.1, expected.1); + assert_eq!(actual.2.start, expected.2.start); + assert_eq!(actual.2.end, expected.2.end); + }) + .collect_vec(); + } + + fn map_range_from_excerpt( + snapshot: &MultiBufferSnapshot, + excerpt_id: ExcerptId, + excerpt_buffer: &BufferSnapshot, + range: Range, + ) -> Range { + snapshot + .anchor_in_excerpt(excerpt_id, excerpt_buffer.anchor_before(range.start)) + .unwrap() + ..snapshot + .anchor_in_excerpt(excerpt_id, excerpt_buffer.anchor_after(range.end)) + .unwrap() + } + + fn make_expected_excerpt_info( + snapshot: &MultiBufferSnapshot, + cx: &mut AppContext, + excerpt_id: ExcerptId, + buffer: &Model, + range: Range, + ) -> (ExcerptId, BufferId, Range) { + ( + excerpt_id, + buffer.read(cx).remote_id(), + map_range_from_excerpt(&snapshot, excerpt_id, &buffer.read(cx).snapshot(), range), + ) + } + + #[gpui::test] + fn test_excerpts_in_ranges_range_inside_the_excerpt(cx: &mut AppContext) { + let buffer_1 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'a'), cx)); + let buffer_2 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'g'), cx)); + let buffer_len = buffer_1.read(cx).len(); + let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite)); + let mut expected_excerpt_id = ExcerptId(0); + + multibuffer.update(cx, |multibuffer, cx| { + expected_excerpt_id = multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: 0..buffer_1.read(cx).len(), + primary: None, + }], + cx, + )[0]; + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange { + context: 0..buffer_2.read(cx).len(), + primary: None, + }], + cx, + ); + }); + + let snapshot = multibuffer.update(cx, |multibuffer, cx| multibuffer.snapshot(cx)); + + let range = snapshot + .anchor_in_excerpt(expected_excerpt_id, buffer_1.read(cx).anchor_before(1)) + .unwrap() + ..snapshot + .anchor_in_excerpt( + expected_excerpt_id, + buffer_1.read(cx).anchor_after(buffer_len / 2), + ) + .unwrap(); + + let expected_excerpts = vec![make_expected_excerpt_info( + &snapshot, + cx, + expected_excerpt_id, + &buffer_1, + 1..(buffer_len / 2), + )]; + + let excerpts = snapshot + .excerpts_in_ranges(vec![range.clone()].into_iter()) + .map(|(excerpt_id, buffer, actual_range)| { + ( + excerpt_id, + buffer.remote_id(), + map_range_from_excerpt(&snapshot, excerpt_id, buffer, actual_range), + ) + }) + .collect_vec(); + + validate_excerpts(&excerpts, &expected_excerpts); + } + + #[gpui::test] + fn test_excerpts_in_ranges_range_crosses_excerpts_boundary(cx: &mut AppContext) { + let buffer_1 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'a'), cx)); + let buffer_2 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'g'), cx)); + let buffer_len = buffer_1.read(cx).len(); + let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite)); + let mut excerpt_1_id = ExcerptId(0); + let mut excerpt_2_id = ExcerptId(0); + + multibuffer.update(cx, |multibuffer, cx| { + excerpt_1_id = multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: 0..buffer_1.read(cx).len(), + primary: None, + }], + cx, + )[0]; + excerpt_2_id = multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange { + context: 0..buffer_2.read(cx).len(), + primary: None, + }], + cx, + )[0]; + }); + + let snapshot = multibuffer.read(cx).snapshot(cx); + + let expected_range = snapshot + .anchor_in_excerpt( + excerpt_1_id, + buffer_1.read(cx).anchor_before(buffer_len / 2), + ) + .unwrap() + ..snapshot + .anchor_in_excerpt(excerpt_2_id, buffer_2.read(cx).anchor_after(buffer_len / 2)) + .unwrap(); + + let expected_excerpts = vec![ + make_expected_excerpt_info( + &snapshot, + cx, + excerpt_1_id, + &buffer_1, + (buffer_len / 2)..buffer_len, + ), + make_expected_excerpt_info(&snapshot, cx, excerpt_2_id, &buffer_2, 0..buffer_len / 2), + ]; + + let excerpts = snapshot + .excerpts_in_ranges(vec![expected_range.clone()].into_iter()) + .map(|(excerpt_id, buffer, actual_range)| { + ( + excerpt_id, + buffer.remote_id(), + map_range_from_excerpt(&snapshot, excerpt_id, buffer, actual_range), + ) + }) + .collect_vec(); + + validate_excerpts(&excerpts, &expected_excerpts); + } + + #[gpui::test] + fn test_excerpts_in_ranges_range_encloses_excerpt(cx: &mut AppContext) { + let buffer_1 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'a'), cx)); + let buffer_2 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'g'), cx)); + let buffer_3 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'r'), cx)); + let buffer_len = buffer_1.read(cx).len(); + let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite)); + let mut excerpt_1_id = ExcerptId(0); + let mut excerpt_2_id = ExcerptId(0); + let mut excerpt_3_id = ExcerptId(0); + + multibuffer.update(cx, |multibuffer, cx| { + excerpt_1_id = multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: 0..buffer_1.read(cx).len(), + primary: None, + }], + cx, + )[0]; + excerpt_2_id = multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange { + context: 0..buffer_2.read(cx).len(), + primary: None, + }], + cx, + )[0]; + excerpt_3_id = multibuffer.push_excerpts( + buffer_3.clone(), + [ExcerptRange { + context: 0..buffer_3.read(cx).len(), + primary: None, + }], + cx, + )[0]; + }); + + let snapshot = multibuffer.read(cx).snapshot(cx); + + let expected_range = snapshot + .anchor_in_excerpt( + excerpt_1_id, + buffer_1.read(cx).anchor_before(buffer_len / 2), + ) + .unwrap() + ..snapshot + .anchor_in_excerpt(excerpt_3_id, buffer_3.read(cx).anchor_after(buffer_len / 2)) + .unwrap(); + + let expected_excerpts = vec![ + make_expected_excerpt_info( + &snapshot, + cx, + excerpt_1_id, + &buffer_1, + (buffer_len / 2)..buffer_len, + ), + make_expected_excerpt_info(&snapshot, cx, excerpt_2_id, &buffer_2, 0..buffer_len), + make_expected_excerpt_info(&snapshot, cx, excerpt_3_id, &buffer_3, 0..buffer_len / 2), + ]; + + let excerpts = snapshot + .excerpts_in_ranges(vec![expected_range.clone()].into_iter()) + .map(|(excerpt_id, buffer, actual_range)| { + ( + excerpt_id, + buffer.remote_id(), + map_range_from_excerpt(&snapshot, excerpt_id, buffer, actual_range), + ) + }) + .collect_vec(); + + validate_excerpts(&excerpts, &expected_excerpts); + } + + #[gpui::test] + fn test_excerpts_in_ranges_multiple_ranges(cx: &mut AppContext) { + let buffer_1 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'a'), cx)); + let buffer_2 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'g'), cx)); + let buffer_len = buffer_1.read(cx).len(); + let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite)); + let mut excerpt_1_id = ExcerptId(0); + let mut excerpt_2_id = ExcerptId(0); + + multibuffer.update(cx, |multibuffer, cx| { + excerpt_1_id = multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: 0..buffer_1.read(cx).len(), + primary: None, + }], + cx, + )[0]; + excerpt_2_id = multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange { + context: 0..buffer_2.read(cx).len(), + primary: None, + }], + cx, + )[0]; + }); + + let snapshot = multibuffer.read(cx).snapshot(cx); + + let ranges = vec![ + 1..(buffer_len / 4), + (buffer_len / 3)..(buffer_len / 2), + (buffer_len / 4 * 3)..(buffer_len), + ]; + + let expected_excerpts = ranges + .iter() + .map(|range| { + make_expected_excerpt_info(&snapshot, cx, excerpt_1_id, &buffer_1, range.clone()) + }) + .collect_vec(); + + let ranges = ranges.into_iter().map(|range| { + map_range_from_excerpt( + &snapshot, + excerpt_1_id, + &buffer_1.read(cx).snapshot(), + range, + ) + }); + + let excerpts = snapshot + .excerpts_in_ranges(ranges) + .map(|(excerpt_id, buffer, actual_range)| { + ( + excerpt_id, + buffer.remote_id(), + map_range_from_excerpt(&snapshot, excerpt_id, buffer, actual_range), + ) + }) + .collect_vec(); + + validate_excerpts(&excerpts, &expected_excerpts); + } + + #[gpui::test] + fn test_excerpts_in_ranges_range_ends_at_excerpt_end(cx: &mut AppContext) { + let buffer_1 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'a'), cx)); + let buffer_2 = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'g'), cx)); + let buffer_len = buffer_1.read(cx).len(); + let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite)); + let mut excerpt_1_id = ExcerptId(0); + let mut excerpt_2_id = ExcerptId(0); + + multibuffer.update(cx, |multibuffer, cx| { + excerpt_1_id = multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: 0..buffer_1.read(cx).len(), + primary: None, + }], + cx, + )[0]; + excerpt_2_id = multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange { + context: 0..buffer_2.read(cx).len(), + primary: None, + }], + cx, + )[0]; + }); + + let snapshot = multibuffer.read(cx).snapshot(cx); + + let ranges = [0..buffer_len, (buffer_len / 3)..(buffer_len / 2)]; + + let expected_excerpts = vec![ + make_expected_excerpt_info(&snapshot, cx, excerpt_1_id, &buffer_1, ranges[0].clone()), + make_expected_excerpt_info(&snapshot, cx, excerpt_2_id, &buffer_2, ranges[1].clone()), + ]; + + let ranges = [ + map_range_from_excerpt( + &snapshot, + excerpt_1_id, + &buffer_1.read(cx).snapshot(), + ranges[0].clone(), + ), + map_range_from_excerpt( + &snapshot, + excerpt_2_id, + &buffer_2.read(cx).snapshot(), + ranges[1].clone(), + ), + ]; + + let excerpts = snapshot + .excerpts_in_ranges(ranges.into_iter()) + .map(|(excerpt_id, buffer, actual_range)| { + ( + excerpt_id, + buffer.remote_id(), + map_range_from_excerpt(&snapshot, excerpt_id, buffer, actual_range), + ) + }) + .collect_vec(); + + validate_excerpts(&excerpts, &expected_excerpts); + } } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index f264d796a9..37c49f34ed 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -3,7 +3,7 @@ mod registrar; use crate::{ search_bar::render_nav_button, FocusSearch, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions, SelectAllMatches, SelectNextMatch, SelectPrevMatch, - ToggleCaseSensitive, ToggleRegex, ToggleReplace, ToggleWholeWord, + ToggleCaseSensitive, ToggleRegex, ToggleReplace, ToggleSelection, ToggleWholeWord, }; use any_vec::AnyVec; use collections::HashMap; @@ -48,6 +48,8 @@ pub struct Deploy { pub focus: bool, #[serde(default)] pub replace_enabled: bool, + #[serde(default)] + pub selection_search_enabled: bool, } impl_actions!(buffer_search, [Deploy]); @@ -59,6 +61,7 @@ impl Deploy { Self { focus: true, replace_enabled: false, + selection_search_enabled: false, } } } @@ -90,6 +93,7 @@ pub struct BufferSearchBar { search_history: SearchHistory, search_history_cursor: SearchHistoryCursor, replace_enabled: bool, + selection_search_enabled: bool, scroll_handle: ScrollHandle, editor_scroll_handle: ScrollHandle, editor_needed_width: Pixels, @@ -228,7 +232,7 @@ impl Render for BufferSearchBar { }), ) })) - .children(supported_options.word.then(|| { + .children(supported_options.regex.then(|| { self.render_search_option_button( SearchOptions::REGEX, cx.listener(|this, _, cx| this.toggle_regex(&ToggleRegex, cx)), @@ -251,6 +255,26 @@ impl Render for BufferSearchBar { .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)), ) }) + .when(supported_options.selection, |this| { + this.child( + IconButton::new( + "buffer-search-bar-toggle-search-selection-button", + IconName::SearchSelection, + ) + .style(ButtonStyle::Subtle) + .when(self.selection_search_enabled, |button| { + button.style(ButtonStyle::Filled) + }) + .on_click(cx.listener(|this, _: &ClickEvent, cx| { + this.toggle_selection(&ToggleSelection, cx); + })) + .selected(self.selection_search_enabled) + .size(ButtonSize::Compact) + .tooltip(|cx| { + Tooltip::for_action("Toggle search selection", &ToggleSelection, cx) + }), + ) + }) .child( h_flex() .flex_none() @@ -359,6 +383,9 @@ impl Render for BufferSearchBar { .when(self.supported_options().regex, |this| { this.on_action(cx.listener(Self::toggle_regex)) }) + .when(self.supported_options().selection, |this| { + this.on_action(cx.listener(Self::toggle_selection)) + }) .gap_2() .child( h_flex() @@ -440,6 +467,11 @@ impl BufferSearchBar { this.toggle_whole_word(action, cx); } })); + registrar.register_handler(ForDeployed(|this, action: &ToggleSelection, cx| { + if this.supported_options().selection { + this.toggle_selection(action, cx); + } + })); registrar.register_handler(ForDeployed(|this, action: &ToggleReplace, cx| { if this.supported_options().replacement { this.toggle_replace(action, cx); @@ -497,6 +529,7 @@ impl BufferSearchBar { search_history_cursor: Default::default(), active_search: None, replace_enabled: false, + selection_search_enabled: false, scroll_handle: ScrollHandle::new(), editor_scroll_handle: ScrollHandle::new(), editor_needed_width: px(0.), @@ -516,8 +549,11 @@ impl BufferSearchBar { searchable_item.clear_matches(cx); } } - if let Some(active_editor) = self.active_searchable_item.as_ref() { + if let Some(active_editor) = self.active_searchable_item.as_mut() { + self.selection_search_enabled = false; + self.replace_enabled = false; active_editor.search_bar_visibility_changed(false, cx); + active_editor.toggle_filtered_search_ranges(false, cx); let handle = active_editor.focus_handle(cx); cx.focus(&handle); } @@ -530,8 +566,12 @@ impl BufferSearchBar { pub fn deploy(&mut self, deploy: &Deploy, cx: &mut ViewContext) -> bool { if self.show(cx) { + if let Some(active_item) = self.active_searchable_item.as_mut() { + active_item.toggle_filtered_search_ranges(deploy.selection_search_enabled, cx); + } self.search_suggested(cx); self.replace_enabled = deploy.replace_enabled; + self.selection_search_enabled = deploy.selection_search_enabled; if deploy.focus { let mut handle = self.query_editor.focus_handle(cx).clone(); let mut select_query = true; @@ -539,9 +579,11 @@ impl BufferSearchBar { handle = self.replacement_editor.focus_handle(cx).clone(); select_query = false; }; + if select_query { self.select_query(cx); } + cx.focus(&handle); } return true; @@ -823,6 +865,15 @@ impl BufferSearchBar { self.toggle_search_option(SearchOptions::WHOLE_WORD, cx) } + fn toggle_selection(&mut self, _: &ToggleSelection, cx: &mut ViewContext) { + if let Some(active_item) = self.active_searchable_item.as_mut() { + self.selection_search_enabled = !self.selection_search_enabled; + active_item.toggle_filtered_search_ranges(self.selection_search_enabled, cx); + let _ = self.update_matches(cx); + cx.notify(); + } + } + fn toggle_regex(&mut self, _: &ToggleRegex, cx: &mut ViewContext) { self.toggle_search_option(SearchOptions::REGEX, cx) } @@ -1090,9 +1141,9 @@ mod tests { use std::ops::Range; use super::*; - use editor::{display_map::DisplayRow, DisplayPoint, Editor}; + use editor::{display_map::DisplayRow, DisplayPoint, Editor, MultiBuffer}; use gpui::{Context, Hsla, TestAppContext, VisualTestContext}; - use language::Buffer; + use language::{Buffer, Point}; use project::Project; use smol::stream::StreamExt as _; use unindent::Unindent as _; @@ -1405,6 +1456,15 @@ mod tests { }); } + fn display_points_of( + background_highlights: Vec<(Range, Hsla)>, + ) -> Vec> { + background_highlights + .into_iter() + .map(|(range, _)| range) + .collect::>() + } + #[gpui::test] async fn test_search_option_handling(cx: &mut TestAppContext) { let (editor, search_bar, cx) = init_test(cx); @@ -1417,12 +1477,6 @@ mod tests { }) .await .unwrap(); - let display_points_of = |background_highlights: Vec<(Range, Hsla)>| { - background_highlights - .into_iter() - .map(|(range, _)| range) - .collect::>() - }; editor.update(cx, |editor, cx| { assert_eq!( display_points_of(editor.all_text_background_highlights(cx)), @@ -2032,15 +2086,156 @@ mod tests { .await; } + #[gpui::test] + async fn test_find_matches_in_selections_singleton_buffer_multiple_selections( + cx: &mut TestAppContext, + ) { + init_globals(cx); + let buffer = cx.new_model(|cx| { + Buffer::local( + r#" + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + "# + .unindent(), + cx, + ) + }); + let cx = cx.add_empty_window(); + let editor = cx.new_view(|cx| Editor::for_buffer(buffer.clone(), None, cx)); + + let search_bar = cx.new_view(|cx| { + let mut search_bar = BufferSearchBar::new(cx); + search_bar.set_active_pane_item(Some(&editor), cx); + search_bar.show(cx); + search_bar + }); + + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_ranges(vec![Point::new(1, 0)..Point::new(2, 4)]) + }) + }); + + search_bar.update(cx, |search_bar, cx| { + let deploy = Deploy { + focus: true, + replace_enabled: false, + selection_search_enabled: true, + }; + search_bar.deploy(&deploy, cx); + }); + + cx.run_until_parked(); + + search_bar + .update(cx, |search_bar, cx| search_bar.search("aaa", None, cx)) + .await + .unwrap(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.search_background_highlights(cx), + &[ + Point::new(1, 0)..Point::new(1, 3), + Point::new(1, 8)..Point::new(1, 11), + Point::new(2, 0)..Point::new(2, 3), + ] + ); + }); + } + + #[gpui::test] + async fn test_find_matches_in_selections_multiple_excerpts_buffer_multiple_selections( + cx: &mut TestAppContext, + ) { + init_globals(cx); + let text = r#" + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + "# + .unindent(); + + let cx = cx.add_empty_window(); + let editor = cx.new_view(|cx| { + let multibuffer = MultiBuffer::build_multi( + [ + ( + &text, + vec![ + Point::new(0, 0)..Point::new(2, 0), + Point::new(4, 0)..Point::new(5, 0), + ], + ), + (&text, vec![Point::new(9, 0)..Point::new(11, 0)]), + ], + cx, + ); + Editor::for_multibuffer(multibuffer, None, false, cx) + }); + + let search_bar = cx.new_view(|cx| { + let mut search_bar = BufferSearchBar::new(cx); + search_bar.set_active_pane_item(Some(&editor), cx); + search_bar.show(cx); + search_bar + }); + + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_ranges(vec![ + Point::new(1, 0)..Point::new(1, 4), + Point::new(5, 3)..Point::new(6, 4), + ]) + }) + }); + + search_bar.update(cx, |search_bar, cx| { + let deploy = Deploy { + focus: true, + replace_enabled: false, + selection_search_enabled: true, + }; + search_bar.deploy(&deploy, cx); + }); + + cx.run_until_parked(); + + search_bar + .update(cx, |search_bar, cx| search_bar.search("aaa", None, cx)) + .await + .unwrap(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.search_background_highlights(cx), + &[ + Point::new(1, 0)..Point::new(1, 3), + Point::new(5, 8)..Point::new(5, 11), + Point::new(6, 0)..Point::new(6, 3), + ] + ); + }); + } + #[gpui::test] async fn test_invalid_regexp_search_after_valid(cx: &mut TestAppContext) { let (editor, search_bar, cx) = init_test(cx); - let display_points_of = |background_highlights: Vec<(Range, Hsla)>| { - background_highlights - .into_iter() - .map(|(range, _)| range) - .collect::>() - }; // Search using valid regexp search_bar .update(cx, |search_bar, cx| { diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index e086ebae51..220863dfce 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -25,6 +25,7 @@ actions!( ToggleIncludeIgnored, ToggleRegex, ToggleReplace, + ToggleSelection, SelectNextMatch, SelectPrevMatch, SelectAllMatches, diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index bea068439b..0a2f5d863a 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -972,6 +972,7 @@ impl SearchableItem for TerminalView { word: false, regex: true, replacement: false, + selection: false, } } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index cb72386b59..79b4d87750 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -169,6 +169,7 @@ pub enum IconName { Save, Screen, SelectAll, + SearchSelection, Server, Settings, Shift, @@ -293,6 +294,7 @@ impl IconName { IconName::Save => "icons/save.svg", IconName::Screen => "icons/desktop.svg", IconName::SelectAll => "icons/select_all.svg", + IconName::SearchSelection => "icons/search_selection.svg", IconName::Server => "icons/server.svg", IconName::Settings => "icons/file_icons/settings.svg", IconName::Shift => "icons/shift.svg", diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 950efde9e2..0d037f6bed 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -39,8 +39,9 @@ pub struct SearchOptions { pub case: bool, pub word: bool, pub regex: bool, - /// Specifies whether the item supports search & replace. + /// Specifies whether the supports search & replace. pub replacement: bool, + pub selection: bool, } pub trait SearchableItem: Item + EventEmitter { @@ -52,15 +53,18 @@ pub trait SearchableItem: Item + EventEmitter { word: true, regex: true, replacement: true, + selection: true, } } fn search_bar_visibility_changed(&mut self, _visible: bool, _cx: &mut ViewContext) {} fn has_filtered_search_ranges(&mut self) -> bool { - false + Self::supported_options().selection } + fn toggle_filtered_search_ranges(&mut self, _enabled: bool, _cx: &mut ViewContext) {} + fn clear_matches(&mut self, cx: &mut ViewContext); fn update_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext); fn query_suggestion(&mut self, cx: &mut ViewContext) -> String; @@ -138,6 +142,8 @@ pub trait SearchableItemHandle: ItemHandle { cx: &mut WindowContext, ) -> Option; fn search_bar_visibility_changed(&self, visible: bool, cx: &mut WindowContext); + + fn toggle_filtered_search_ranges(&mut self, enabled: bool, cx: &mut WindowContext); } impl SearchableItemHandle for View { @@ -240,6 +246,12 @@ impl SearchableItemHandle for View { this.search_bar_visibility_changed(visible, cx) }); } + + fn toggle_filtered_search_ranges(&mut self, enabled: bool, cx: &mut WindowContext) { + self.update(cx, |this, cx| { + this.toggle_filtered_search_ranges(enabled, cx) + }); + } } impl From> for AnyView {