Fix incorrect search ranges when rendering search matches in the outline panel (#41859)

Kirill Bulatov created

Closes https://github.com/zed-industries/zed/issues/41792

Release Notes:

- Fixed outline panel panicking when rendering certain search matches

Change summary

crates/outline_panel/src/outline_panel.rs | 573 +++++++++++++++++++++---
1 file changed, 484 insertions(+), 89 deletions(-)

Detailed changes

crates/outline_panel/src/outline_panel.rs 🔗

@@ -465,14 +465,8 @@ impl SearchData {
         let match_offset_range = match_range.to_offset(multi_buffer_snapshot);
 
         let mut search_match_indices = vec![
-            multi_buffer_snapshot.clip_offset(
-                match_offset_range.start - context_offset_range.start,
-                Bias::Left,
-            )
-                ..multi_buffer_snapshot.clip_offset(
-                    match_offset_range.end - context_offset_range.start,
-                    Bias::Right,
-                ),
+            match_offset_range.start - context_offset_range.start
+                ..match_offset_range.end - context_offset_range.start,
         ];
 
         let entire_context_text = multi_buffer_snapshot
@@ -509,14 +503,8 @@ impl SearchData {
                 .next()
                 .is_some_and(|c| !c.is_whitespace());
         search_match_indices.iter_mut().for_each(|range| {
-            range.start = multi_buffer_snapshot.clip_offset(
-                range.start.saturating_sub(left_whitespaces_offset),
-                Bias::Left,
-            );
-            range.end = multi_buffer_snapshot.clip_offset(
-                range.end.saturating_sub(left_whitespaces_offset),
-                Bias::Right,
-            );
+            range.start = range.start.saturating_sub(left_whitespaces_offset);
+            range.end = range.end.saturating_sub(left_whitespaces_offset);
         });
 
         let trimmed_row_offset_range =
@@ -5226,10 +5214,13 @@ mod tests {
     use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
     use pretty_assertions::assert_eq;
     use project::FakeFs;
-    use search::project_search::{self, perform_project_search};
+    use search::{
+        buffer_search,
+        project_search::{self, perform_project_search},
+    };
     use serde_json::json;
     use util::path;
-    use workspace::{OpenOptions, OpenVisible};
+    use workspace::{OpenOptions, OpenVisible, ToolbarItemView};
 
     use super::*;
 
@@ -5292,25 +5283,28 @@ mod tests {
     ide/src/
       inlay_hints/
         fn_lifetime_fn.rs
-          search: match config.param_names_for_lifetime_elision_hints {
-          search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
-          search: Some(it) if config.param_names_for_lifetime_elision_hints => {
-          search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
+          search: match config.«param_names_for_lifetime_elision_hints» {
+          search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
+          search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
+          search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
       inlay_hints.rs
-        search: pub param_names_for_lifetime_elision_hints: bool,
-        search: param_names_for_lifetime_elision_hints: self
+        search: pub «param_names_for_lifetime_elision_hints»: bool,
+        search: «param_names_for_lifetime_elision_hints»: self
       static_index.rs
-        search: param_names_for_lifetime_elision_hints: false,
+        search: «param_names_for_lifetime_elision_hints»: false,
     rust-analyzer/src/
       cli/
         analysis_stats.rs
-          search: param_names_for_lifetime_elision_hints: true,
+          search: «param_names_for_lifetime_elision_hints»: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#
+        search: «param_names_for_lifetime_elision_hints»: self"#
             .to_string();
 
         let select_first_in_all_matches = |line_to_select: &str| {
-            assert!(all_matches.contains(line_to_select));
+            assert!(
+                all_matches.contains(line_to_select),
+                "`{line_to_select}` was not found in all matches `{all_matches}`"
+            );
             all_matches.replacen(
                 line_to_select,
                 &format!("{line_to_select}{SELECTED_MARKER}"),
@@ -5331,7 +5325,7 @@ mod tests {
                     cx,
                 ),
                 select_first_in_all_matches(
-                    "search: match config.param_names_for_lifetime_elision_hints {"
+                    "search: match config.«param_names_for_lifetime_elision_hints» {"
                 )
             );
         });
@@ -5371,16 +5365,16 @@ mod tests {
       inlay_hints/
         fn_lifetime_fn.rs{SELECTED_MARKER}
       inlay_hints.rs
-        search: pub param_names_for_lifetime_elision_hints: bool,
-        search: param_names_for_lifetime_elision_hints: self
+        search: pub «param_names_for_lifetime_elision_hints»: bool,
+        search: «param_names_for_lifetime_elision_hints»: self
       static_index.rs
-        search: param_names_for_lifetime_elision_hints: false,
+        search: «param_names_for_lifetime_elision_hints»: false,
     rust-analyzer/src/
       cli/
         analysis_stats.rs
-          search: param_names_for_lifetime_elision_hints: true,
+          search: «param_names_for_lifetime_elision_hints»: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#,
+        search: «param_names_for_lifetime_elision_hints»: self"#,
                 )
             );
         });
@@ -5441,9 +5435,9 @@ mod tests {
     rust-analyzer/src/
       cli/
         analysis_stats.rs
-          search: param_names_for_lifetime_elision_hints: true,
+          search: «param_names_for_lifetime_elision_hints»: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#,
+        search: «param_names_for_lifetime_elision_hints»: self"#,
                 )
             );
         });
@@ -5523,21 +5517,21 @@ mod tests {
     ide/src/
       inlay_hints/
         fn_lifetime_fn.rs
-          search: match config.param_names_for_lifetime_elision_hints {
-          search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
-          search: Some(it) if config.param_names_for_lifetime_elision_hints => {
-          search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
+          search: match config.«param_names_for_lifetime_elision_hints» {
+          search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
+          search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
+          search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
       inlay_hints.rs
-        search: pub param_names_for_lifetime_elision_hints: bool,
-        search: param_names_for_lifetime_elision_hints: self
+        search: pub «param_names_for_lifetime_elision_hints»: bool,
+        search: «param_names_for_lifetime_elision_hints»: self
       static_index.rs
-        search: param_names_for_lifetime_elision_hints: false,
+        search: «param_names_for_lifetime_elision_hints»: false,
     rust-analyzer/src/
       cli/
         analysis_stats.rs
-          search: param_names_for_lifetime_elision_hints: true,
+          search: «param_names_for_lifetime_elision_hints»: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#
+        search: «param_names_for_lifetime_elision_hints»: self"#
             .to_string();
 
         cx.executor()
@@ -5662,30 +5656,40 @@ mod tests {
     ide/src/
       inlay_hints/
         fn_lifetime_fn.rs
-          search: match config.param_names_for_lifetime_elision_hints {
-          search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
-          search: Some(it) if config.param_names_for_lifetime_elision_hints => {
-          search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
+          search: match config.«param_names_for_lifetime_elision_hints» {
+          search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
+          search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
+          search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
       inlay_hints.rs
-        search: pub param_names_for_lifetime_elision_hints: bool,
-        search: param_names_for_lifetime_elision_hints: self
+        search: pub «param_names_for_lifetime_elision_hints»: bool,
+        search: «param_names_for_lifetime_elision_hints»: self
       static_index.rs
-        search: param_names_for_lifetime_elision_hints: false,
+        search: «param_names_for_lifetime_elision_hints»: false,
     rust-analyzer/src/
       cli/
         analysis_stats.rs
-          search: param_names_for_lifetime_elision_hints: true,
+          search: «param_names_for_lifetime_elision_hints»: true,
       config.rs
-        search: param_names_for_lifetime_elision_hints: self"#
+        search: «param_names_for_lifetime_elision_hints»: self"#
             .to_string();
         let select_first_in_all_matches = |line_to_select: &str| {
-            assert!(all_matches.contains(line_to_select));
+            assert!(
+                all_matches.contains(line_to_select),
+                "`{line_to_select}` was not found in all matches `{all_matches}`"
+            );
             all_matches.replacen(
                 line_to_select,
                 &format!("{line_to_select}{SELECTED_MARKER}"),
                 1,
             )
         };
+        let clear_outline_metadata = |input: &str| {
+            input
+                .replace("search: ", "")
+                .replace("«", "")
+                .replace("»", "")
+        };
+
         cx.executor()
             .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
         cx.run_until_parked();
@@ -5696,7 +5700,7 @@ mod tests {
                 .expect("should have an active editor open")
         });
         let initial_outline_selection =
-            "search: match config.param_names_for_lifetime_elision_hints {";
+            "search: match config.«param_names_for_lifetime_elision_hints» {";
         outline_panel.update_in(cx, |outline_panel, window, cx| {
             assert_eq!(
                 display_entries(
@@ -5710,7 +5714,7 @@ mod tests {
             );
             assert_eq!(
                 selected_row_text(&active_editor, cx),
-                initial_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
+                clear_outline_metadata(initial_outline_selection),
                 "Should place the initial editor selection on the corresponding search result"
             );
 
@@ -5719,7 +5723,7 @@ mod tests {
         });
 
         let navigated_outline_selection =
-            "search: Some(it) if config.param_names_for_lifetime_elision_hints => {";
+            "search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {";
         outline_panel.update(cx, |outline_panel, cx| {
             assert_eq!(
                 display_entries(
@@ -5737,7 +5741,7 @@ mod tests {
         outline_panel.update(cx, |_, cx| {
             assert_eq!(
                 selected_row_text(&active_editor, cx),
-                navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
+                clear_outline_metadata(navigated_outline_selection),
                 "Should still have the initial caret position after SelectNext calls"
             );
         });
@@ -5748,7 +5752,7 @@ mod tests {
         outline_panel.update(cx, |_outline_panel, cx| {
             assert_eq!(
                 selected_row_text(&active_editor, cx),
-                navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
+                clear_outline_metadata(navigated_outline_selection),
                 "After opening, should move the caret to the opened outline entry's position"
             );
         });
@@ -5756,7 +5760,7 @@ mod tests {
         outline_panel.update_in(cx, |outline_panel, window, cx| {
             outline_panel.select_next(&SelectNext, window, cx);
         });
-        let next_navigated_outline_selection = "search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },";
+        let next_navigated_outline_selection = "search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },";
         outline_panel.update(cx, |outline_panel, cx| {
             assert_eq!(
                 display_entries(
@@ -5774,7 +5778,7 @@ mod tests {
         outline_panel.update(cx, |_outline_panel, cx| {
             assert_eq!(
                 selected_row_text(&active_editor, cx),
-                next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
+                clear_outline_metadata(next_navigated_outline_selection),
                 "Should again preserve the selection after another SelectNext call"
             );
         });
@@ -5807,7 +5811,7 @@ mod tests {
             );
             assert_eq!(
                 selected_row_text(&new_active_editor, cx),
-                next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
+                clear_outline_metadata(next_navigated_outline_selection),
                 "When opening the excerpt, should navigate to the place corresponding the outline entry"
             );
         });
@@ -5909,11 +5913,11 @@ mod tests {
                 format!(
                     r#"one/
   a.txt
-    search: aaa aaa  <==== selected
-    search: aaa aaa
+    search: «aaa» aaa  <==== selected
+    search: aaa «aaa»
 two/
   b.txt
-    search: a aaa"#,
+    search: a «aaa»"#,
                 ),
             );
         });
@@ -5939,7 +5943,7 @@ two/
   a.txt  <==== selected
 two/
   b.txt
-    search: a aaa"#,
+    search: a «aaa»"#,
                 ),
             );
         });
@@ -5988,7 +5992,7 @@ two/  <==== selected"#,
   a.txt
 two/  <==== selected
   b.txt
-    search: a aaa"#,
+    search: a «aaa»"#,
                 )
             );
         });
@@ -6453,18 +6457,18 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}  <==== selected
+      search: {{ "something": "«static»" }}  <==== selected
   src/
     app/(site)/
       (about)/jobs/[slug]/
         page.tsx
-          search: static
+          search: «static»
       (blog)/post/[slug]/
         page.tsx
-          search: static
+          search: «static»
     components/
       ErrorBoundary.tsx
-        search: static"#
+        search: «static»"#
                 )
             );
         });
@@ -6492,12 +6496,12 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}
+      search: {{ "something": "«static»" }}
   src/
     app/(site)/  <==== selected
     components/
       ErrorBoundary.tsx
-        search: static"#
+        search: «static»"#
                 )
             );
         });
@@ -6522,12 +6526,12 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}
+      search: {{ "something": "«static»" }}
   src/
     app/(site)/
     components/
       ErrorBoundary.tsx
