Display buffer/project search entries in the outline panel (#16589)

Kirill Bulatov created

Prototypes a way to display new entities in the outline panel, making it
less outline.
The design is not final and might be adjusted, but the workflow seems to
be solid enough to keep and iron it out.

* Now, when any project search buffer is activated (multi buffer mode),
or buffer search is open (singleton buffer mode, but is available for
search usages multi buffer too — in that case buffer search overrides
multi buffer's contents display), outline panel displays all search
matches instead of the outline items.

Outline items are not displayed at all during those cases, unless the
buffer search is closed, or a new buffer gets opened, of an active
buffer search matches zero items.


https://github.com/user-attachments/assets/4a3e4faa-7f75-4522-96bb-3761872c753a


* For the multi buffer mode, search matches are grouped under
directories and files, same as outline items

![Screenshot 2024-08-21 at 14 55
01](https://github.com/user-attachments/assets/6dac75e4-be4e-4338-917b-37a32c285b71)


* For buffer search , search matches are displayed one under another


![image](https://github.com/user-attachments/assets/9efcff85-d4c7-4462-9ef5-f76b08e59f20)


For both cases, the entire match line is taken and rendered, with the
hover tooltip showing the line number.
So far it does not look very bad, but I am certain there are bad cases
with long lines and bad indents where it looks not optimal — this part
most probably will be redesigned after some trial.
Or, maybe, it's ok to leave the current state if the horizontal
scrollbar is added?

Clicking the item navigates to the item's position in the editor.
Search item lines are also possible to filter with the outline panel's
filter input.

* Inline panel is now possible to "pin" to track a currently active
editor, to display outlines/search results for that editor even if
another item is activated afterwards:


![image](https://github.com/user-attachments/assets/75fb78c3-0e5f-47b4-ba3a-485c71d7e342)

This is useful in combination with project search results display: now
it's possible to leave the search results pinned in the outline panel
and jump to every search result and back.

If the item the panel was pinned to gets closed, the panel gets back to
its regular state, showing outlines/search results for a currently
active editor.


Release Notes:

- Added a way to display buffer/project search entries in the outline
panel

Change summary

Cargo.lock                                |   2 
assets/icons/pin.svg                      |   1 
assets/icons/unpin.svg                    |   1 
assets/keymaps/default-linux.json         |   2 
assets/keymaps/default-macos.json         |   2 
crates/editor/src/items.rs                |  23 
crates/outline_panel/Cargo.toml           |   2 
crates/outline_panel/src/outline_panel.rs | 607 +++++++++++++++---------
crates/search/src/buffer_search.rs        |   6 
crates/search/src/project_search.rs       |   8 
crates/ui/src/components/icon.rs          |   4 
crates/workspace/src/searchable.rs        |   3 
12 files changed, 416 insertions(+), 245 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7387,9 +7387,11 @@ dependencies = [
  "menu",
  "project",
  "schemars",
+ "search",
  "serde",
  "serde_json",
  "settings",
+ "theme",
  "util",
  "workspace",
  "worktree",

assets/icons/pin.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-pin"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>

assets/icons/unpin.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-pin-off"><path d="M12 17v5"/><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89"/><path d="m2 2 20 20"/><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"/></svg>

assets/keymaps/default-linux.json 🔗

@@ -524,7 +524,7 @@
       "ctrl-alt-c": "outline_panel::CopyPath",
       "alt-ctrl-shift-c": "outline_panel::CopyRelativePath",
       "alt-ctrl-r": "outline_panel::RevealInFileManager",
-      "space": "outline_panel::Open",
+      "space": ["outline_panel::Open", { "change_selection": false }],
       "shift-down": "menu::SelectNext",
       "shift-up": "menu::SelectPrev"
     }

assets/keymaps/default-macos.json 🔗

@@ -536,7 +536,7 @@
       "cmd-alt-c": "outline_panel::CopyPath",
       "alt-cmd-shift-c": "outline_panel::CopyRelativePath",
       "alt-cmd-r": "outline_panel::RevealInFileManager",
-      "space": "outline_panel::Open",
+      "space": ["outline_panel::Open", { "change_selection": false }],
       "shift-down": "menu::SelectNext",
       "shift-up": "menu::SelectPrev"
     }

crates/editor/src/items.rs 🔗

@@ -1144,16 +1144,37 @@ pub(crate) enum BufferSearchHighlights {}
 impl SearchableItem for Editor {
     type Match = Range<Anchor>;
 
+    fn get_matches(&self, _: &mut WindowContext) -> Vec<Range<Anchor>> {
+        self.background_highlights
+            .get(&TypeId::of::<BufferSearchHighlights>())
+            .map_or(Vec::new(), |(_color, ranges)| {
+                ranges.iter().map(|range| range.clone()).collect()
+            })
+    }
+
     fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
-        self.clear_background_highlights::<BufferSearchHighlights>(cx);
+        if self
+            .clear_background_highlights::<BufferSearchHighlights>(cx)
+            .is_some()
+        {
+            cx.emit(SearchEvent::MatchesInvalidated);
+        }
     }
 
     fn update_matches(&mut self, matches: &[Range<Anchor>], cx: &mut ViewContext<Self>) {
+        let existing_range = self
+            .background_highlights
+            .get(&TypeId::of::<BufferSearchHighlights>())
+            .map(|(_, range)| range.as_ref());
+        let updated = existing_range != Some(matches);
         self.highlight_background::<BufferSearchHighlights>(
             matches,
             |theme| theme.search_match_background,
             cx,
         );
+        if updated {
+            cx.emit(SearchEvent::MatchesInvalidated);
+        }
     }
 
     fn has_filtered_search_ranges(&mut self) -> bool {

crates/outline_panel/Cargo.toml 🔗

@@ -26,9 +26,11 @@ log.workspace = true
 menu.workspace = true
 project.workspace = true
 schemars.workspace = true
+search.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
+theme.workspace = true
 util.workspace = true
 worktree.workspace = true
 workspace.workspace = true

crates/outline_panel/src/outline_panel.rs 🔗

@@ -1,11 +1,13 @@
 mod outline_panel_settings;
 
 use std::{
+    cell::OnceCell,
     cmp,
     ops::Range,
     path::{Path, PathBuf},
     sync::{atomic::AtomicBool, Arc},
     time::Duration,
+    u32,
 };
 
 use anyhow::Context;
@@ -14,18 +16,19 @@ use db::kvp::KEY_VALUE_STORE;
 use editor::{
     display_map::ToDisplayPoint,
     items::{entry_git_aware_label_color, entry_label_color},
-    scroll::ScrollAnchor,
-    DisplayPoint, Editor, EditorEvent, ExcerptId, ExcerptRange,
+    scroll::{Autoscroll, ScrollAnchor},
+    AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, EditorMode, ExcerptId, ExcerptRange,
+    MultiBufferSnapshot, RangeToAnchorExt,
 };
 use file_icons::FileIcons;
 use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
 use gpui::{
-    actions, anchored, deferred, div, px, uniform_list, Action, AnyElement, AppContext,
-    AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, ElementId, EntityId,
-    EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement, KeyContext, Model,
-    MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, SharedString, Stateful,
-    Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext,
-    WeakView, WindowContext,
+    actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
+    AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, ElementId,
+    EventEmitter, FocusHandle, FocusableView, HighlightStyle, InteractiveElement, IntoElement,
+    KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
+    SharedString, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext,
+    VisualContext, WeakView, WindowContext,
 };
 use itertools::Itertools;
 use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
@@ -33,36 +36,46 @@ use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrev};
 
 use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings};
 use project::{File, Fs, Item, Project};
+use search::{BufferSearchBar, ProjectSearchView};
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
+use theme::SyntaxTheme;
 use util::{RangeExt, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     item::ItemHandle,
+    searchable::{SearchEvent, SearchableItem},
     ui::{
-        h_flex, v_flex, ActiveTheme, Color, ContextMenu, FluentBuilder, HighlightedLabel, Icon,
-        IconName, IconSize, Label, LabelCommon, ListItem, Selectable, Spacing, StyledExt,
-        StyledTypography,
+        h_flex, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, FluentBuilder,
+        HighlightedLabel, Icon, IconButton, IconButtonShape, IconName, IconSize, Label,
+        LabelCommon, ListItem, Selectable, Spacing, StyledExt, StyledTypography, Tooltip,
     },
     OpenInTerminal, Workspace,
 };
 use worktree::{Entry, ProjectEntryId, WorktreeId};
 
+#[derive(Clone, Default, Deserialize, PartialEq)]
+pub struct Open {
+    change_selection: bool,
+}
+
+impl_actions!(outline_panel, [Open]);
+
 actions!(
     outline_panel,
     [
-        ExpandSelectedEntry,
-        CollapseSelectedEntry,
-        ExpandAllEntries,
         CollapseAllEntries,
+        CollapseSelectedEntry,
         CopyPath,
         CopyRelativePath,
+        ExpandAllEntries,
+        ExpandSelectedEntry,
+        FoldDirectory,
+        ToggleActiveEditorPin,
         RevealInFileManager,
-        Open,
+        SelectParent,
         ToggleFocus,
         UnfoldDirectory,
-        FoldDirectory,
-        SelectParent,
     ]
 );
 
@@ -75,7 +88,9 @@ pub struct OutlinePanel {
     fs: Arc<dyn Fs>,
     width: Option<Pixels>,
     project: Model<Project>,
+    workspace: View<Workspace>,
     active: bool,
+    pinned: bool,
     scroll_handle: UniformListScrollHandle,
     context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
     focus_handle: FocusHandle,
@@ -85,16 +100,47 @@ pub struct OutlinePanel {
     fs_children_count: HashMap<WorktreeId, HashMap<Arc<Path>, FsChildren>>,
     collapsed_entries: HashSet<CollapsedEntry>,
     unfolded_dirs: HashMap<WorktreeId, BTreeSet<ProjectEntryId>>,
-    selected_entry: Option<EntryOwned>,
+    selected_entry: SelectedEntry,
     active_item: Option<ActiveItem>,
     _subscriptions: Vec<Subscription>,
     updating_fs_entries: bool,
     fs_entries_update_task: Task<()>,
     cached_entries_update_task: Task<()>,
+    reveal_selection_task: Task<anyhow::Result<()>>,
     outline_fetch_tasks: HashMap<(BufferId, ExcerptId), Task<()>>,
     excerpts: HashMap<BufferId, HashMap<ExcerptId, Excerpt>>,
-    cached_entries_with_depth: Vec<CachedEntry>,
+    cached_entries: Vec<CachedEntry>,
     filter_editor: View<Editor>,
+    mode: ItemsDisplayMode,
+    search: Option<(SearchKind, String)>,
+    search_matches: Vec<Range<editor::Anchor>>,
+}
+
+#[derive(Debug)]
+enum SelectedEntry {
+    Invalidated(Option<PanelEntry>),
+    Valid(PanelEntry),
+    None,
+}
+
+impl SelectedEntry {
+    fn invalidate(&mut self) {
+        match std::mem::replace(self, SelectedEntry::None) {
+            Self::Valid(entry) => *self = Self::Invalidated(Some(entry)),
+            Self::None => *self = Self::Invalidated(None),
+            other => *self = other,
+        }
+    }
+
+    fn is_invalidated(&self) -> bool {
+        matches!(self, Self::Invalidated(_))
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ItemsDisplayMode {
+    Search,
+    Outline,
 }
 
 #[derive(Debug, Clone, Copy, Default)]
@@ -113,7 +159,7 @@ impl FsChildren {
 struct CachedEntry {
     depth: usize,
     string_match: Option<StringMatch>,
-    entry: EntryOwned,
+    entry: PanelEntry,
 }
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -161,54 +207,152 @@ enum ExcerptOutlines {
     NotFetched,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq)]
-enum EntryOwned {
-    Entry(FsEntry),
+#[derive(Clone, Debug)]
+enum PanelEntry {
+    Fs(FsEntry),
     FoldedDirs(WorktreeId, Vec<Entry>),
-    Excerpt(BufferId, ExcerptId, ExcerptRange<language::Anchor>),
-    Outline(BufferId, ExcerptId, Outline),
+    Outline(OutlineEntry),
+    Search(SearchEntry),
 }
 
-impl EntryOwned {
-    fn to_ref_entry(&self) -> EntryRef<'_> {
-        match self {
-            Self::Entry(entry) => EntryRef::Entry(entry),
-            Self::FoldedDirs(worktree_id, dirs) => EntryRef::FoldedDirs(*worktree_id, dirs),
-            Self::Excerpt(buffer_id, excerpt_id, range) => {
-                EntryRef::Excerpt(*buffer_id, *excerpt_id, range)
-            }
-            Self::Outline(buffer_id, excerpt_id, outline) => {
-                EntryRef::Outline(*buffer_id, *excerpt_id, outline)
-            }
-        }
-    }
+#[derive(Clone, Debug)]
+struct SearchEntry {
+    match_range: Range<editor::Anchor>,
+    same_line_matches: Vec<Range<editor::Anchor>>,
+    kind: SearchKind,
+    render_data: Option<OnceCell<SearchData>>,
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum EntryRef<'a> {
-    Entry(&'a FsEntry),
-    FoldedDirs(WorktreeId, &'a [Entry]),
-    Excerpt(BufferId, ExcerptId, &'a ExcerptRange<language::Anchor>),
-    Outline(BufferId, ExcerptId, &'a Outline),
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum SearchKind {
+    Project,
+    Buffer,
 }
 
-impl EntryRef<'_> {
-    fn to_owned_entry(&self) -> EntryOwned {
-        match self {
-            &Self::Entry(entry) => EntryOwned::Entry(entry.clone()),
-            &Self::FoldedDirs(worktree_id, dirs) => {
-                EntryOwned::FoldedDirs(worktree_id, dirs.to_vec())
-            }
-            &Self::Excerpt(buffer_id, excerpt_id, range) => {
-                EntryOwned::Excerpt(buffer_id, excerpt_id, range.clone())
+#[derive(Clone, Debug)]
+struct SearchData {
+    context_range: Range<editor::Anchor>,
+    context_text: String,
+    highlight_ranges: Vec<(Range<usize>, HighlightStyle)>,
+    search_match_indices: Vec<Range<usize>>,
+}
+
+impl PartialEq for PanelEntry {
+    fn eq(&self, other: &Self) -> bool {
+        match (self, other) {
+            (Self::Fs(a), Self::Fs(b)) => a == b,
+            (Self::FoldedDirs(a1, a2), Self::FoldedDirs(b1, b2)) => a1 == b1 && a2 == b2,
+            (Self::Outline(a), Self::Outline(b)) => a == b,
+            (
+                Self::Search(SearchEntry {
+                    match_range: match_range_a,
+                    kind: kind_a,
+                    ..
+                }),
+                Self::Search(SearchEntry {
+                    match_range: match_range_b,
+                    kind: kind_b,
+                    ..
+                }),
+            ) => match_range_a == match_range_b && kind_a == kind_b,
+            _ => false,
+        }
+    }
+}
+
+impl Eq for PanelEntry {}
+
+impl SearchData {
+    fn new(
+        kind: SearchKind,
+        match_range: &Range<editor::Anchor>,
+        multi_buffer_snapshot: &MultiBufferSnapshot,
+        theme: &SyntaxTheme,
+    ) -> Self {
+        let match_point_range = match_range.to_point(&multi_buffer_snapshot);
+        let entire_row_range_start = language::Point::new(match_point_range.start.row, 0);
+        let entire_row_range_end = multi_buffer_snapshot.clip_point(
+            language::Point::new(match_point_range.end.row, u32::MAX),
+            Bias::Right,
+        );
+        let entire_row_range =
+            (entire_row_range_start..entire_row_range_end).to_anchors(&multi_buffer_snapshot);
+        let entire_row_offset_range = entire_row_range.to_offset(&multi_buffer_snapshot);
+        let match_offset_range = match_range.to_offset(&multi_buffer_snapshot);
+        let mut search_match_indices = vec![
+            match_offset_range.start - entire_row_offset_range.start
+                ..match_offset_range.end - entire_row_offset_range.start,
+        ];
+
+        let mut left_whitespaces_count = 0;
+        let mut non_whitespace_symbol_occurred = false;
+        let mut offset = entire_row_offset_range.start;
+        let mut entire_row_text = String::new();
+        let mut highlight_ranges = Vec::new();
+        for mut chunk in multi_buffer_snapshot.chunks(
+            entire_row_offset_range.start..entire_row_offset_range.end,
+            true,
+        ) {
+            if !non_whitespace_symbol_occurred {
+                for c in chunk.text.chars() {
+                    if c.is_whitespace() {
+                        left_whitespaces_count += 1;
+                    } else {
+                        non_whitespace_symbol_occurred = true;
+                        break;
+                    }
+                }
             }
-            &Self::Outline(buffer_id, excerpt_id, outline) => {
-                EntryOwned::Outline(buffer_id, excerpt_id, outline.clone())
+
+            if chunk.text.len() > entire_row_offset_range.end - offset {
+                chunk.text = &chunk.text[0..(entire_row_offset_range.end - offset)];
+                offset = entire_row_offset_range.end;
+            } else {
+                offset += chunk.text.len();
+            }
+            let style = chunk
+                .syntax_highlight_id
+                .and_then(|highlight| highlight.style(theme));
+            if let Some(style) = style {
+                let start = entire_row_text.len();
+                let end = start + chunk.text.len();
+                highlight_ranges.push((start..end, style));
+            }
+            entire_row_text.push_str(chunk.text);
+            if offset >= entire_row_offset_range.end {
+                break;
             }
         }
+
+        if let SearchKind::Buffer = kind {
+            left_whitespaces_count = 0;
+        }
+        highlight_ranges.iter_mut().for_each(|(range, _)| {
+            range.start = range.start.saturating_sub(left_whitespaces_count);
+            range.end = range.end.saturating_sub(left_whitespaces_count);
+        });
+        search_match_indices.iter_mut().for_each(|range| {
+            range.start = range.start.saturating_sub(left_whitespaces_count);
+            range.end = range.end.saturating_sub(left_whitespaces_count);
+        });
+        let trimmed_row_offset_range =
+            entire_row_offset_range.start + left_whitespaces_count..entire_row_offset_range.end;
+        let trimmed_text = entire_row_text[left_whitespaces_count..].to_owned();
+        Self {
+            highlight_ranges,
+            search_match_indices,
+            context_range: trimmed_row_offset_range.to_anchors(&multi_buffer_snapshot),
+            context_text: trimmed_text,
+        }
     }
 }
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum OutlineEntry {
+    Excerpt(BufferId, ExcerptId, ExcerptRange<language::Anchor>),
+    Outline(BufferId, ExcerptId, Outline),
+}
+
 #[derive(Clone, Debug, Eq)]
 enum FsEntry {
     ExternalFile(BufferId, Vec<ExcerptId>),
@@ -233,8 +377,8 @@ impl PartialEq for FsEntry {
 }
 
 struct ActiveItem {
-    item_id: EntityId,
     active_editor: WeakView<Editor>,
+    _buffer_search_subscription: Subscription,
     _editor_subscrpiption: Subscription,
 }
 
@@ -297,6 +441,7 @@ impl OutlinePanel {
 
     fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
         let project = workspace.project().clone();
+        let workspace_handle = cx.view().clone();
         let outline_panel = cx.new_view(|cx| {
             let filter_editor = cx.new_view(|cx| {
                 let mut editor = Editor::single_line(cx);
@@ -319,19 +464,11 @@ impl OutlinePanel {
                     .expect("have a &mut Workspace"),
                 move |outline_panel, workspace, event, cx| {
                     if let workspace::Event::ActiveItemChanged = event {
-                        if let Some(new_active_editor) = workspace
-                            .read(cx)
-                            .active_item(cx)
-                            .and_then(|item| item.act_as::<Editor>(cx))
+                        if let Some(new_active_editor) =
+                            workspace_active_editor(workspace.read(cx), cx)
                         {
-                            let active_editor_updated = outline_panel
-                                .active_item
-                                .as_ref()
-                                .map_or(true, |active_item| {
-                                    active_item.item_id != new_active_editor.item_id()
-                                });
-                            if active_editor_updated {
-                                outline_panel.replace_visible_entries(new_active_editor, cx);
+                            if outline_panel.should_replace_active_editor(&new_active_editor) {
+                                outline_panel.replace_active_editor(new_active_editor, cx);
                             }
                         } else {
                             outline_panel.clear_previous(cx);
@@ -355,18 +492,23 @@ impl OutlinePanel {
             });
 
             let mut outline_panel = Self {
+                mode: ItemsDisplayMode::Outline,
                 active: false,
-                project: project.clone(),
+                pinned: false,
+                workspace: workspace_handle,
+                project,
                 fs: workspace.app_state().fs.clone(),
                 scroll_handle: UniformListScrollHandle::new(),
                 focus_handle,
                 filter_editor,
                 fs_entries: Vec::new(),
+                search_matches: Vec::new(),
+                search: None,
                 fs_entries_depth: HashMap::default(),
                 fs_children_count: HashMap::default(),
                 collapsed_entries: HashSet::default(),
                 unfolded_dirs: HashMap::default(),
-                selected_entry: None,
+                selected_entry: SelectedEntry::None,
                 context_menu: None,
                 width: None,
                 active_item: None,
@@ -374,9 +516,10 @@ impl OutlinePanel {
                 updating_fs_entries: false,
                 fs_entries_update_task: Task::ready(()),
                 cached_entries_update_task: Task::ready(()),
+                reveal_selection_task: Task::ready(Ok(())),
                 outline_fetch_tasks: HashMap::default(),
                 excerpts: HashMap::default(),
-                cached_entries_with_depth: Vec::new(),
+                cached_entries: Vec::new(),
                 _subscriptions: vec![
                     settings_subscription,
                     icons_subscription,
@@ -385,11 +528,8 @@ impl OutlinePanel {
                     filter_update_subscription,
                 ],
             };
-            if let Some(editor) = workspace
-                .active_item(cx)
-                .and_then(|item| item.act_as::<Editor>(cx))
-            {
-                outline_panel.replace_visible_entries(editor, cx);
+            if let Some(editor) = workspace_active_editor(workspace, cx) {
+                outline_panel.replace_active_editor(editor, cx);
             }
             outline_panel
         });
@@ -422,9 +562,9 @@ impl OutlinePanel {
     }
 
     fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
-        if let Some(EntryOwned::FoldedDirs(worktree_id, entries)) = &self.selected_entry {
+        if let Some(PanelEntry::FoldedDirs(worktree_id, entries)) = self.selected_entry().cloned() {
             self.unfolded_dirs
-                .entry(*worktree_id)
+                .entry(worktree_id)
                 .or_default()
                 .extend(entries.iter().map(|entry| entry.id));
             self.update_cached_entries(None, cx);
@@ -432,21 +572,23 @@ impl OutlinePanel {
     }
 
     fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext<Self>) {
-        let (worktree_id, entry) = match &self.selected_entry {
-            Some(EntryOwned::Entry(FsEntry::Directory(worktree_id, entry))) => {
+        let (worktree_id, entry) = match self.selected_entry().cloned() {
+            Some(PanelEntry::Fs(FsEntry::Directory(worktree_id, entry))) => {
                 (worktree_id, Some(entry))
             }
-            Some(EntryOwned::FoldedDirs(worktree_id, entries)) => (worktree_id, entries.last()),
+            Some(PanelEntry::FoldedDirs(worktree_id, entries)) => {
+                (worktree_id, entries.last().cloned())
+            }
             _ => return,
         };
         let Some(entry) = entry else {
             return;
         };
-        let unfolded_dirs = self.unfolded_dirs.get_mut(worktree_id);
+        let unfolded_dirs = self.unfolded_dirs.get_mut(&worktree_id);
         let worktree = self
             .project
             .read(cx)
-            .worktree_for_id(*worktree_id, cx)
+            .worktree_for_id(worktree_id, cx)
             .map(|w| w.read(cx).snapshot());
         let Some((_, unfolded_dirs)) = worktree.zip(unfolded_dirs) else {
             return;
@@ -456,23 +598,19 @@ impl OutlinePanel {
         self.update_cached_entries(None, cx);
     }
 
-    fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
+    fn open(&mut self, open: &Open, cx: &mut ViewContext<Self>) {
         if self.filter_editor.focus_handle(cx).is_focused(cx) {
             cx.propagate()
-        } else if let Some(selected_entry) = self.selected_entry.clone() {
-            self.open_entry(&selected_entry, cx);
+        } else if let Some(selected_entry) = self.selected_entry().cloned() {
+            self.open_entry(&selected_entry, open.change_selection, cx);
         }
     }
 
     fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
         if self.filter_editor.focus_handle(cx).is_focused(cx) {
-            self.filter_editor.update(cx, |editor, cx| {
-                if editor.buffer().read(cx).len(cx) > 0 {
-                    editor.set_text("", cx);
-                }
-            });
+            self.focus_handle.focus(cx);
         } else {
-            cx.focus_view(&self.filter_editor);
+            self.filter_editor.focus_handle(cx).focus(cx);
         }
 
         if self.context_menu.is_some() {
@@ -481,12 +619,13 @@ impl OutlinePanel {
         }
     }
 
-    fn open_entry(&mut self, entry: &EntryOwned, cx: &mut ViewContext<OutlinePanel>) {
-        let Some(active_editor) = self
-            .active_item
-            .as_ref()
-            .and_then(|item| item.active_editor.upgrade())
-        else {
+    fn open_entry(
+        &mut self,
+        entry: &PanelEntry,
+        change_selection: bool,
+        cx: &mut ViewContext<OutlinePanel>,
+    ) {
+        let Some(active_editor) = self.active_editor() else {
             return;
         };
         let active_multi_buffer = active_editor.read(cx).buffer().clone();
@@ -498,9 +637,9 @@ impl OutlinePanel {
         };
 
         self.toggle_expanded(entry, cx);
-        match entry {
-            EntryOwned::FoldedDirs(..) | EntryOwned::Entry(FsEntry::Directory(..)) => {}
-            EntryOwned::Entry(FsEntry::ExternalFile(buffer_id, _)) => {
+        let scroll_target = match entry {
+            PanelEntry::FoldedDirs(..) | PanelEntry::Fs(FsEntry::Directory(..)) => None,
+            PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => {
                 let scroll_target = multi_buffer_snapshot.excerpts().find_map(
                     |(excerpt_id, buffer_snapshot, excerpt_range)| {
                         if &buffer_snapshot.remote_id() == buffer_id {
@@ -511,20 +650,9 @@ impl OutlinePanel {
                         }
                     },
                 );
-                if let Some(anchor) = scroll_target {
-                    self.selected_entry = Some(entry.clone());
-                    active_editor.update(cx, |editor, cx| {
-                        editor.set_scroll_anchor(
-                            ScrollAnchor {
-                                offset: offset_from_top,
-                                anchor,
-                            },
-                            cx,
-                        );
-                    })
-                }
+                Some(offset_from_top).zip(scroll_target)
             }
-            EntryOwned::Entry(FsEntry::File(_, file_entry, ..)) => {
+            PanelEntry::Fs(FsEntry::File(_, file_entry, ..)) => {
                 let scroll_target = self
                     .project
                     .update(cx, |project, cx| {
@@ -542,118 +670,112 @@ impl OutlinePanel {
                         multi_buffer_snapshot
                             .anchor_in_excerpt(*excerpt_id, excerpt_range.context.start)
                     });
-                if let Some(anchor) = scroll_target {
-                    self.selected_entry = Some(entry.clone());
-                    active_editor.update(cx, |editor, cx| {
-                        editor.set_scroll_anchor(
-                            ScrollAnchor {
-                                offset: offset_from_top,
-                                anchor,
-                            },
-                            cx,
-                        );
-                    })
-                }
+                Some(offset_from_top).zip(scroll_target)
             }
-            EntryOwned::Outline(_, excerpt_id, outline) => {
+            PanelEntry::Outline(OutlineEntry::Outline(_, excerpt_id, outline)) => {
                 let scroll_target = multi_buffer_snapshot
                     .anchor_in_excerpt(*excerpt_id, outline.range.start)
                     .or_else(|| {
                         multi_buffer_snapshot.anchor_in_excerpt(*excerpt_id, outline.range.end)
                     });
-                if let Some(anchor) = scroll_target {
-                    self.selected_entry = Some(entry.clone());
-                    active_editor.update(cx, |editor, cx| {
-                        editor.set_scroll_anchor(
-                            ScrollAnchor {
-                                offset: Point::default(),
-                                anchor,
-                            },
-                            cx,
-                        );
-                    })
-                }
+                Some(Point::default()).zip(scroll_target)
             }
-            EntryOwned::Excerpt(_, excerpt_id, excerpt_range) => {
+            PanelEntry::Outline(OutlineEntry::Excerpt(_, excerpt_id, excerpt_range)) => {
                 let scroll_target = multi_buffer_snapshot
                     .anchor_in_excerpt(*excerpt_id, excerpt_range.context.start);
-                if let Some(anchor) = scroll_target {
-                    self.selected_entry = Some(entry.clone());
-                    active_editor.update(cx, |editor, cx| {
-                        editor.set_scroll_anchor(
-                            ScrollAnchor {
-                                offset: Point::default(),
-                                anchor,
-                            },
-                            cx,
-                        );
-                    })
-                }
+                Some(Point::default()).zip(scroll_target)
+            }
+            PanelEntry::Search(SearchEntry { match_range, .. }) => {
+                Some((Point::default(), match_range.start))
+            }
+        };
+
+        if let Some((offset, anchor)) = scroll_target {
+            self.select_entry(entry.clone(), true, cx);
+            if change_selection {
+                active_editor.update(cx, |editor, cx| {
+                    editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                        s.select_ranges(Some(anchor..anchor))
+                    });
+                });
+                active_editor.focus_handle(cx).focus(cx);
+            } else {
+                active_editor.update(cx, |editor, cx| {
+                    editor.set_scroll_anchor(ScrollAnchor { offset, anchor }, cx);
+                });
+                self.focus_handle.focus(cx);
             }
+
+            if let PanelEntry::Search(_) = entry {
+                if let Some(active_project_search) =
+                    self.active_project_search(Some(&active_editor), cx)
+                {
+                    self.workspace.update(cx, |workspace, cx| {
+                        workspace.activate_item(&active_project_search, true, change_selection, cx)
+                    });
+                }
+            } else {
+                self.workspace.update(cx, |workspace, cx| {
+                    workspace.activate_item(&active_editor, true, change_selection, cx)
+                });
+            };
         }
     }
 
     fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        if let Some(entry_to_select) = self.selected_entry.clone().and_then(|selected_entry| {
-            self.cached_entries_with_depth
+        if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| {
+            self.cached_entries
                 .iter()
                 .map(|cached_entry| &cached_entry.entry)
-                .skip_while(|entry| entry != &&selected_entry)
+                .skip_while(|entry| entry != &selected_entry)
                 .skip(1)
                 .next()
                 .cloned()
         }) {
-            self.selected_entry = Some(entry_to_select);
-            self.autoscroll(cx);
-            cx.notify();
+            self.select_entry(entry_to_select, true, cx);
         } else {
             self.select_first(&SelectFirst {}, cx)
         }
     }
 
     fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        if let Some(entry_to_select) = self.selected_entry.clone().and_then(|selected_entry| {
-            self.cached_entries_with_depth
+        if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| {
+            self.cached_entries
                 .iter()
                 .rev()
                 .map(|cached_entry| &cached_entry.entry)
-                .skip_while(|entry| entry != &&selected_entry)
+                .skip_while(|entry| entry != &selected_entry)
                 .skip(1)
                 .next()
                 .cloned()
         }) {
-            self.selected_entry = Some(entry_to_select);
-            self.autoscroll(cx);
-            cx.notify();
+            self.select_entry(entry_to_select, true, cx);
         } else {
             self.select_first(&SelectFirst {}, cx)
         }
     }
 
     fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
-        if let Some(entry_to_select) = self.selected_entry.clone().and_then(|selected_entry| {
+        if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| {
             let mut previous_entries = self
-                .cached_entries_with_depth
+                .cached_entries
                 .iter()
                 .rev()
                 .map(|cached_entry| &cached_entry.entry)
-                .skip_while(|entry| entry != &&selected_entry)
+                .skip_while(|entry| entry != &selected_entry)
                 .skip(1);
             match &selected_entry {
-                EntryOwned::Entry(fs_entry) => match fs_entry {
+                PanelEntry::Fs(fs_entry) => match fs_entry {
                     FsEntry::ExternalFile(..) => None,
                     FsEntry::File(worktree_id, entry, ..)
                     | FsEntry::Directory(worktree_id, entry) => {
                         entry.path.parent().and_then(|parent_path| {
                             previous_entries.find(|entry| match entry {
-                                EntryOwned::Entry(FsEntry::Directory(
-                                    dir_worktree_id,
-                                    dir_entry,
-                                )) => {
+                                PanelEntry::Fs(FsEntry::Directory(dir_worktree_id, dir_entry)) => {
                                     dir_worktree_id == worktree_id
                                         && dir_entry.path.as_ref() == parent_path
                                 }
-                                EntryOwned::FoldedDirs(dirs_worktree_id, dirs) => {
+                                PanelEntry::FoldedDirs(dirs_worktree_id, dirs) => {
                                     dirs_worktree_id == worktree_id
                                         && dirs
                                             .first()
@@ -664,15 +786,13 @@ impl OutlinePanel {
                         })
                     }
                 },
-                EntryOwned::FoldedDirs(worktree_id, entries) => entries
+                PanelEntry::FoldedDirs(worktree_id, entries) => entries
                     .first()
                     .and_then(|entry| entry.path.parent())
                     .and_then(|parent_path| {
                         previous_entries.find(|entry| {
-                            if let EntryOwned::Entry(FsEntry::Directory(
-                                dir_worktree_id,
-                                dir_entry,
-                            )) = entry
+                            if let PanelEntry::Fs(FsEntry::Directory(dir_worktree_id, dir_entry)) =
+                                entry
                             {
                                 dir_worktree_id == worktree_id
                                     && dir_entry.path.as_ref() == parent_path
@@ -681,66 +801,70 @@ impl OutlinePanel {
                             }
                         })
                     }),
-                EntryOwned::Excerpt(excerpt_buffer_id, excerpt_id, _) => {
+                PanelEntry::Outline(OutlineEntry::Excerpt(excerpt_buffer_id, excerpt_id, _)) => {
                     previous_entries.find(|entry| match entry {
-                        EntryOwned::Entry(FsEntry::File(_, _, file_buffer_id, file_excerpts)) => {
+                        PanelEntry::Fs(FsEntry::File(_, _, file_buffer_id, file_excerpts)) => {
                             file_buffer_id == excerpt_buffer_id
                                 && file_excerpts.contains(&excerpt_id)
                         }
-                        EntryOwned::Entry(FsEntry::ExternalFile(file_buffer_id, file_excerpts)) => {
+                        PanelEntry::Fs(FsEntry::ExternalFile(file_buffer_id, file_excerpts)) => {
                             file_buffer_id == excerpt_buffer_id
                                 && file_excerpts.contains(&excerpt_id)
                         }
                         _ => false,
                     })
                 }
-                EntryOwned::Outline(outline_buffer_id, outline_excerpt_id, _) => previous_entries
-                    .find(|entry| {
-                        if let EntryOwned::Excerpt(excerpt_buffer_id, excerpt_id, _) = entry {
-                            outline_buffer_id == excerpt_buffer_id
-                                && outline_excerpt_id == excerpt_id
-                        } else {
-                            false
-                        }
-                    }),
+                PanelEntry::Outline(OutlineEntry::Outline(
+                    outline_buffer_id,
+                    outline_excerpt_id,
+                    _,
+                )) => previous_entries.find(|entry| {
+                    if let PanelEntry::Outline(OutlineEntry::Excerpt(
+                        excerpt_buffer_id,
+                        excerpt_id,
+                        _,
+                    )) = entry
+                    {
+                        outline_buffer_id == excerpt_buffer_id && outline_excerpt_id == excerpt_id
+                    } else {
+                        false
+                    }
+                }),
+                PanelEntry::Search(_) => {
+                    previous_entries.find(|entry| !matches!(entry, PanelEntry::Search(_)))
+                }
             }
         }) {
-            self.selected_entry = Some(entry_to_select.clone());
-            self.autoscroll(cx);
-            cx.notify();
+            self.select_entry(entry_to_select.clone(), true, cx);
         } else {
             self.select_first(&SelectFirst {}, cx);
         }
     }
 
     fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
-        if let Some(first_entry) = self.cached_entries_with_depth.iter().next() {
-            self.selected_entry = Some(first_entry.entry.clone());
-            self.autoscroll(cx);
-            cx.notify();
+        if let Some(first_entry) = self.cached_entries.iter().next() {
+            self.select_entry(first_entry.entry.clone(), true, cx);
         }
     }
 
     fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
         if let Some(new_selection) = self
-            .cached_entries_with_depth
+            .cached_entries
             .iter()
             .rev()
             .map(|cached_entry| &cached_entry.entry)
             .next()
         {
-            self.selected_entry = Some(new_selection.clone());
-            self.autoscroll(cx);
-            cx.notify();
+            self.select_entry(new_selection.clone(), true, cx);
         }
     }
 
     fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
-        if let Some(selected_entry) = self.selected_entry.clone() {
+        if let Some(selected_entry) = self.selected_entry() {
             let index = self
-                .cached_entries_with_depth
+                .cached_entries
                 .iter()
-                .position(|cached_entry| cached_entry.entry == selected_entry);
+                .position(|cached_entry| &cached_entry.entry == selected_entry);
             if let Some(index) = index {
                 self.scroll_handle.scroll_to_item(index);
                 cx.notify();
@@ -757,13 +881,13 @@ impl OutlinePanel {
     fn deploy_context_menu(
         &mut self,
         position: Point<Pixels>,
-        entry: EntryRef<'_>,
+        entry: PanelEntry,
         cx: &mut ViewContext<Self>,
     ) {
-        self.selected_entry = Some(entry.to_owned_entry());
-        let is_root = match entry {
-            EntryRef::Entry(FsEntry::File(worktree_id, entry, ..))
-            | EntryRef::Entry(FsEntry::Directory(worktree_id, entry)) => self
+        self.select_entry(entry.clone(), true, cx);
+        let is_root = match &entry {
+            PanelEntry::Fs(FsEntry::File(worktree_id, entry, ..))
+            | PanelEntry::Fs(FsEntry::Directory(worktree_id, entry)) => self
                 .project
                 .read(cx)
                 .worktree_for_id(*worktree_id, cx)
@@ -771,30 +895,30 @@ impl OutlinePanel {
                     worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id)
                 })
                 .unwrap_or(false),
-            EntryRef::FoldedDirs(worktree_id, entries) => entries
+            PanelEntry::FoldedDirs(worktree_id, entries) => entries
                 .first()
                 .and_then(|entry| {
                     self.project
                         .read(cx)
-                        .worktree_for_id(worktree_id, cx)
+                        .worktree_for_id(*worktree_id, cx)
                         .map(|worktree| {
                             worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id)
                         })
                 })
                 .unwrap_or(false),
-            EntryRef::Entry(FsEntry::ExternalFile(..)) => false,
-            EntryRef::Excerpt(..) => {
+            PanelEntry::Fs(FsEntry::ExternalFile(..)) => false,
+            PanelEntry::Outline(..) => {
                 cx.notify();
                 return;
             }
-            EntryRef::Outline(..) => {
+            PanelEntry::Search(_) => {
                 cx.notify();
                 return;
             }
         };
         let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
-        let is_foldable = auto_fold_dirs && !is_root && self.is_foldable(entry);
-        let is_unfoldable = auto_fold_dirs && !is_root && self.is_unfoldable(entry);
+        let is_foldable = auto_fold_dirs && !is_root && self.is_foldable(&entry);
+        let is_unfoldable = auto_fold_dirs && !is_root && self.is_unfoldable(&entry);
 
         let context_menu = ContextMenu::build(cx, |menu, _| {
             menu.context(self.focus_handle.clone())
@@ -824,13 +948,13 @@ impl OutlinePanel {
         cx.notify();
     }
 
-    fn is_unfoldable(&self, entry: EntryRef) -> bool {
-        matches!(entry, EntryRef::FoldedDirs(..))
+    fn is_unfoldable(&self, entry: &PanelEntry) -> bool {
+        matches!(entry, PanelEntry::FoldedDirs(..))
     }
 
-    fn is_foldable(&self, entry: EntryRef) -> bool {
+    fn is_foldable(&self, entry: &PanelEntry) -> bool {
         let (directory_worktree, directory_entry) = match entry {
-            EntryRef::Entry(FsEntry::Directory(directory_worktree, directory_entry)) => {
+            PanelEntry::Fs(FsEntry::Directory(directory_worktree, directory_entry)) => {
                 (*directory_worktree, Some(directory_entry))
             }
             _ => return false,
@@ -860,23 +984,23 @@ impl OutlinePanel {
     }
 
     fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
-        let entry_to_expand = match &self.selected_entry {
-            Some(EntryOwned::FoldedDirs(worktree_id, dir_entries)) => dir_entries
+        let entry_to_expand = match self.selected_entry() {
+            Some(PanelEntry::FoldedDirs(worktree_id, dir_entries)) => dir_entries
                 .last()
                 .map(|entry| CollapsedEntry::Dir(*worktree_id, entry.id)),
-            Some(EntryOwned::Entry(FsEntry::Directory(worktree_id, dir_entry))) => {
+            Some(PanelEntry::Fs(FsEntry::Directory(worktree_id, dir_entry))) => {
                 Some(CollapsedEntry::Dir(*worktree_id, dir_entry.id))
             }
-            Some(EntryOwned::Entry(FsEntry::File(worktree_id, _, buffer_id, _))) => {
+            Some(PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _))) => {
                 Some(CollapsedEntry::File(*worktree_id, *buffer_id))
             }
-            Some(EntryOwned::Entry(FsEntry::ExternalFile(buffer_id, _))) => {
+            Some(PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _))) => {
                 Some(CollapsedEntry::ExternalFile(*buffer_id))
             }
-            Some(EntryOwned::Excerpt(buffer_id, excerpt_id, _)) => {
+            Some(PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, _))) => {
                 Some(CollapsedEntry::Excerpt(*buffer_id, *excerpt_id))
             }
-            None | Some(EntryOwned::Outline(..)) => None,
+            None | Some(PanelEntry::Search(_)) | Some(PanelEntry::Outline(..)) => None,
         };
         let Some(collapsed_entry) = entry_to_expand else {
             return;
@@ -895,48 +1019,49 @@ impl OutlinePanel {
     }
 
     fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
-        match &self.selected_entry {
-            Some(
-                dir_entry @ EntryOwned::Entry(FsEntry::Directory(worktree_id, selected_dir_entry)),
-            ) => {
+        let Some(selected_entry) = self.selected_entry().cloned() else {
+            return;
+        };
+        match &selected_entry {
+            PanelEntry::Fs(FsEntry::Directory(worktree_id, selected_dir_entry)) => {
                 self.collapsed_entries
                     .insert(CollapsedEntry::Dir(*worktree_id, selected_dir_entry.id));
-                self.selected_entry = Some(dir_entry.clone());
+                self.select_entry(selected_entry, true, cx);
                 self.update_cached_entries(None, cx);
             }
-            Some(file_entry @ EntryOwned::Entry(FsEntry::File(worktree_id, _, buffer_id, _))) => {
+            PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _)) => {
                 self.collapsed_entries
                     .insert(CollapsedEntry::File(*worktree_id, *buffer_id));
-                self.selected_entry = Some(file_entry.clone());
+                self.select_entry(selected_entry, true, cx);
                 self.update_cached_entries(None, cx);
             }
-            Some(file_entry @ EntryOwned::Entry(FsEntry::ExternalFile(buffer_id, _))) => {
+            PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => {
                 self.collapsed_entries
                     .insert(CollapsedEntry::ExternalFile(*buffer_id));
-                self.selected_entry = Some(file_entry.clone());
+                self.select_entry(selected_entry, true, cx);
                 self.update_cached_entries(None, cx);
             }
-            Some(dirs_entry @ EntryOwned::FoldedDirs(worktree_id, dir_entries)) => {
+            PanelEntry::FoldedDirs(worktree_id, dir_entries) => {
                 if let Some(dir_entry) = dir_entries.last() {
                     if self
                         .collapsed_entries
                         .insert(CollapsedEntry::Dir(*worktree_id, dir_entry.id))
                     {
-                        self.selected_entry = Some(dirs_entry.clone());
+                        self.select_entry(selected_entry, true, cx);
                         self.update_cached_entries(None, cx);
                     }
                 }
             }
-            Some(excerpt_entry @ EntryOwned::Excerpt(buffer_id, excerpt_id, _)) => {
+            PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, _)) => {
                 if self
                     .collapsed_entries
                     .insert(CollapsedEntry::Excerpt(*buffer_id, *excerpt_id))
                 {
-                    self.selected_entry = Some(excerpt_entry.clone());
+                    self.select_entry(selected_entry, true, cx);
                     self.update_cached_entries(None, cx);
                 }
             }
-            None | Some(EntryOwned::Outline(..)) => {}
+            PanelEntry::Search(_) | PanelEntry::Outline(..) => {}
         }
     }
 

crates/search/src/buffer_search.rs 🔗

@@ -988,7 +988,11 @@ impl BufferSearchBar {
                                     .searchable_items_with_matches
                                     .get(&active_searchable_item.downgrade())
                                     .unwrap();
-                                active_searchable_item.update_matches(matches, cx);
+                                if matches.is_empty() {
+                                    active_searchable_item.clear_matches(cx);
+                                } else {
+                                    active_searchable_item.update_matches(matches, cx);
+                                }
                                 let _ = done_tx.send(());
                             }
                             cx.notify();

crates/search/src/project_search.rs 🔗

@@ -503,6 +503,10 @@ impl Item for ProjectSearchView {
 }
 
 impl ProjectSearchView {
+    pub fn get_matches(&self, cx: &AppContext) -> Vec<Range<Anchor>> {
+        self.model.read(cx).match_ranges.clone()
+    }
+
     fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) {
         self.filters_enabled = !self.filters_enabled;
         ActiveSettings::update_global(cx, |settings, cx| {
@@ -836,6 +840,10 @@ impl ProjectSearchView {
         }
     }
 
+    pub fn search_query_text(&self, cx: &WindowContext) -> String {
+        self.query_editor.read(cx).text(cx)
+    }
+
     fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
         // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
         let text = self.query_editor.read(cx).text(cx);

crates/ui/src/components/icon.rs 🔗

@@ -212,6 +212,7 @@ pub enum IconName {
     PageUp,
     Pencil,
     Person,
+    Pin,
     Play,
     Plus,
     Public,
@@ -263,6 +264,7 @@ pub enum IconName {
     Trash,
     TriangleRight,
     Undo,
+    Unpin,
     Update,
     WholeWord,
     XCircle,
@@ -381,6 +383,7 @@ impl IconName {
             IconName::PageUp => "icons/page_up.svg",
             IconName::Pencil => "icons/pencil.svg",
             IconName::Person => "icons/person.svg",
+            IconName::Pin => "icons/pin.svg",
             IconName::Play => "icons/play.svg",
             IconName::Plus => "icons/plus.svg",
             IconName::Public => "icons/public.svg",
@@ -431,6 +434,7 @@ impl IconName {
             IconName::TextSelect => "icons/text_select.svg",
             IconName::Trash => "icons/trash.svg",
             IconName::TriangleRight => "icons/triangle_right.svg",
+            IconName::Unpin => "icons/unpin.svg",
             IconName::Update => "icons/update.svg",
             IconName::Undo => "icons/undo.svg",
             IconName::WholeWord => "icons/word_search.svg",

crates/workspace/src/searchable.rs 🔗

@@ -65,6 +65,9 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
 
     fn toggle_filtered_search_ranges(&mut self, _enabled: bool, _cx: &mut ViewContext<Self>) {}
 
+    fn get_matches(&self, _: &mut WindowContext) -> Vec<Self::Match> {
+        Vec::new()
+    }
     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;