Search in selections (#10831)

kshokhin and Conrad Irwin created

Release Notes:

- Adding [#8617 ](https://github.com/zed-industries/zed/issues/8617)

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

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(-)

Detailed changes

assets/icons/search_selection.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-text-quote"><path d="M17 6H3"/><path d="M21 12H8"/><path d="M21 18H8"/><path d="M3 12v6"/></svg>

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",

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",

crates/editor/src/editor.rs 🔗

@@ -522,6 +522,7 @@ pub struct Editor {
     expect_bounds_change: Option<Bounds<Pixels>>,
     tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>,
     tasks_update_task: Option<Task<()>>,
+    previous_search_ranges: Option<Arc<[Range<Anchor>]>>,
 }
 
 #[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<Self>,
+    ) -> Vec<Range<Point>> {
+        let snapshot = self.buffer().read(cx).snapshot(cx);
+
+        let highlights = self
+            .background_highlights
+            .get(&TypeId::of::<items::BufferSearchHighlights>());
+
+        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,

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::<SearchWithinRange>()
     }
 
+    fn toggle_filtered_search_ranges(&mut self, enabled: bool, cx: &mut ViewContext<Self>) {
+        if self.has_filtered_search_ranges() {
+            self.previous_search_ranges = self
+                .clear_background_highlights::<SearchWithinRange>(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<Self>) -> 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::<SearchWithinRange>())
-            .map(|(_color, ranges)| {
-                ranges
-                    .iter()
-                    .map(|range| range.to_offset(&buffer))
-                    .collect::<Vec<_>>()
+            .map_or(vec![], |(_color, ranges)| {
+                ranges.iter().map(|range| range.clone()).collect::<Vec<_>>()
             });
+
         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::<Vec<_>>()
+                };
+
+                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
         })
     }

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<Range<Anchor>> {
+        self.disjoint_anchors()
+            .iter()
+            .map(|s| s.start..s.end)
+            .collect()
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn ranges<D: TextDimension + Ord + Sub<D, Output = D> + std::fmt::Debug>(
         &self,

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(

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<Item = Range<Anchor>>,
+    ) -> impl Iterator<Item = (ExcerptId, &BufferSnapshot, Range<usize>)> {
+        let mut ranges = ranges.into_iter().map(|range| range.to_offset(self));
+
+        let mut cursor = self.excerpts.cursor::<usize>();
+        let mut next_range = move |cursor: &mut Cursor<Excerpt, usize>| {
+            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<Anchor>,
@@ -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<Anchor>)>,
+        expected: &Vec<(ExcerptId, BufferId, Range<Anchor>)>,
+    ) {
+        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<usize>,
+    ) -> Range<Anchor> {
+        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<Buffer>,
+        range: Range<usize>,
+    ) -> (ExcerptId, BufferId, Range<Anchor>) {
+        (
+            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);
+    }
 }

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<Self>) -> 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<Self>) {
+        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>) {
         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<DisplayPoint>, Hsla)>,
+    ) -> Vec<Range<DisplayPoint>> {
+        background_highlights
+            .into_iter()
+            .map(|(range, _)| range)
+            .collect::<Vec<_>>()
+    }
+
     #[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<DisplayPoint>, Hsla)>| {
-            background_highlights
-                .into_iter()
-                .map(|(range, _)| range)
-                .collect::<Vec<_>>()
-        };
         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<DisplayPoint>, Hsla)>| {
-            background_highlights
-                .into_iter()
-                .map(|(range, _)| range)
-                .collect::<Vec<_>>()
-        };
         // Search using valid regexp
         search_bar
             .update(cx, |search_bar, cx| {

crates/search/src/search.rs 🔗

@@ -25,6 +25,7 @@ actions!(
         ToggleIncludeIgnored,
         ToggleRegex,
         ToggleReplace,
+        ToggleSelection,
         SelectNextMatch,
         SelectPrevMatch,
         SelectAllMatches,

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",

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<SearchEvent> {
@@ -52,15 +53,18 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
             word: true,
             regex: true,
             replacement: true,
+            selection: true,
         }
     }
 
     fn search_bar_visibility_changed(&mut self, _visible: bool, _cx: &mut ViewContext<Self>) {}
 
     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<Self>) {}
+
     fn clear_matches(&mut self, cx: &mut ViewContext<Self>);
     fn update_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext<Self>);
     fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String;
@@ -138,6 +142,8 @@ pub trait SearchableItemHandle: ItemHandle {
         cx: &mut WindowContext,
     ) -> Option<usize>;
     fn search_bar_visibility_changed(&self, visible: bool, cx: &mut WindowContext);
+
+    fn toggle_filtered_search_ranges(&mut self, enabled: bool, cx: &mut WindowContext);
 }
 
 impl<T: SearchableItem> SearchableItemHandle for View<T> {
@@ -240,6 +246,12 @@ impl<T: SearchableItem> SearchableItemHandle for View<T> {
             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<Box<dyn SearchableItemHandle>> for AnyView {