-        search: static  <==== selected"#
+        search: «static»  <==== selected"#
                 )
             );
         });
@@ -6556,7 +6560,7 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}
+      search: {{ "something": "«static»" }}
   src/
     app/(site)/
     components/
@@ -6589,12 +6593,66 @@ outline: struct OutlineEntryExcerpt
                     r#"frontend-project/
   public/lottie/
     syntax-tree.json
-      search: {{ "something": "static" }}
+      search: {{ "something": "«static»" }}
+  src/
+    app/(site)/
+    components/
+      ErrorBoundary.tsx  <==== selected
+        search: «static»"#
+                )
+            );
+        });
+
+        outline_panel.update_in(cx, |outline_panel, window, cx| {
+            outline_panel.collapse_all_entries(&CollapseAllEntries, window, cx);
+        });
+        cx.executor()
+            .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
+        cx.run_until_parked();
+        outline_panel.update(cx, |outline_panel, cx| {
+            assert_eq!(
+                display_entries(
+                    &project,
+                    &snapshot(outline_panel, cx),
+                    &outline_panel.cached_entries,
+                    outline_panel.selected_entry(),
+                    cx,
+                ),
+                format!(r#"frontend-project/"#)
+            );
+        });
+
+        outline_panel.update_in(cx, |outline_panel, window, cx| {
+            outline_panel.expand_all_entries(&ExpandAllEntries, window, cx);
+        });
+        cx.executor()
+            .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
+        cx.run_until_parked();
+        outline_panel.update(cx, |outline_panel, cx| {
+            assert_eq!(
+                display_entries(
+                    &project,
+                    &snapshot(outline_panel, cx),
+                    &outline_panel.cached_entries,
+                    outline_panel.selected_entry(),
+                    cx,
+                ),
+                format!(
+                    r#"frontend-project/
+  public/lottie/
+    syntax-tree.json
+      search: {{ "something": "«static»" }}
   src/
     app/(site)/
+      (about)/jobs/[slug]/
+        page.tsx
+          search: «static»
+      (blog)/post/[slug]/
+        page.tsx
+          search: «static»
     components/
       ErrorBoundary.tsx  <==== selected
-        search: static"#
+        search: «static»"#
                 )
             );
         });
@@ -6700,16 +6758,21 @@ outline: struct OutlineEntryExcerpt
                     }
                 },
                 PanelEntry::Search(search_entry) => {
-                    format!(
-                        "search: {}",
-                        search_entry
-                            .render_data
-                            .get_or_init(|| SearchData::new(
-                                &search_entry.match_range,
-                                multi_buffer_snapshot
-                            ))
-                            .context_text
-                    )
+                    let search_data = search_entry.render_data.get_or_init(|| {
+                        SearchData::new(&search_entry.match_range, multi_buffer_snapshot)
+                    });
+                    let mut search_result = String::new();
+                    let mut last_end = 0;
+                    for range in &search_data.search_match_indices {
+                        search_result.push_str(&search_data.context_text[last_end..range.start]);
+                        search_result.push('«');
+                        search_result.push_str(&search_data.context_text[range.start..range.end]);
+                        search_result.push('»');
+                        last_end = range.end;
+                    }
+                    search_result.push_str(&search_data.context_text[last_end..]);
+
+                    format!("search: {search_result}")
                 }
             };
 
@@ -6732,6 +6795,7 @@ outline: struct OutlineEntryExcerpt
             workspace::init_settings(cx);
             Project::init_settings(cx);
             project_search::init(cx);
+            buffer_search::init(cx);
             super::init(cx);
         });
     }
@@ -7510,4 +7574,335 @@ outline: fn main()"
             );
         });
     }
+
+    #[gpui::test]
+    async fn test_outline_expand_collapse_all(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            "/test",
+            json!({
+                "src": {
+                    "lib.rs": indoc!("
+                            mod outer {
+                                pub struct OuterStruct {
+                                    field: String,
+                                }
+                                impl OuterStruct {
+                                    pub fn new() -> Self {
+                                        Self { field: String::new() }
+                                    }
+                                    pub fn method(&self) {
+                                        println!(\"{}\", self.field);
+                                    }
+                                }
+                                mod inner {
+                                    pub fn inner_function() {
+                                        let x = 42;
+                                        println!(\"{}\", x);
+                                    }
+                                    pub struct InnerStruct {
+                                        value: i32,
+                                    }
+                                }
+                            }
+                            fn main() {
+                                let s = outer::OuterStruct::new();
+                                s.method();
+                            }
+                        "),
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
+        project.read_with(cx, |project, _| {
+            project.languages().add(Arc::new(
+                rust_lang()
+                    .with_outline_query(
+                        r#"
+                            (struct_item
+                                (visibility_modifier)? @context
+                                "struct" @context
+                                name: (_) @name) @item
+                            (impl_item
+                                "impl" @context
+                                trait: (_)? @context
+                                "for"? @context
+                                type: (_) @context
+                                body: (_)) @item
+                            (function_item
+                                (visibility_modifier)? @context
+                                "fn" @context
+                                name: (_) @name
+                                parameters: (_) @context) @item
+                            (mod_item
+                                (visibility_modifier)? @context
+                                "mod" @context
+                                name: (_) @name) @item
+                            (enum_item
+                                (visibility_modifier)? @context
+                                "enum" @context
+                                name: (_) @name) @item
+                            (field_declaration
+                                (visibility_modifier)? @context
+                                name: (_) @name
+                                ":" @context
+                                type: (_) @context) @item
+                            "#,
+                    )
+                    .unwrap(),
+            ))
+        });
+        let workspace = add_outline_panel(&project, cx).await;
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+        let outline_panel = outline_panel(&workspace, cx);
+
+        outline_panel.update_in(cx, |outline_panel, window, cx| {
+            outline_panel.set_active(true, window, cx)
+        });
+
+        workspace
+            .update(cx, |workspace, window, cx| {
+                workspace.open_abs_path(
+                    PathBuf::from("/test/src/lib.rs"),
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                )
+            })
+            .unwrap()
+            .await
+            .unwrap();
+
+        cx.executor()
+            .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
+        cx.run_until_parked();
+
+        // Force another update cycle to ensure outlines are fetched
+        outline_panel.update_in(cx, |panel, window, cx| {
+            panel.update_non_fs_items(window, cx);
+            panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
+        });
+        cx.executor()
+            .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
+        cx.run_until_parked();
+
+        outline_panel.update(cx, |outline_panel, cx| {
+            assert_eq!(
+                display_entries(
+                    &project,
+                    &snapshot(outline_panel, cx),
+                    &outline_panel.cached_entries,
+                    outline_panel.selected_entry(),
+                    cx,
+                ),
+                indoc!(
+                    "
+outline: mod outer  <==== selected
+  outline: pub struct OuterStruct
+    outline: field: String
+  outline: impl OuterStruct
+    outline: pub fn new()
+    outline: pub fn method(&self)
+  outline: mod inner
+    outline: pub fn inner_function()
+    outline: pub struct InnerStruct
+      outline: value: i32
+outline: fn main()"
+                )
+            );
+        });
+
+        let _parent_outline = outline_panel
+            .read_with(cx, |panel, _cx| {
+                panel
+                    .cached_entries
+                    .iter()
+                    .find_map(|entry| match &entry.entry {
+                        PanelEntry::Outline(OutlineEntry::Outline(outline))
+                            if panel
+                                .outline_children_cache
+                                .get(&outline.buffer_id)
+                                .and_then(|children_map| {
+                                    let key =
+                                        (outline.outline.range.clone(), outline.outline.depth);
+                                    children_map.get(&key)
+                                })
+                                .copied()
+                                .unwrap_or(false) =>
+                        {
+                            Some(entry.entry.clone())
+                        }
+                        _ => None,
+                    })
+            })
+            .expect("Should find an outline with children");
+
+        // Collapse all entries
+        outline_panel.update_in(cx, |panel, window, cx| {
+            panel.collapse_all_entries(&CollapseAllEntries, window, cx);
+        });
+        cx.executor()
+            .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
+        cx.run_until_parked();
+
+        let expected_collapsed_output = indoc!(
+            "
+        outline: mod outer  <==== selected
+        outline: fn main()"
+        );
+
+        outline_panel.update(cx, |panel, cx| {
+            assert_eq! {
+                display_entries(
+                    &project,
+                    &snapshot(panel, cx),
+                    &panel.cached_entries,
+                    panel.selected_entry(),
+                    cx,
+                ),
+                expected_collapsed_output
+            };
+        });
+
+        // Expand all entries
+        outline_panel.update_in(cx, |panel, window, cx| {
+            panel.expand_all_entries(&ExpandAllEntries, window, cx);
+        });
+        cx.executor()
+            .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
+        cx.run_until_parked();
+
+        let expected_expanded_output = indoc!(
+            "
+        outline: mod outer  <==== selected
+          outline: pub struct OuterStruct
+            outline: field: String
+          outline: impl OuterStruct
+            outline: pub fn new()
+            outline: pub fn method(&self)
+          outline: mod inner
+            outline: pub fn inner_function()
+            outline: pub struct InnerStruct
+              outline: value: i32
+        outline: fn main()"
+        );
+
+        outline_panel.update(cx, |panel, cx| {
+            assert_eq! {
+                display_entries(
+                    &project,
+                    &snapshot(panel, cx),
+                    &panel.cached_entries,
+                    panel.selected_entry(),
+                    cx,
+                ),
+                expected_expanded_output
+            };
+        });
+    }
+
+    #[gpui::test]
+    async fn test_buffer_search(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            "/test",
+            json!({
+                "foo.txt": r#"<_constitution>
+
+</_constitution>
+
+
+
+## 📊 Output
+
+| Field          | Meaning                |
+"#
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
+        let workspace = add_outline_panel(&project, cx).await;
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+        let editor = workspace
+            .update(cx, |workspace, window, cx| {
+                workspace.open_abs_path(
+                    PathBuf::from("/test/foo.txt"),
+                    OpenOptions {
+                        visible: Some(OpenVisible::All),
+                        ..OpenOptions::default()
+                    },
+                    window,
+                    cx,
+                )
+            })
+            .unwrap()
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap();
+
+        let search_bar = workspace
+            .update(cx, |_, window, cx| {
+                cx.new(|cx| {
+                    let mut search_bar = BufferSearchBar::new(None, window, cx);
+                    search_bar.set_active_pane_item(Some(&editor), window, cx);
+                    search_bar.show(window, cx);
+                    search_bar
+                })
+            })
+            .unwrap();
+
+        let outline_panel = outline_panel(&workspace, cx);
+
+        outline_panel.update_in(cx, |outline_panel, window, cx| {
+            outline_panel.set_active(true, window, cx)
+        });
+
+        search_bar
+            .update_in(cx, |search_bar, window, cx| {
+                search_bar.search("  ", None, true, window, cx)
+            })
+            .await
+            .unwrap();
+
+        cx.executor()
+            .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
+        cx.run_until_parked();
+
+        outline_panel.update(cx, |outline_panel, cx| {
+            assert_eq!(
+                display_entries(
+                    &project,
+                    &snapshot(outline_panel, cx),
+                    &outline_panel.cached_entries,
+                    outline_panel.selected_entry(),
+                    cx,
+                ),
+                "search: | Field«  »        | Meaning                |  <==== selected
+search: | Field  «  »      | Meaning                |
+search: | Field    «  »    | Meaning                |
+search: | Field      «  »  | Meaning                |
+search: | Field        «  »| Meaning                |
+search: | Field          | Meaning«  »              |
+search: | Field          | Meaning  «  »            |
+search: | Field          | Meaning    «  »          |
+search: | Field          | Meaning      «  »        |
+search: | Field          | Meaning        «  »      |
+search: | Field          | Meaning          «  »    |
+search: | Field          | Meaning            «  »  |
+search: | Field          | Meaning              «  »|"
+            );
+        });
+    }
 }