Introduce an outline panel (#12637)

Kirill Bulatov created

Adds a new panel: `OutlinePanel` which looks very close to project
panel:

<img width="256" alt="Screenshot 2024-06-10 at 23 19 05"
src="https://github.com/zed-industries/zed/assets/2690773/c66e6e78-44ec-4de8-8d60-43238bb09ae9">

has similar settings and keymap (actions work in the `OutlinePanel`
context and are under `outline_panel::` namespace), with two notable
differences:
* no "edit" actions such as cut/copy/paste/delete/etc.
* directory auto folding is enabled by default

Empty view: 
<img width="841" alt="Screenshot 2024-06-10 at 23 19 11"
src="https://github.com/zed-industries/zed/assets/2690773/dc8bf37c-5a70-4fd5-9b57-76271eb7a40c">


When editor gets active, the panel displays all related files in a tree
(similar to what the project panel does) and all related excerpts'
outlines under each file.
Same as in the project panel, directories can be expanded or collapsed,
unfolded or folded; clicking file entries or outlines scrolls the buffer
to the corresponding excerpt; changing editor's selection reveals the
corresponding outline in the panel.

The panel is applicable to any singleton buffer:
<img width="1215" alt="Screenshot 2024-06-10 at 23 19 35"
src="https://github.com/zed-industries/zed/assets/2690773/a087631f-5c2d-4d4d-ae25-30ab9731d528">

<img width="1728" alt="image"
src="https://github.com/zed-industries/zed/assets/2690773/e4f8082c-d12d-4473-8500-e8fd1051285b">

or any multi buffer:

(search multi buffer)

<img width="1728" alt="Screenshot 2024-06-10 at 23 19 41"
src="https://github.com/zed-industries/zed/assets/2690773/60f768a3-6716-4520-9b13-42da8fd15f50">

(diagnostics multi buffer)
<img width="1728" alt="image"
src="https://github.com/zed-industries/zed/assets/2690773/64e285bd-9530-4bf2-8f1f-10ee5596067c">

Release Notes:
- Added an outline panel to show a "map" of the active editor

Change summary

Cargo.lock                                         |   27 
Cargo.toml                                         |    2 
assets/icons/list_tree.svg                         |    1 
assets/keymaps/default-linux.json                  |   18 
assets/keymaps/default-macos.json                  |   13 
assets/settings/default.json                       |   23 
crates/editor/src/editor.rs                        |   28 
crates/fuzzy/src/char_bag.rs                       |    2 
crates/git/src/repository.rs                       |    2 
crates/gpui/src/color.rs                           |   14 
crates/gpui/src/style.rs                           |   24 
crates/language/src/buffer.rs                      |    5 
crates/language/src/language.rs                    |    2 
crates/language/src/outline.rs                     |   40 
crates/multi_buffer/src/multi_buffer.rs            |    7 
crates/outline/Cargo.toml                          |    1 
crates/outline/src/outline.rs                      |   41 
crates/outline_panel/Cargo.toml                    |   37 
crates/outline_panel/LICENSE-GPL                   |    1 
crates/outline_panel/src/outline_panel.rs          | 2515 ++++++++++++++++
crates/outline_panel/src/outline_panel_settings.rs |   81 
crates/ui/src/components/icon.rs                   |    2 
crates/worktree/src/worktree.rs                    |   14 
crates/zed/Cargo.toml                              |    1 
crates/zed/src/main.rs                             |    1 
crates/zed/src/zed.rs                              |   14 
crates/zed/src/zed/app_menus.rs                    |    1 
27 files changed, 2,860 insertions(+), 57 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7132,7 +7132,6 @@ dependencies = [
  "project",
  "rope",
  "serde_json",
- "settings",
  "smol",
  "theme",
  "tree-sitter-rust",
@@ -7142,6 +7141,31 @@ dependencies = [
  "workspace",
 ]
 
+[[package]]
+name = "outline_panel"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "db",
+ "editor",
+ "file_icons",
+ "git",
+ "gpui",
+ "language",
+ "log",
+ "menu",
+ "project",
+ "schemars",
+ "serde",
+ "serde_json",
+ "settings",
+ "unicase",
+ "util",
+ "workspace",
+ "worktree",
+]
+
 [[package]]
 name = "outref"
 version = "0.5.1"
@@ -13255,6 +13279,7 @@ dependencies = [
  "node_runtime",
  "notifications",
  "outline",
+ "outline_panel",
  "parking_lot",
  "profiling",
  "project",

Cargo.toml 🔗

@@ -64,6 +64,7 @@ members = [
     "crates/ollama",
     "crates/open_ai",
     "crates/outline",
+    "crates/outline_panel",
     "crates/picker",
     "crates/prettier",
     "crates/project",
@@ -212,6 +213,7 @@ notifications = { path = "crates/notifications" }
 ollama = { path = "crates/ollama" }
 open_ai = { path = "crates/open_ai" }
 outline = { path = "crates/outline" }
+outline_panel = { path = "crates/outline_panel" }
 picker = { path = "crates/picker" }
 plugin = { path = "crates/plugin" }
 plugin_macros = { path = "crates/plugin_macros" }

assets/icons/list_tree.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-list-tree"><path d="M21 12h-8"/><path d="M21 6H8"/><path d="M21 18h-8"/><path d="M3 6v4c0 1.1.9 2 2 2h3"/><path d="M3 10v6c0 1.1.9 2 2 2h3"/></svg>

assets/keymaps/default-linux.json 🔗

@@ -439,6 +439,7 @@
       "ctrl-shift-p": "command_palette::Toggle",
       "ctrl-shift-m": "diagnostics::Deploy",
       "ctrl-shift-e": "project_panel::ToggleFocus",
+      "ctrl-shift-b": "outline_panel::ToggleFocus",
       "ctrl-?": "assistant::ToggleFocus",
       "ctrl-alt-s": "workspace::SaveAll",
       "ctrl-k m": "language_selector::Toggle",
@@ -562,6 +563,18 @@
       "ctrl-enter": "project_search::SearchInNew"
     }
   },
+  {
+    "context": "OutlinePanel",
+    "bindings": {
+      "left": "project_panel::CollapseSelectedEntry",
+      "right": "project_panel::ExpandSelectedEntry",
+      "ctrl-alt-c": "project_panel::CopyPath",
+      "alt-ctrl-shift-c": "project_panel::CopyRelativePath",
+      "alt-ctrl-r": "project_panel::RevealInFinder",
+      "shift-down": "menu::SelectNext",
+      "shift-up": "menu::SelectPrev"
+    }
+  },
   {
     "context": "ProjectPanel",
     "bindings": {
@@ -583,7 +596,10 @@
       "ctrl-backspace": ["project_panel::Delete", { "skip_prompt": false }],
       "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }],
       "alt-ctrl-r": "project_panel::RevealInFinder",
-      "alt-shift-f": "project_panel::NewSearchInDirectory"
+      "alt-shift-f": "project_panel::NewSearchInDirectory",
+      "shift-down": "menu::SelectNext",
+      "shift-up": "menu::SelectPrev",
+      "escape": "menu::Cancel"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -475,6 +475,7 @@
       "cmd-shift-p": "command_palette::Toggle",
       "cmd-shift-m": "diagnostics::Deploy",
       "cmd-shift-e": "project_panel::ToggleFocus",
+      "cmd-shift-b": "outline_panel::ToggleFocus",
       "cmd-?": "assistant::ToggleFocus",
       "cmd-alt-s": "workspace::SaveAll",
       "cmd-k m": "language_selector::Toggle",
@@ -584,6 +585,18 @@
       "cmd-enter": "project_search::SearchInNew"
     }
   },
+  {
+    "context": "OutlinePanel",
+    "bindings": {
+      "left": "outline_panel::CollapseSelectedEntry",
+      "right": "outline_panel::ExpandSelectedEntry",
+      "cmd-alt-c": "outline_panel::CopyPath",
+      "alt-cmd-shift-c": "outline_panel::CopyRelativePath",
+      "alt-cmd-r": "outline_panel::RevealInFinder",
+      "shift-down": "menu::SelectNext",
+      "shift-up": "menu::SelectPrev"
+    }
+  },
   {
     "context": "ProjectPanel",
     "bindings": {

assets/settings/default.json 🔗

@@ -302,6 +302,29 @@
     /// when a directory has only one directory inside.
     "auto_fold_dirs": false
   },
+  "outline_panel": {
+    // Whether to show the outline panel button in the status bar
+    "button": true,
+    // Default width of the outline panel.
+    "default_width": 240,
+    // Where to dock the outline panel. Can be 'left' or 'right'.
+    "dock": "left",
+    // Whether to show file icons in the outline panel.
+    "file_icons": true,
+    // Whether to show folder icons or chevrons for directories in the outline panel.
+    "folder_icons": true,
+    // Whether to show the git status in the outline panel.
+    "git_status": true,
+    // Amount of indentation for nested items.
+    "indent_size": 20,
+    // Whether to reveal it in the outline panel automatically,
+    // when a corresponding outline entry becomes active.
+    // Gitignored entries are never auto revealed.
+    "auto_reveal_entries": true,
+    /// Whether to fold directories automatically
+    /// when a directory has only one directory inside.
+    "auto_fold_dirs": true
+  },
   "collaboration_panel": {
     // Whether to show the collaboration panel button in the status bar.
     "button": true,

crates/editor/src/editor.rs 🔗

@@ -149,6 +149,9 @@ use workspace::{OpenInTerminal, OpenTerminal, TabBarSettings, Toast};
 
 use crate::hover_links::find_url;
 
+pub const FILE_HEADER_HEIGHT: u8 = 1;
+pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u8 = 1;
+pub const MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT: u8 = 1;
 pub const DEFAULT_MULTIBUFFER_CONTEXT: u32 = 2;
 const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
 const MAX_LINE_LEN: usize = 1024;
@@ -529,6 +532,7 @@ pub struct Editor {
     tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>,
     tasks_update_task: Option<Task<()>>,
     previous_search_ranges: Option<Arc<[Range<Anchor>]>>,
+    file_header_size: u8,
 }
 
 #[derive(Clone)]
@@ -1651,9 +1655,8 @@ impl Editor {
             }),
             merge_adjacent: true,
         };
+        let file_header_size = if show_excerpt_controls { 3 } else { 2 };
         let display_map = cx.new_model(|cx| {
-            let file_header_size = if show_excerpt_controls { 3 } else { 2 };
-
             DisplayMap::new(
                 buffer.clone(),
                 style.font(),
@@ -1661,8 +1664,8 @@ impl Editor {
                 None,
                 show_excerpt_controls,
                 file_header_size,
-                1,
-                1,
+                MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
+                MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT,
                 fold_placeholder,
                 cx,
             )
@@ -1812,6 +1815,7 @@ impl Editor {
             git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(),
             blame: None,
             blame_subscription: None,
+            file_header_size,
             tasks: Default::default(),
             _subscriptions: vec![
                 cx.observe(&buffer, Self::on_buffer_changed),
@@ -10829,6 +10833,12 @@ impl Editor {
                 self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx);
                 cx.emit(EditorEvent::ExcerptsRemoved { ids: ids.clone() })
             }
+            multi_buffer::Event::ExcerptsEdited { ids } => {
+                cx.emit(EditorEvent::ExcerptsEdited { ids: ids.clone() })
+            }
+            multi_buffer::Event::ExcerptsExpanded { ids } => {
+                cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() })
+            }
             multi_buffer::Event::Reparsed => {
                 self.tasks_update_task = Some(self.refresh_runnables(cx));
 
@@ -11299,6 +11309,10 @@ impl Editor {
         }));
         self
     }
+
+    pub fn file_header_size(&self) -> u8 {
+        self.file_header_size
+    }
 }
 
 fn hunks_for_selections(
@@ -11743,6 +11757,12 @@ pub enum EditorEvent {
     ExcerptsRemoved {
         ids: Vec<ExcerptId>,
     },
+    ExcerptsEdited {
+        ids: Vec<ExcerptId>,
+    },
+    ExcerptsExpanded {
+        ids: Vec<ExcerptId>,
+    },
     BufferEdited,
     Edited,
     Reparsed,

crates/fuzzy/src/char_bag.rs 🔗

@@ -1,6 +1,6 @@
 use std::iter::FromIterator;
 
-#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
 pub struct CharBag(u64);
 
 impl CharBag {

crates/git/src/repository.rs 🔗

@@ -316,7 +316,7 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
     }
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
 pub enum GitFileStatus {
     Added,
     Modified,

crates/gpui/src/color.rs 🔗

@@ -1,6 +1,9 @@
 use anyhow::{bail, Context};
 use serde::de::{self, Deserialize, Deserializer, Visitor};
-use std::fmt;
+use std::{
+    fmt,
+    hash::{Hash, Hasher},
+};
 
 /// Convert an RGB hex color code number to a color type
 pub fn rgb(hex: u32) -> Rgba {
@@ -267,6 +270,15 @@ impl Ord for Hsla {
 
 impl Eq for Hsla {}
 
+impl Hash for Hsla {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        state.write_u32(u32::from_be_bytes(self.h.to_be_bytes()));
+        state.write_u32(u32::from_be_bytes(self.s.to_be_bytes()));
+        state.write_u32(u32::from_be_bytes(self.l.to_be_bytes()));
+        state.write_u32(u32::from_be_bytes(self.a.to_be_bytes()));
+    }
+}
+
 /// Construct an [`Hsla`] object from plain values
 pub fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla {
     Hsla {

crates/gpui/src/style.rs 🔗

@@ -1,4 +1,8 @@
-use std::{iter, mem, ops::Range};
+use std::{
+    hash::{Hash, Hasher},
+    iter, mem,
+    ops::Range,
+};
 
 use crate::{
     black, phi, point, quad, rems, AbsoluteLength, Bounds, ContentMask, Corners, CornersRefinement,
@@ -319,6 +323,20 @@ pub struct HighlightStyle {
 
 impl Eq for HighlightStyle {}
 
+impl Hash for HighlightStyle {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.color.hash(state);
+        self.font_weight.hash(state);
+        self.font_style.hash(state);
+        self.background_color.hash(state);
+        self.underline.hash(state);
+        self.strikethrough.hash(state);
+        state.write_u32(u32::from_be_bytes(
+            self.fade_out.map(|f| f.to_be_bytes()).unwrap_or_default(),
+        ));
+    }
+}
+
 impl Style {
     /// Returns true if the style is visible and the background is opaque.
     pub fn has_opaque_background(&self) -> bool {
@@ -549,7 +567,7 @@ impl Default for Style {
 }
 
 /// The properties that can be applied to an underline.
-#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq)]
+#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
 #[refineable(Debug)]
 pub struct UnderlineStyle {
     /// The thickness of the underline.
@@ -563,7 +581,7 @@ pub struct UnderlineStyle {
 }
 
 /// The properties that can be applied to a strikethrough.
-#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq)]
+#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
 #[refineable(Debug)]
 pub struct StrikethroughStyle {
     /// The thickness of the strikethrough.

crates/language/src/buffer.rs 🔗

@@ -2738,12 +2738,13 @@ impl BufferSnapshot {
         Some(items)
     }
 
-    fn outline_items_containing(
+    pub fn outline_items_containing<T: ToOffset>(
         &self,
-        range: Range<usize>,
+        range: Range<T>,
         include_extra_context: bool,
         theme: Option<&SyntaxTheme>,
     ) -> Option<Vec<OutlineItem<Anchor>>> {
+        let range = range.to_offset(self);
         let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| {
             grammar.outline_config.as_ref().map(|c| &c.query)
         });

crates/language/src/language.rs 🔗

@@ -70,7 +70,7 @@ pub use language_registry::{
     PendingLanguageServer, QUERY_FILENAME_PREFIXES,
 };
 pub use lsp::LanguageServerId;
-pub use outline::{Outline, OutlineItem};
+pub use outline::{render_item, Outline, OutlineItem};
 pub use syntax_map::{OwnedSyntaxLayer, SyntaxLayer};
 pub use text::{AnchorRangeExt, LineEnding};
 pub use tree_sitter::{Node, Parser, Tree, TreeCursor};

crates/language/src/outline.rs 🔗

@@ -1,6 +1,11 @@
 use fuzzy::{StringMatch, StringMatchCandidate};
-use gpui::{BackgroundExecutor, HighlightStyle};
+use gpui::{
+    relative, AppContext, BackgroundExecutor, FontStyle, FontWeight, HighlightStyle, StyledText,
+    TextStyle, WhiteSpace,
+};
+use settings::Settings;
 use std::ops::Range;
+use theme::{ActiveTheme, ThemeSettings};
 
 /// An outline of all the symbols contained in a buffer.
 #[derive(Debug)]
@@ -11,7 +16,7 @@ pub struct Outline<T> {
     path_candidate_prefixes: Vec<usize>,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
 pub struct OutlineItem<T> {
     pub depth: usize,
     pub range: Range<T>,
@@ -138,3 +143,34 @@ impl<T> Outline<T> {
         tree_matches
     }
 }
+
+pub fn render_item<T>(
+    outline_item: &OutlineItem<T>,
+    custom_highlights: impl IntoIterator<Item = (Range<usize>, HighlightStyle)>,
+    cx: &AppContext,
+) -> StyledText {
+    let settings = ThemeSettings::get_global(cx);
+
+    // TODO: We probably shouldn't need to build a whole new text style here
+    // but I'm not sure how to get the current one and modify it.
+    // Before this change TextStyle::default() was used here, which was giving us the wrong font and text color.
+    let text_style = TextStyle {
+        color: cx.theme().colors().text,
+        font_family: settings.buffer_font.family.clone(),
+        font_features: settings.buffer_font.features.clone(),
+        font_size: settings.buffer_font_size(cx).into(),
+        font_weight: FontWeight::NORMAL,
+        font_style: FontStyle::Normal,
+        line_height: relative(1.),
+        background_color: None,
+        underline: None,
+        strikethrough: None,
+        white_space: WhiteSpace::Normal,
+    };
+    let highlights = gpui::combine_highlights(
+        custom_highlights,
+        outline_item.highlight_ranges.iter().cloned(),
+    );
+
+    StyledText::new(outline_item.text.clone()).with_highlights(&text_style, highlights)
+}

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -77,6 +77,9 @@ pub enum Event {
     ExcerptsRemoved {
         ids: Vec<ExcerptId>,
     },
+    ExcerptsExpanded {
+        ids: Vec<ExcerptId>,
+    },
     ExcerptsEdited {
         ids: Vec<ExcerptId>,
     },
@@ -1666,8 +1669,9 @@ impl MultiBuffer {
         }
         self.sync(cx);
 
+        let ids = ids.into_iter().collect::<Vec<_>>();
         let snapshot = self.snapshot(cx);
-        let locators = snapshot.excerpt_locators_for_ids(ids);
+        let locators = snapshot.excerpt_locators_for_ids(ids.iter().copied());
         let mut new_excerpts = SumTree::new();
         let mut cursor = snapshot.excerpts.cursor::<(Option<&Locator>, usize)>();
         let mut edits = Vec::<Edit<usize>>::new();
@@ -1746,6 +1750,7 @@ impl MultiBuffer {
         cx.emit(Event::Edited {
             singleton_buffer_edited: false,
         });
+        cx.emit(Event::ExcerptsExpanded { ids });
         cx.notify();
     }
 

crates/outline/Cargo.toml 🔗

@@ -19,7 +19,6 @@ gpui.workspace = true
 language.workspace = true
 ordered-float.workspace = true
 picker.workspace = true
-settings.workspace = true
 smol.workspace = true
 theme.workspace = true
 ui.workspace = true

crates/outline/src/outline.rs 🔗

@@ -2,19 +2,18 @@ use editor::{scroll::Autoscroll, Anchor, AnchorRangeExt, Editor, EditorMode};
 use fuzzy::StringMatch;
 use gpui::{
     actions, div, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
-    FontStyle, FontWeight, HighlightStyle, ParentElement, Point, Render, Styled, StyledText, Task,
-    TextStyle, View, ViewContext, VisualContext, WeakView, WhiteSpace, WindowContext,
+    HighlightStyle, ParentElement, Point, Render, Styled, Task, View, ViewContext, VisualContext,
+    WeakView, WindowContext,
 };
 use language::Outline;
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
-use settings::Settings;
 use std::{
     cmp::{self, Reverse},
     sync::Arc,
 };
 
-use theme::{color_alpha, ActiveTheme, ThemeSettings};
+use theme::{color_alpha, ActiveTheme};
 use ui::{prelude::*, ListItem, ListItemSpacing};
 use util::ResultExt;
 use workspace::{DismissDecision, ModalView};
@@ -268,38 +267,12 @@ impl PickerDelegate for OutlineViewDelegate {
         selected: bool,
         cx: &mut ViewContext<Picker<Self>>,
     ) -> Option<Self::ListItem> {
-        let settings = ThemeSettings::get_global(cx);
-
-        // TODO: We probably shouldn't need to build a whole new text style here
-        // but I'm not sure how to get the current one and modify it.
-        // Before this change TextStyle::default() was used here, which was giving us the wrong font and text color.
-        let text_style = TextStyle {
-            color: cx.theme().colors().text,
-            font_family: settings.buffer_font.family.clone(),
-            font_features: settings.buffer_font.features.clone(),
-            font_size: settings.buffer_font_size(cx).into(),
-            font_weight: FontWeight::NORMAL,
-            font_style: FontStyle::Normal,
-            line_height: relative(1.),
-            background_color: None,
-            underline: None,
-            strikethrough: None,
-            white_space: WhiteSpace::Normal,
-        };
+        let mat = self.matches.get(ix)?;
+        let outline_item = self.outline.items.get(mat.candidate_id)?;
 
         let mut highlight_style = HighlightStyle::default();
         highlight_style.background_color = Some(color_alpha(cx.theme().colors().text_accent, 0.3));
-
-        let mat = &self.matches[ix];
-        let outline_item = &self.outline.items[mat.candidate_id];
-
-        let highlights = gpui::combine_highlights(
-            mat.ranges().map(|range| (range, highlight_style)),
-            outline_item.highlight_ranges.iter().cloned(),
-        );
-
-        let styled_text =
-            StyledText::new(outline_item.text.clone()).with_highlights(&text_style, highlights);
+        let custom_highlights = mat.ranges().map(|range| (range, highlight_style));
 
         Some(
             ListItem::new(ix)
@@ -310,7 +283,7 @@ impl PickerDelegate for OutlineViewDelegate {
                     div()
                         .text_ui(cx)
                         .pl(rems(outline_item.depth as f32))
-                        .child(styled_text),
+                        .child(language::render_item(outline_item, custom_highlights, cx)),
                 ),
         )
     }

crates/outline_panel/Cargo.toml 🔗

@@ -0,0 +1,37 @@
+[package]
+name = "outline_panel"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/outline_panel.rs"
+doctest = false
+
+[dependencies]
+anyhow.workspace = true
+collections.workspace = true
+db.workspace = true
+editor.workspace = true
+file_icons.workspace = true
+git.workspace = true
+gpui.workspace = true
+language.workspace = true
+log.workspace = true
+menu.workspace = true
+project.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+unicase.workspace = true
+util.workspace = true
+worktree.workspace = true
+workspace.workspace = true
+
+[package.metadata.cargo-machete]
+ignored = ["log"]

crates/outline_panel/src/outline_panel.rs 🔗

@@ -0,0 +1,2515 @@
+mod outline_panel_settings;
+
+use std::{
+    cmp,
+    hash::Hash,
+    ops::Range,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+};
+
+use anyhow::Context;
+use collections::{hash_map, BTreeSet, HashMap, HashSet};
+use db::kvp::KEY_VALUE_STORE;
+use editor::{
+    items::{entry_git_aware_label_color, entry_label_color},
+    scroll::ScrollAnchor,
+    Editor, EditorEvent, ExcerptId,
+};
+use file_icons::FileIcons;
+use git::repository::GitFileStatus;
+use gpui::{
+    actions, anchored, deferred, div, px, uniform_list, Action, 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,
+};
+use language::{BufferId, OffsetRangeExt, OutlineItem};
+use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
+
+use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings};
+use project::{EntryKind, File, Fs, Project};
+use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsStore};
+use unicase::UniCase;
+use util::{maybe, NumericPrefixWithSuffix, ResultExt, TryFutureExt};
+use workspace::{
+    dock::{DockPosition, Panel, PanelEvent},
+    item::ItemHandle,
+    ui::{
+        h_flex, v_flex, ActiveTheme, Color, ContextMenu, FluentBuilder, Icon, IconName, IconSize,
+        Label, LabelCommon, ListItem, Selectable,
+    },
+    OpenInTerminal, Workspace,
+};
+use worktree::{Entry, ProjectEntryId, WorktreeId};
+
+actions!(
+    outline_panel,
+    [
+        ExpandSelectedEntry,
+        CollapseSelectedEntry,
+        CollapseAllEntries,
+        CopyPath,
+        CopyRelativePath,
+        RevealInFinder,
+        Open,
+        ToggleFocus,
+        UnfoldDirectory,
+        FoldDirectory,
+        SelectParent,
+    ]
+);
+
+const OUTLINE_PANEL_KEY: &str = "OutlinePanel";
+const UPDATE_DEBOUNCE_MILLIS: u64 = 80;
+
+type Outline = OutlineItem<language::Anchor>;
+
+pub struct OutlinePanel {
+    fs: Arc<dyn Fs>,
+    width: Option<Pixels>,
+    project: Model<Project>,
+    active: bool,
+    scroll_handle: UniformListScrollHandle,
+    context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
+    focus_handle: FocusHandle,
+    pending_serialization: Task<Option<()>>,
+    fs_entries_depth: HashMap<(WorktreeId, ProjectEntryId), (bool, usize)>,
+    fs_entries: Vec<FsEntry>,
+    collapsed_dirs: HashMap<WorktreeId, BTreeSet<ProjectEntryId>>,
+    unfolded_dirs: HashMap<WorktreeId, BTreeSet<ProjectEntryId>>,
+    last_visible_range: Range<usize>,
+    selected_entry: Option<EntryOwned>,
+    active_item: Option<ActiveItem>,
+    _subscriptions: Vec<Subscription>,
+    update_task: Task<()>,
+    outline_fetch_tasks: Vec<Task<()>>,
+    outlines: HashMap<OutlinesContainer, Vec<Outline>>,
+    cached_entries_with_depth: Option<Vec<(usize, EntryOwned)>>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum EntryOwned {
+    Entry(FsEntry),
+    FoldedDirs(WorktreeId, Vec<Entry>),
+    Outline(OutlinesContainer, Outline),
+}
+
+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::Outline(container, outline) => EntryRef::Outline(*container, outline),
+        }
+    }
+
+    fn abs_path(&self, project: &Model<Project>, cx: &AppContext) -> Option<PathBuf> {
+        match self {
+            Self::Entry(entry) => entry.abs_path(project, cx),
+            Self::FoldedDirs(worktree_id, dirs) => dirs.last().and_then(|entry| {
+                project
+                    .read(cx)
+                    .worktree_for_id(*worktree_id, cx)
+                    .and_then(|worktree| worktree.read(cx).absolutize(&entry.path).ok())
+            }),
+            Self::Outline(..) => None,
+        }
+    }
+
+    fn outlines_container(&self) -> Option<OutlinesContainer> {
+        match self {
+            Self::Entry(entry) => entry.outlines_container(),
+            Self::FoldedDirs(..) => None,
+            Self::Outline(container, _) => Some(*container),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum EntryRef<'a> {
+    Entry(&'a FsEntry),
+    FoldedDirs(WorktreeId, &'a [Entry]),
+    Outline(OutlinesContainer, &'a Outline),
+}
+
+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::Outline(container, outline) => EntryOwned::Outline(container, outline.clone()),
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+enum OutlinesContainer {
+    ExternalFile(BufferId),
+    File(WorktreeId, ProjectEntryId),
+}
+
+#[derive(Clone, Debug, Eq)]
+enum FsEntry {
+    ExternalFile(BufferId),
+    Directory(WorktreeId, Entry),
+    File(WorktreeId, Entry),
+}
+
+impl PartialEq for FsEntry {
+    fn eq(&self, other: &Self) -> bool {
+        match (self, other) {
+            (Self::ExternalFile(id_a), Self::ExternalFile(id_b)) => id_a == id_b,
+            (Self::Directory(id_a, entry_a), Self::Directory(id_b, entry_b)) => {
+                id_a == id_b && entry_a.id == entry_b.id
+            }
+            (Self::File(worktree_a, entry_a), Self::File(worktree_b, entry_b)) => {
+                worktree_a == worktree_b && entry_a.id == entry_b.id
+            }
+            _ => false,
+        }
+    }
+}
+
+impl FsEntry {
+    fn abs_path(&self, project: &Model<Project>, cx: &AppContext) -> Option<PathBuf> {
+        match self {
+            Self::ExternalFile(buffer_id) => project
+                .read(cx)
+                .buffer_for_id(*buffer_id)
+                .and_then(|buffer| File::from_dyn(buffer.read(cx).file()))
+                .and_then(|file| file.worktree.read(cx).absolutize(&file.path).ok()),
+            Self::Directory(worktree_id, entry) => project
+                .read(cx)
+                .worktree_for_id(*worktree_id, cx)?
+                .read(cx)
+                .absolutize(&entry.path)
+                .ok(),
+            Self::File(worktree_id, entry) => project
+                .read(cx)
+                .worktree_for_id(*worktree_id, cx)?
+                .read(cx)
+                .absolutize(&entry.path)
+                .ok(),
+        }
+    }
+
+    fn relative_path<'a>(
+        &'a self,
+        project: &Model<Project>,
+        cx: &'a AppContext,
+    ) -> Option<&'a Path> {
+        match self {
+            Self::ExternalFile(buffer_id) => project
+                .read(cx)
+                .buffer_for_id(*buffer_id)
+                .and_then(|buffer| buffer.read(cx).file())
+                .map(|file| file.path().as_ref()),
+            Self::Directory(_, entry) => Some(entry.path.as_ref()),
+            Self::File(_, entry) => Some(entry.path.as_ref()),
+        }
+    }
+
+    fn outlines_container(&self) -> Option<OutlinesContainer> {
+        match self {
+            Self::ExternalFile(buffer_id) => Some(OutlinesContainer::ExternalFile(*buffer_id)),
+            Self::File(worktree_id, entry) => Some(OutlinesContainer::File(*worktree_id, entry.id)),
+            Self::Directory(..) => None,
+        }
+    }
+}
+
+struct ActiveItem {
+    item_id: EntityId,
+    active_editor: WeakView<Editor>,
+    _editor_subscrpiption: Option<Subscription>,
+}
+
+#[derive(Debug)]
+pub enum Event {
+    Focus,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SerializedOutlinePanel {
+    width: Option<Pixels>,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct EntryDetails {
+    filename: String,
+    icon: Option<Arc<str>>,
+    path: Arc<Path>,
+    depth: usize,
+    kind: EntryKind,
+    is_ignored: bool,
+    is_expanded: bool,
+    is_selected: bool,
+    git_status: Option<GitFileStatus>,
+    is_private: bool,
+    worktree_id: WorktreeId,
+    canonical_path: Option<PathBuf>,
+}
+
+pub fn init_settings(cx: &mut AppContext) {
+    OutlinePanelSettings::register(cx);
+}
+
+pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
+    init_settings(cx);
+    file_icons::init(assets, cx);
+
+    cx.observe_new_views(|workspace: &mut Workspace, _| {
+        workspace.register_action(|workspace, _: &ToggleFocus, cx| {
+            workspace.toggle_panel_focus::<OutlinePanel>(cx);
+        });
+    })
+    .detach();
+}
+
+impl OutlinePanel {
+    pub async fn load(
+        workspace: WeakView<Workspace>,
+        mut cx: AsyncWindowContext,
+    ) -> anyhow::Result<View<Self>> {
+        let serialized_panel = cx
+            .background_executor()
+            .spawn(async move { KEY_VALUE_STORE.read_kvp(OUTLINE_PANEL_KEY) })
+            .await
+            .context("loading outline panel")
+            .log_err()
+            .flatten()
+            .map(|panel| serde_json::from_str::<SerializedOutlinePanel>(&panel))
+            .transpose()
+            .log_err()
+            .flatten();
+
+        workspace.update(&mut cx, |workspace, cx| {
+            let panel = Self::new(workspace, cx);
+            if let Some(serialized_panel) = serialized_panel {
+                panel.update(cx, |panel, cx| {
+                    panel.width = serialized_panel.width.map(|px| px.round());
+                    cx.notify();
+                });
+            }
+            panel
+        })
+    }
+
+    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
+        let project = workspace.project().clone();
+        let outline_panel = cx.new_view(|cx| {
+            let focus_handle = cx.focus_handle();
+            let focus_subscription = cx.on_focus(&focus_handle, Self::focus_in);
+            let workspace_subscription = cx.subscribe(
+                &workspace
+                    .weak_handle()
+                    .upgrade()
+                    .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))
+                        {
+                            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);
+                            }
+                        } else {
+                            outline_panel.clear_previous();
+                            cx.notify();
+                        }
+                    }
+                },
+            );
+
+            let icons_subscription = cx.observe_global::<FileIcons>(|_, cx| {
+                cx.notify();
+            });
+
+            let mut outline_panel_settings = *OutlinePanelSettings::get_global(cx);
+            let settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
+                let new_settings = *OutlinePanelSettings::get_global(cx);
+                if outline_panel_settings != new_settings {
+                    outline_panel_settings = new_settings;
+                    cx.notify();
+                }
+            });
+
+            let mut outline_panel = Self {
+                active: false,
+                project: project.clone(),
+                fs: workspace.app_state().fs.clone(),
+                scroll_handle: UniformListScrollHandle::new(),
+                focus_handle,
+                fs_entries: Vec::new(),
+                fs_entries_depth: HashMap::default(),
+                collapsed_dirs: HashMap::default(),
+                unfolded_dirs: HashMap::default(),
+                selected_entry: None,
+                context_menu: None,
+                width: None,
+                active_item: None,
+                pending_serialization: Task::ready(None),
+                update_task: Task::ready(()),
+                outline_fetch_tasks: Vec::new(),
+                outlines: HashMap::default(),
+                last_visible_range: 0..0,
+                cached_entries_with_depth: None,
+                _subscriptions: vec![
+                    settings_subscription,
+                    icons_subscription,
+                    focus_subscription,
+                    workspace_subscription,
+                ],
+            };
+            if let Some(editor) = workspace
+                .active_item(cx)
+                .and_then(|item| item.act_as::<Editor>(cx))
+            {
+                outline_panel.replace_visible_entries(editor, cx);
+            }
+            outline_panel
+        });
+
+        outline_panel
+    }
+
+    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+        let width = self.width;
+        self.pending_serialization = cx.background_executor().spawn(
+            async move {
+                KEY_VALUE_STORE
+                    .write_kvp(
+                        OUTLINE_PANEL_KEY.into(),
+                        serde_json::to_string(&SerializedOutlinePanel { width })?,
+                    )
+                    .await?;
+                anyhow::Ok(())
+            }
+            .log_err(),
+        );
+    }
+
+    fn dispatch_context(&self, _: &ViewContext<Self>) -> KeyContext {
+        let mut dispatch_context = KeyContext::new_with_defaults();
+        dispatch_context.add("OutlinePanel");
+        dispatch_context.add("menu");
+        dispatch_context
+    }
+
+    fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
+        let Some(editor) = self
+            .active_item
+            .as_ref()
+            .and_then(|item| item.active_editor.upgrade())
+        else {
+            return;
+        };
+        if let Some(EntryOwned::FoldedDirs(worktree_id, entries)) = &self.selected_entry {
+            self.unfolded_dirs
+                .entry(*worktree_id)
+                .or_default()
+                .extend(entries.iter().map(|entry| entry.id));
+            self.update_fs_entries(&editor, HashSet::default(), None, None, false, cx);
+        }
+    }
+
+    fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext<Self>) {
+        let Some(editor) = self
+            .active_item
+            .as_ref()
+            .and_then(|item| item.active_editor.upgrade())
+        else {
+            return;
+        };
+
+        let (worktree_id, entry) = match &self.selected_entry {
+            Some(EntryOwned::Entry(FsEntry::Directory(worktree_id, entry))) => {
+                (worktree_id, Some(entry))
+            }
+            Some(EntryOwned::FoldedDirs(worktree_id, entries)) => (worktree_id, entries.last()),
+            _ => return,
+        };
+        let Some(entry) = entry else {
+            return;
+        };
+        let unfolded_dirs = self.unfolded_dirs.get_mut(worktree_id);
+        let worktree = self
+            .project
+            .read(cx)
+            .worktree_for_id(*worktree_id, cx)
+            .map(|w| w.read(cx).snapshot());
+        let Some((worktree, unfolded_dirs)) = worktree.zip(unfolded_dirs) else {
+            return;
+        };
+
+        unfolded_dirs.remove(&entry.id);
+        let mut parent = entry.path.parent();
+        while let Some(parent_path) = parent {
+            let removed = worktree.entry_for_path(parent_path).map_or(false, |entry| {
+                if worktree.root_entry().map(|entry| entry.id) == Some(entry.id) {
+                    false
+                } else {
+                    unfolded_dirs.remove(&entry.id)
+                }
+            });
+
+            if removed {
+                parent = parent_path.parent();
+            } else {
+                break;
+            }
+        }
+        for child_dir in worktree
+            .child_entries(&entry.path)
+            .filter(|entry| entry.is_dir())
+        {
+            let removed = unfolded_dirs.remove(&child_dir.id);
+            if !removed {
+                break;
+            }
+        }
+
+        self.update_fs_entries(&editor, HashSet::default(), None, None, false, cx);
+    }
+
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        if let Some(selected_entry) = &self.selected_entry {
+            let outline_to_select = match selected_entry {
+                EntryOwned::Entry(entry) => entry.outlines_container().and_then(|container| {
+                    let next_outline = self.outlines.get(&container)?.first()?.clone();
+                    Some((container, next_outline))
+                }),
+                EntryOwned::FoldedDirs(..) => None,
+                EntryOwned::Outline(container, outline) => self
+                    .outlines
+                    .get(container)
+                    .and_then(|outlines| {
+                        outlines.iter().skip_while(|o| o != &outline).skip(1).next()
+                    })
+                    .map(|outline| (*container, outline.clone())),
+            }
+            .map(|(container, outline)| EntryOwned::Outline(container, outline));
+
+            let entry_to_select = outline_to_select.or_else(|| {
+                match selected_entry {
+                    EntryOwned::Entry(entry) => self
+                        .fs_entries
+                        .iter()
+                        .skip_while(|e| e != &entry)
+                        .skip(1)
+                        .next(),
+                    EntryOwned::FoldedDirs(worktree_id, dirs) => self
+                        .fs_entries
+                        .iter()
+                        .skip_while(|e| {
+                            if let FsEntry::Directory(dir_worktree_id, dir_entry) = e {
+                                dir_worktree_id != worktree_id || dirs.last() != Some(dir_entry)
+                            } else {
+                                true
+                            }
+                        })
+                        .skip(1)
+                        .next(),
+                    EntryOwned::Outline(container, _) => self
+                        .fs_entries
+                        .iter()
+                        .skip_while(|entry| entry.outlines_container().as_ref() != Some(container))
+                        .skip(1)
+                        .next(),
+                }
+                .cloned()
+                .map(EntryOwned::Entry)
+            });
+
+            if let Some(entry_to_select) = entry_to_select {
+                self.selected_entry = Some(entry_to_select);
+                self.autoscroll(cx);
+                cx.notify();
+            }
+        } else {
+            self.select_first(&SelectFirst {}, cx)
+        }
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        if let Some(selected_entry) = &self.selected_entry {
+            let outline_to_select = match selected_entry {
+                EntryOwned::Entry(entry) => {
+                    let previous_entry = self
+                        .fs_entries
+                        .iter()
+                        .rev()
+                        .skip_while(|e| e != &entry)
+                        .skip(1)
+                        .next();
+                    previous_entry
+                        .and_then(|entry| entry.outlines_container())
+                        .and_then(|container| {
+                            let previous_outline = self.outlines.get(&container)?.last()?.clone();
+                            Some((container, previous_outline))
+                        })
+                }
+                EntryOwned::FoldedDirs(worktree_id, dirs) => {
+                    let previous_entry = self
+                        .fs_entries
+                        .iter()
+                        .rev()
+                        .skip_while(|e| {
+                            if let FsEntry::Directory(dir_worktree_id, dir_entry) = e {
+                                dir_worktree_id != worktree_id || dirs.first() != Some(dir_entry)
+                            } else {
+                                true
+                            }
+                        })
+                        .skip(1)
+                        .next();
+                    previous_entry
+                        .and_then(|entry| entry.outlines_container())
+                        .and_then(|container| {
+                            let previous_outline = self.outlines.get(&container)?.last()?.clone();
+                            Some((container, previous_outline))
+                        })
+                }
+                EntryOwned::Outline(container, outline) => self
+                    .outlines
+                    .get(container)
+                    .and_then(|outlines| {
+                        outlines
+                            .iter()
+                            .rev()
+                            .skip_while(|o| o != &outline)
+                            .skip(1)
+                            .next()
+                    })
+                    .map(|outline| (*container, outline.clone())),
+            }
+            .map(|(container, outline)| EntryOwned::Outline(container, outline));
+
+            let entry_to_select = outline_to_select.or_else(|| {
+                match selected_entry {
+                    EntryOwned::Entry(entry) => self
+                        .fs_entries
+                        .iter()
+                        .rev()
+                        .skip_while(|e| e != &entry)
+                        .skip(1)
+                        .next(),
+                    EntryOwned::FoldedDirs(worktree_id, dirs) => self
+                        .fs_entries
+                        .iter()
+                        .rev()
+                        .skip_while(|e| {
+                            if let FsEntry::Directory(dir_worktree_id, dir_entry) = e {
+                                dir_worktree_id != worktree_id || dirs.first() != Some(dir_entry)
+                            } else {
+                                true
+                            }
+                        })
+                        .skip(1)
+                        .next(),
+                    EntryOwned::Outline(container, _) => self
+                        .fs_entries
+                        .iter()
+                        .rev()
+                        .find(|entry| entry.outlines_container().as_ref() == Some(container)),
+                }
+                .cloned()
+                .map(EntryOwned::Entry)
+            });
+
+            if let Some(entry_to_select) = entry_to_select {
+                self.selected_entry = Some(entry_to_select);
+                self.autoscroll(cx);
+                cx.notify();
+            }
+        } else {
+            self.select_first(&SelectFirst {}, cx);
+        }
+    }
+
+    fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
+        if let Some(selected_entry) = &self.selected_entry {
+            let parent_entry = match selected_entry {
+                EntryOwned::Entry(entry) => self
+                    .fs_entries
+                    .iter()
+                    .rev()
+                    .skip_while(|e| e != &entry)
+                    .skip(1)
+                    .find(|entry_before_current| match (entry, entry_before_current) {
+                        (
+                            FsEntry::File(worktree_id, entry)
+                            | FsEntry::Directory(worktree_id, entry),
+                            FsEntry::Directory(parent_worktree_id, parent_entry),
+                        ) => {
+                            parent_worktree_id == worktree_id
+                                && directory_contains(parent_entry, entry)
+                        }
+                        _ => false,
+                    }),
+                EntryOwned::FoldedDirs(worktree_id, dirs) => self
+                    .fs_entries
+                    .iter()
+                    .rev()
+                    .skip_while(|e| {
+                        if let FsEntry::Directory(dir_worktree_id, dir_entry) = e {
+                            dir_worktree_id != worktree_id || dirs.first() != Some(dir_entry)
+                        } else {
+                            true
+                        }
+                    })
+                    .skip(1)
+                    .find(
+                        |entry_before_current| match (dirs.first(), entry_before_current) {
+                            (Some(entry), FsEntry::Directory(parent_worktree_id, parent_entry)) => {
+                                parent_worktree_id == worktree_id
+                                    && directory_contains(parent_entry, entry)
+                            }
+                            _ => false,
+                        },
+                    ),
+                EntryOwned::Outline(container, _) => self
+                    .fs_entries
+                    .iter()
+                    .find(|entry| entry.outlines_container().as_ref() == Some(container)),
+            }
+            .cloned()
+            .map(EntryOwned::Entry);
+            if let Some(parent_entry) = parent_entry {
+                self.selected_entry = Some(parent_entry);
+                self.autoscroll(cx);
+                cx.notify();
+            }
+        } else {
+            self.select_first(&SelectFirst {}, cx);
+        }
+    }
+
+    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
+        if let Some(first_entry) = self.fs_entries.first().cloned().map(EntryOwned::Entry) {
+            self.selected_entry = Some(first_entry);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
+
+    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
+        if let Some(new_selection) = self.fs_entries.last().map(|last_entry| {
+            last_entry
+                .outlines_container()
+                .and_then(|container| {
+                    let outline = self.outlines.get(&container)?.last()?;
+                    Some((container, outline.clone()))
+                })
+                .map(|(container, outline)| EntryOwned::Outline(container, outline))
+                .unwrap_or_else(|| EntryOwned::Entry(last_entry.clone()))
+        }) {
+            self.selected_entry = Some(new_selection);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
+
+    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(selected_entry) = self.selected_entry.clone() {
+            let index = self
+                .entries_with_depths(cx)
+                .iter()
+                .position(|(_, entry)| entry == &selected_entry);
+            if let Some(index) = index {
+                self.scroll_handle.scroll_to_item(index);
+                cx.notify();
+            }
+        }
+    }
+
+    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
+        if !self.focus_handle.contains_focused(cx) {
+            cx.emit(Event::Focus);
+        }
+    }
+
+    fn deploy_context_menu(
+        &mut self,
+        position: Point<Pixels>,
+        entry: EntryRef<'_>,
+        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
+                .project
+                .read(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::FoldedDirs(worktree_id, entries) => entries
+                .first()
+                .and_then(|entry| {
+                    self.project
+                        .read(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::Outline(_, _) => {
+                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 context_menu = ContextMenu::build(cx, |menu, _| {
+            menu.context(self.focus_handle.clone())
+                .action("Copy Relative Path", Box::new(CopyRelativePath))
+                .action("Reveal in Finder", Box::new(RevealInFinder))
+                .action("Open in Terminal", Box::new(OpenInTerminal))
+                .when(is_unfoldable, |menu| {
+                    menu.action("Unfold Directory", Box::new(UnfoldDirectory))
+                })
+                .when(is_foldable, |menu| {
+                    menu.action("Fold Directory", Box::new(FoldDirectory))
+                })
+                .separator()
+                .action("Copy Path", Box::new(CopyPath))
+                .action("Copy Relative Path", Box::new(CopyRelativePath))
+        });
+        cx.focus_view(&context_menu);
+        let subscription = cx.subscribe(&context_menu, |outline_panel, _, _: &DismissEvent, cx| {
+            outline_panel.context_menu.take();
+            cx.notify();
+        });
+        self.context_menu = Some((context_menu, position, subscription));
+        cx.notify();
+    }
+
+    fn is_unfoldable(&self, entry: EntryRef) -> bool {
+        matches!(entry, EntryRef::FoldedDirs(..))
+    }
+
+    fn is_foldable(&self, entry: EntryRef) -> bool {
+        let (directory_worktree, directory_entry) = match entry {
+            EntryRef::Entry(FsEntry::Directory(directory_worktree, directory_entry)) => {
+                (*directory_worktree, Some(directory_entry))
+            }
+            EntryRef::FoldedDirs(directory_worktree, entries) => {
+                (directory_worktree, entries.last())
+            }
+            _ => return false,
+        };
+        let Some(directory_entry) = directory_entry else {
+            return false;
+        };
+
+        if self
+            .unfolded_dirs
+            .get(&directory_worktree)
+            .map_or(false, |unfolded_dirs| {
+                unfolded_dirs.contains(&directory_entry.id)
+            })
+        {
+            return true;
+        }
+
+        let child_entries = self
+            .fs_entries
+            .iter()
+            .skip_while(|entry| {
+                if let FsEntry::Directory(worktree_id, entry) = entry {
+                    worktree_id != &directory_worktree || entry.id != directory_entry.id
+                } else {
+                    true
+                }
+            })
+            .skip(1)
+            .filter(|next_entry| match next_entry {
+                FsEntry::ExternalFile(_) => false,
+                FsEntry::Directory(worktree_id, entry) | FsEntry::File(worktree_id, entry) => {
+                    worktree_id == &directory_worktree
+                        && entry.path.parent() == Some(directory_entry.path.as_ref())
+                }
+            })
+            .collect::<Vec<_>>();
+
+        child_entries.len() == 1 && matches!(child_entries.first(), Some(FsEntry::Directory(..)))
+    }
+
+    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
+        let Some(editor) = self
+            .active_item
+            .as_ref()
+            .and_then(|item| item.active_editor.upgrade())
+        else {
+            return;
+        };
+        if let Some(EntryOwned::Entry(FsEntry::Directory(worktree_id, selected_dir_entry))) =
+            &self.selected_entry
+        {
+            let expanded = self
+                .collapsed_dirs
+                .get_mut(worktree_id)
+                .map_or(false, |hidden_dirs| {
+                    hidden_dirs.remove(&selected_dir_entry.id)
+                });
+            if expanded {
+                self.project.update(cx, |project, cx| {
+                    project.expand_entry(*worktree_id, selected_dir_entry.id, cx);
+                });
+                self.update_fs_entries(&editor, HashSet::default(), None, None, false, cx);
+            } else {
+                self.select_next(&SelectNext, cx)
+            }
+        }
+    }
+
+    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
+        let Some(editor) = self
+            .active_item
+            .as_ref()
+            .and_then(|item| item.active_editor.upgrade())
+        else {
+            return;
+        };
+        if let Some(
+            dir_entry @ EntryOwned::Entry(FsEntry::Directory(worktree_id, selected_dir_entry)),
+        ) = &self.selected_entry
+        {
+            self.collapsed_dirs
+                .entry(*worktree_id)
+                .or_default()
+                .insert(selected_dir_entry.id);
+            self.update_fs_entries(
+                &editor,
+                HashSet::default(),
+                Some(dir_entry.clone()),
+                None,
+                false,
+                cx,
+            );
+        }
+    }
+
+    pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
+        let Some(editor) = self
+            .active_item
+            .as_ref()
+            .and_then(|item| item.active_editor.upgrade())
+        else {
+            return;
+        };
+
+        self.fs_entries_depth
+            .iter()
+            .filter(|(_, &(is_dir, depth))| is_dir && depth == 0)
+            .for_each(|(&(worktree_id, entry_id), _)| {
+                self.collapsed_dirs
+                    .entry(worktree_id)
+                    .or_default()
+                    .insert(entry_id);
+            });
+        self.update_fs_entries(&editor, HashSet::default(), None, None, false, cx);
+    }
+
+    fn toggle_expanded(&mut self, entry: &EntryOwned, cx: &mut ViewContext<Self>) {
+        let Some(editor) = self
+            .active_item
+            .as_ref()
+            .and_then(|item| item.active_editor.upgrade())
+        else {
+            return;
+        };
+
+        match entry {
+            EntryOwned::Entry(FsEntry::Directory(worktree_id, dir_entry)) => {
+                let entry_id = dir_entry.id;
+                match self.collapsed_dirs.entry(*worktree_id) {
+                    hash_map::Entry::Occupied(mut o) => {
+                        let collapsed_dir_ids = o.get_mut();
+                        if collapsed_dir_ids.remove(&entry_id) {
+                            self.project
+                                .update(cx, |project, cx| {
+                                    project.expand_entry(*worktree_id, entry_id, cx)
+                                })
+                                .unwrap_or_else(|| Task::ready(Ok(())))
+                                .detach_and_log_err(cx);
+                        } else {
+                            collapsed_dir_ids.insert(entry_id);
+                        }
+                    }
+                    hash_map::Entry::Vacant(v) => {
+                        v.insert(BTreeSet::new()).insert(entry_id);
+                    }
+                }
+            }
+            EntryOwned::FoldedDirs(worktree_id, dir_entries) => {
+                if let Some(entry_id) = dir_entries.first().map(|entry| entry.id) {
+                    match self.collapsed_dirs.entry(*worktree_id) {
+                        hash_map::Entry::Occupied(mut o) => {
+                            let collapsed_dir_ids = o.get_mut();
+                            if collapsed_dir_ids.remove(&entry_id) {
+                                self.project
+                                    .update(cx, |project, cx| {
+                                        project.expand_entry(*worktree_id, entry_id, cx)
+                                    })
+                                    .unwrap_or_else(|| Task::ready(Ok(())))
+                                    .detach_and_log_err(cx);
+                            } else {
+                                collapsed_dir_ids.insert(entry_id);
+                            }
+                        }
+                        hash_map::Entry::Vacant(v) => {
+                            v.insert(BTreeSet::new()).insert(entry_id);
+                        }
+                    }
+                }
+            }
+            _ => return,
+        }
+
+        self.update_fs_entries(
+            &editor,
+            HashSet::default(),
+            Some(entry.clone()),
+            None,
+            false,
+            cx,
+        );
+    }
+
+    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
+        if let Some(clipboard_text) = self
+            .selected_entry
+            .as_ref()
+            .and_then(|entry| entry.abs_path(&self.project, cx))
+            .map(|p| p.to_string_lossy().to_string())
+        {
+            cx.write_to_clipboard(ClipboardItem::new(clipboard_text));
+        }
+    }
+
+    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
+        if let Some(clipboard_text) = self
+            .selected_entry
+            .as_ref()
+            .and_then(|entry| match entry {
+                EntryOwned::Entry(entry) => entry.relative_path(&self.project, cx),
+                EntryOwned::FoldedDirs(_, dirs) => dirs.last().map(|entry| entry.path.as_ref()),
+                EntryOwned::Outline(..) => None,
+            })
+            .map(|p| p.to_string_lossy().to_string())
+        {
+            cx.write_to_clipboard(ClipboardItem::new(clipboard_text));
+        }
+    }
+
+    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
+        if let Some(abs_path) = self
+            .selected_entry
+            .as_ref()
+            .and_then(|entry| entry.abs_path(&self.project, cx))
+        {
+            cx.reveal_path(&abs_path);
+        }
+    }
+
+    fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
+        let selected_entry = self.selected_entry.as_ref();
+        let abs_path = selected_entry.and_then(|entry| entry.abs_path(&self.project, cx));
+        let working_directory = if let (
+            Some(abs_path),
+            Some(EntryOwned::Entry(FsEntry::File(..) | FsEntry::ExternalFile(..))),
+        ) = (&abs_path, selected_entry)
+        {
+            abs_path.parent().map(|p| p.to_owned())
+        } else {
+            abs_path
+        };
+
+        if let Some(working_directory) = working_directory {
+            cx.dispatch_action(workspace::OpenTerminal { working_directory }.boxed_clone())
+        }
+    }
+
+    fn reveal_entry_for_selection(
+        &mut self,
+        editor: &View<Editor>,
+        cx: &mut ViewContext<'_, Self>,
+    ) {
+        let Some((container, outline_item)) = self.location_for_editor_selection(editor, cx) else {
+            return;
+        };
+
+        let file_entry_to_expand = self
+            .fs_entries
+            .iter()
+            .find(|entry| match (entry, &container) {
+                (
+                    FsEntry::ExternalFile(buffer_id),
+                    OutlinesContainer::ExternalFile(container_buffer_id),
+                ) => buffer_id == container_buffer_id,
+                (
+                    FsEntry::File(file_worktree_id, file_entry),
+                    OutlinesContainer::File(worktree_id, id),
+                ) => file_worktree_id == worktree_id && &file_entry.id == id,
+                _ => false,
+            });
+        let Some(entry_to_select) = outline_item
+            .map(|outline| EntryOwned::Outline(container, outline))
+            .or_else(|| Some(EntryOwned::Entry(file_entry_to_expand.cloned()?)))
+        else {
+            return;
+        };
+
+        if self.selected_entry.as_ref() == Some(&entry_to_select) {
+            return;
+        }
+
+        if let Some(FsEntry::File(file_worktree_id, file_entry)) = file_entry_to_expand {
+            if let Some(worktree) = self.project.read(cx).worktree_for_id(*file_worktree_id, cx) {
+                let parent_entry = {
+                    let mut traversal = worktree.read(cx).traverse_from_path(
+                        true,
+                        true,
+                        true,
+                        file_entry.path.as_ref(),
+                    );
+                    if traversal.back_to_parent() {
+                        traversal.entry()
+                    } else {
+                        None
+                    }
+                    .cloned()
+                };
+                if let Some(directory_entry) = parent_entry {
+                    self.expand_entry(worktree.read(cx).id(), directory_entry.id, cx);
+                }
+            }
+        }
+
+        self.update_fs_entries(
+            &editor,
+            HashSet::default(),
+            Some(entry_to_select),
+            None,
+            false,
+            cx,
+        );
+    }
+
+    fn expand_entry(
+        &mut self,
+        worktree_id: WorktreeId,
+        entry_id: ProjectEntryId,
+        cx: &mut AppContext,
+    ) {
+        if let Some(collapsed_dir_ids) = self.collapsed_dirs.get_mut(&worktree_id) {
+            if collapsed_dir_ids.remove(&entry_id) {
+                self.project
+                    .update(cx, |project, cx| {
+                        project.expand_entry(worktree_id, entry_id, cx)
+                    })
+                    .unwrap_or_else(|| Task::ready(Ok(())))
+                    .detach_and_log_err(cx)
+            }
+        }
+    }
+
+    fn render_outline(
+        &self,
+        container: OutlinesContainer,
+        rendered_outline: &Outline,
+        depth: usize,
+        cx: &mut ViewContext<Self>,
+    ) -> Stateful<Div> {
+        let (item_id, label_element) = (
+            ElementId::from(SharedString::from(format!(
+                "{:?}|{:?}",
+                rendered_outline.range, &rendered_outline.text,
+            ))),
+            language::render_item(&rendered_outline, None, cx).into_any_element(),
+        );
+        let is_active = match &self.selected_entry {
+            Some(EntryOwned::Outline(selected_container, selected_entry)) => {
+                selected_container == &container && selected_entry == rendered_outline
+            }
+            _ => false,
+        };
+
+        self.entry_element(
+            EntryRef::Outline(container, rendered_outline),
+            item_id,
+            depth,
+            None,
+            is_active,
+            label_element,
+            cx,
+        )
+    }
+
+    fn render_entry(
+        &self,
+        rendered_entry: &FsEntry,
+        depth: usize,
+        cx: &mut ViewContext<Self>,
+    ) -> Stateful<Div> {
+        let settings = OutlinePanelSettings::get_global(cx);
+        let is_active = match &self.selected_entry {
+            Some(EntryOwned::Entry(selected_entry)) => selected_entry == rendered_entry,
+            _ => false,
+        };
+        let (item_id, label_element, icon) = match rendered_entry {
+            FsEntry::File(worktree_id, entry) => {
+                let name = self.entry_name(worktree_id, entry, cx);
+                let color =
+                    entry_git_aware_label_color(entry.git_status, entry.is_ignored, is_active);
+                let icon = if settings.file_icons {
+                    FileIcons::get_icon(&entry.path, cx)
+                } else {
+                    None
+                }
+                .map(Icon::from_path)
+                .map(|icon| icon.color(color));
+                (
+                    ElementId::from(entry.id.to_proto() as usize),
+                    Label::new(name)
+                        .single_line()
+                        .color(color)
+                        .into_any_element(),
+                    icon,
+                )
+            }
+            FsEntry::Directory(worktree_id, entry) => {
+                let name = self.entry_name(worktree_id, entry, cx);
+
+                let is_expanded = self
+                    .collapsed_dirs
+                    .get(worktree_id)
+                    .map_or(true, |ids| !ids.contains(&entry.id));
+                let color =
+                    entry_git_aware_label_color(entry.git_status, entry.is_ignored, is_active);
+                let icon = if settings.folder_icons {
+                    FileIcons::get_folder_icon(is_expanded, cx)
+                } else {
+                    FileIcons::get_chevron_icon(is_expanded, cx)
+                }
+                .map(Icon::from_path)
+                .map(|icon| icon.color(color));
+                (
+                    ElementId::from(entry.id.to_proto() as usize),
+                    Label::new(name)
+                        .single_line()
+                        .color(color)
+                        .into_any_element(),
+                    icon,
+                )
+            }
+            FsEntry::ExternalFile(buffer_id) => {
+                let color = entry_label_color(is_active);
+                let (icon, name) = match self.project.read(cx).buffer_for_id(*buffer_id) {
+                    Some(buffer) => match buffer.read(cx).file() {
+                        Some(file) => {
+                            let path = file.path();
+                            let icon = if settings.file_icons {
+                                FileIcons::get_icon(path.as_ref(), cx)
+                            } else {
+                                None
+                            }
+                            .map(Icon::from_path)
+                            .map(|icon| icon.color(color));
+                            (icon, file_name(path.as_ref()))
+                        }
+                        None => (None, "Untitled".to_string()),
+                    },
+                    None => (None, "Unknown buffer".to_string()),
+                };
+                (
+                    ElementId::from(buffer_id.to_proto() as usize),
+                    Label::new(name)
+                        .single_line()
+                        .color(color)
+                        .into_any_element(),
+                    icon,
+                )
+            }
+        };
+
+        self.entry_element(
+            EntryRef::Entry(rendered_entry),
+            item_id,
+            depth,
+            icon,
+            is_active,
+            label_element,
+            cx,
+        )
+    }
+
+    fn render_folded_dirs(
+        &self,
+        worktree_id: WorktreeId,
+        dir_entries: &[Entry],
+        depth: usize,
+        cx: &mut ViewContext<OutlinePanel>,
+    ) -> Stateful<Div> {
+        let settings = OutlinePanelSettings::get_global(cx);
+        let is_active = match &self.selected_entry {
+            Some(EntryOwned::FoldedDirs(selected_worktree_id, selected_entries)) => {
+                selected_worktree_id == &worktree_id && selected_entries == dir_entries
+            }
+            _ => false,
+        };
+        let (item_id, label_element, icon) = {
+            let name = dir_entries.iter().fold(String::new(), |mut name, entry| {
+                if !name.is_empty() {
+                    name.push(std::path::MAIN_SEPARATOR)
+                }
+                name.push_str(&self.entry_name(&worktree_id, entry, cx));
+                name
+            });
+
+            let is_expanded =
+                self.collapsed_dirs
+                    .get(&worktree_id)
+                    .map_or(true, |collapsed_dirs| {
+                        dir_entries
+                            .iter()
+                            .all(|dir| !collapsed_dirs.contains(&dir.id))
+                    });
+            let is_ignored = dir_entries.iter().any(|entry| entry.is_ignored);
+            let git_status = dir_entries.first().and_then(|entry| entry.git_status);
+            let color = entry_git_aware_label_color(git_status, is_ignored, is_active);
+            let icon = if settings.folder_icons {
+                FileIcons::get_folder_icon(is_expanded, cx)
+            } else {
+                FileIcons::get_chevron_icon(is_expanded, cx)
+            }
+            .map(Icon::from_path)
+            .map(|icon| icon.color(color));
+            (
+                ElementId::from(
+                    dir_entries
+                        .last()
+                        .map(|entry| entry.id.to_proto())
+                        .unwrap_or_else(|| worktree_id.to_proto()) as usize,
+                ),
+                Label::new(name)
+                    .single_line()
+                    .color(color)
+                    .into_any_element(),
+                icon,
+            )
+        };
+
+        self.entry_element(
+            EntryRef::FoldedDirs(worktree_id, dir_entries),
+            item_id,
+            depth,
+            icon,
+            is_active,
+            label_element,
+            cx,
+        )
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn entry_element(
+        &self,
+        rendered_entry: EntryRef<'_>,
+        item_id: ElementId,
+        depth: usize,
+        icon: Option<Icon>,
+        is_active: bool,
+        label_element: gpui::AnyElement,
+        cx: &mut ViewContext<OutlinePanel>,
+    ) -> Stateful<Div> {
+        let settings = OutlinePanelSettings::get_global(cx);
+        let rendered_entry = rendered_entry.to_owned_entry();
+        div()
+            .id(item_id.clone())
+            .child(
+                ListItem::new(item_id)
+                    .indent_level(depth)
+                    .indent_step_size(px(settings.indent_size))
+                    .selected(is_active)
+                    .child(if let Some(icon) = icon {
+                        h_flex().child(icon)
+                    } else {
+                        h_flex()
+                            .size(IconSize::default().rems())
+                            .invisible()
+                            .flex_none()
+                    })
+                    .child(h_flex().h_6().child(label_element).ml_1())
+                    .on_click({
+                        let clicked_entry = rendered_entry.clone();
+                        cx.listener(move |outline_panel, event: &gpui::ClickEvent, cx| {
+                            if event.down.button == MouseButton::Right || event.down.first_mouse {
+                                return;
+                            }
+
+                            let Some(active_editor) = outline_panel
+                                .active_item
+                                .as_ref()
+                                .and_then(|item| item.active_editor.upgrade())
+                            else {
+                                return;
+                            };
+                            let active_multi_buffer = active_editor.read(cx).buffer().clone();
+                            let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx);
+
+                            match &clicked_entry {
+                                EntryOwned::Entry(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 {
+                                                multi_buffer_snapshot.anchor_in_excerpt(
+                                                    excerpt_id,
+                                                    excerpt_range.context.start,
+                                                )
+                                            } else {
+                                                None
+                                            }
+                                        },
+                                    );
+                                    if let Some(anchor) = scroll_target {
+                                        outline_panel.selected_entry = Some(clicked_entry.clone());
+                                        active_editor.update(cx, |editor, cx| {
+                                            editor.set_scroll_anchor(
+                                                ScrollAnchor {
+                                                    offset: Point::new(
+                                                        0.0,
+                                                        -(editor.file_header_size() as f32),
+                                                    ),
+                                                    anchor,
+                                                },
+                                                cx,
+                                            );
+                                        })
+                                    }
+                                }
+                                entry @ EntryOwned::Entry(FsEntry::Directory(..)) => {
+                                    outline_panel.toggle_expanded(entry, cx);
+                                }
+                                entry @ EntryOwned::FoldedDirs(..) => {
+                                    outline_panel.toggle_expanded(entry, cx);
+                                }
+                                EntryOwned::Entry(FsEntry::File(_, file_entry)) => {
+                                    let scroll_target = outline_panel
+                                        .project
+                                        .update(cx, |project, cx| {
+                                            project
+                                                .path_for_entry(file_entry.id, cx)
+                                                .and_then(|path| project.get_open_buffer(&path, cx))
+                                        })
+                                        .map(|buffer| {
+                                            active_multi_buffer
+                                                .read(cx)
+                                                .excerpts_for_buffer(&buffer, cx)
+                                        })
+                                        .and_then(|excerpts| {
+                                            let (excerpt_id, excerpt_range) = excerpts.first()?;
+                                            multi_buffer_snapshot.anchor_in_excerpt(
+                                                *excerpt_id,
+                                                excerpt_range.context.start,
+                                            )
+                                        });
+                                    if let Some(anchor) = scroll_target {
+                                        outline_panel.selected_entry = Some(clicked_entry.clone());
+                                        active_editor.update(cx, |editor, cx| {
+                                            editor.set_scroll_anchor(
+                                                ScrollAnchor {
+                                                    offset: Point::new(
+                                                        0.0,
+                                                        -(editor.file_header_size() as f32),
+                                                    ),
+                                                    anchor,
+                                                },
+                                                cx,
+                                            );
+                                        })
+                                    }
+                                }
+                                EntryOwned::Outline(_, outline) => {
+                                    let Some(full_buffer_snapshot) = outline
+                                        .range
+                                        .start
+                                        .buffer_id
+                                        .and_then(|buffer_id| {
+                                            active_multi_buffer.read(cx).buffer(buffer_id)
+                                        })
+                                        .or_else(|| {
+                                            outline.range.end.buffer_id.and_then(|buffer_id| {
+                                                active_multi_buffer.read(cx).buffer(buffer_id)
+                                            })
+                                        })
+                                        .map(|buffer| buffer.read(cx).snapshot())
+                                    else {
+                                        return;
+                                    };
+                                    let outline_offset_range =
+                                        outline.range.to_offset(&full_buffer_snapshot);
+                                    let scroll_target = multi_buffer_snapshot
+                                        .excerpts()
+                                        .filter(|(_, buffer_snapshot, _)| {
+                                            let buffer_id = buffer_snapshot.remote_id();
+                                            Some(buffer_id) == outline.range.start.buffer_id
+                                                || Some(buffer_id) == outline.range.end.buffer_id
+                                        })
+                                        .min_by_key(|(_, _, excerpt_range)| {
+                                            let excerpt_offeset_range = excerpt_range
+                                                .context
+                                                .to_offset(&full_buffer_snapshot);
+                                            ((outline_offset_range.start / 2
+                                                + outline_offset_range.end / 2)
+                                                as isize
+                                                - (excerpt_offeset_range.start / 2
+                                                    + excerpt_offeset_range.end / 2)
+                                                    as isize)
+                                                .abs()
+                                        })
+                                        .and_then(
+                                            |(excerpt_id, excerpt_snapshot, excerpt_range)| {
+                                                let location = if outline
+                                                    .range
+                                                    .start
+                                                    .is_valid(excerpt_snapshot)
+                                                {
+                                                    outline.range.start
+                                                } else {
+                                                    excerpt_range.context.start
+                                                };
+                                                multi_buffer_snapshot
+                                                    .anchor_in_excerpt(excerpt_id, location)
+                                            },
+                                        );
+                                    if let Some(anchor) = scroll_target {
+                                        outline_panel.selected_entry = Some(clicked_entry.clone());
+                                        active_editor.update(cx, |editor, cx| {
+                                            editor.set_scroll_anchor(
+                                                ScrollAnchor {
+                                                    offset: Point::default(),
+                                                    anchor,
+                                                },
+                                                cx,
+                                            );
+                                        })
+                                    }
+                                }
+                            }
+                        })
+                    })
+                    .on_secondary_mouse_down(cx.listener(
+                        move |outline_panel, event: &MouseDownEvent, cx| {
+                            // Stop propagation to prevent the catch-all context menu for the project
+                            // panel from being deployed.
+                            cx.stop_propagation();
+                            outline_panel.deploy_context_menu(
+                                event.position,
+                                rendered_entry.to_ref_entry(),
+                                cx,
+                            )
+                        },
+                    )),
+            )
+            .border_1()
+            .border_r_2()
+            .rounded_none()
+            .hover(|style| {
+                if is_active {
+                    style
+                } else {
+                    let hover_color = cx.theme().colors().ghost_element_hover;
+                    style.bg(hover_color).border_color(hover_color)
+                }
+            })
+            .when(is_active && self.focus_handle.contains_focused(cx), |div| {
+                div.border_color(Color::Selected.color(cx))
+            })
+    }
+
+    fn entry_name(
+        &self,
+        worktree_id: &WorktreeId,
+        entry: &Entry,
+        cx: &ViewContext<OutlinePanel>,
+    ) -> String {
+        let name = match self.project.read(cx).worktree_for_id(*worktree_id, cx) {
+            Some(worktree) => {
+                let worktree = worktree.read(cx);
+                match worktree.snapshot().root_entry() {
+                    Some(root_entry) => {
+                        if root_entry.id == entry.id {
+                            file_name(worktree.abs_path().as_ref())
+                        } else {
+                            let path = worktree.absolutize(entry.path.as_ref()).ok();
+                            let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref());
+                            file_name(path)
+                        }
+                    }
+                    None => {
+                        let path = worktree.absolutize(entry.path.as_ref()).ok();
+                        let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref());
+                        file_name(path)
+                    }
+                }
+            }
+            None => file_name(entry.path.as_ref()),
+        };
+        name
+    }
+
+    fn update_fs_entries(
+        &mut self,
+        active_editor: &View<Editor>,
+        new_entries: HashSet<ExcerptId>,
+        new_selected_entry: Option<EntryOwned>,
+        debounce: Option<Duration>,
+        prefetch: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if !self.active {
+            return;
+        }
+
+        let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
+        let active_multi_buffer = active_editor.read(cx).buffer().clone();
+        let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx);
+        let mut new_collapsed_dirs = self.collapsed_dirs.clone();
+        let mut new_unfolded_dirs = self.unfolded_dirs.clone();
+        let mut root_entries = HashSet::default();
+        let excerpts = multi_buffer_snapshot
+            .excerpts()
+            .map(|(excerpt_id, buffer_snapshot, _)| {
+                let file = File::from_dyn(buffer_snapshot.file());
+                let entry_id = file.and_then(|file| file.project_entry_id(cx));
+                let worktree = file.map(|file| file.worktree.read(cx).snapshot());
+                (excerpt_id, buffer_snapshot.remote_id(), entry_id, worktree)
+            })
+            .collect::<Vec<_>>();
+
+        self.update_task = cx.spawn(|outline_panel, mut cx| async move {
+            if let Some(debounce) = debounce {
+                cx.background_executor().timer(debounce).await;
+            }
+            let Some((new_collapsed_dirs, new_unfolded_dirs, new_fs_entries, new_depth_map)) = cx
+                .background_executor()
+                .spawn(async move {
+                    let mut processed_external_buffers = HashSet::default();
+                    let mut new_worktree_entries =
+                        HashMap::<WorktreeId, (worktree::Snapshot, HashSet<Entry>)>::default();
+                    let mut external_entries = Vec::default();
+
+                    for (excerpt_id, buffer_id, file_entry_id, worktree) in excerpts {
+                        let is_new = new_entries.contains(&excerpt_id);
+                        if let Some(worktree) = worktree {
+                            let collapsed_dirs =
+                                new_collapsed_dirs.entry(worktree.id()).or_default();
+                            let unfolded_dirs = new_unfolded_dirs.entry(worktree.id()).or_default();
+
+                            match file_entry_id
+                                .and_then(|id| worktree.entry_for_id(id))
+                                .cloned()
+                            {
+                                Some(entry) => {
+                                    let mut traversal = worktree.traverse_from_path(
+                                        true,
+                                        true,
+                                        true,
+                                        entry.path.as_ref(),
+                                    );
+
+                                    let mut entries_to_add = HashSet::default();
+                                    let mut current_entry = entry;
+                                    loop {
+                                        if current_entry.is_dir() {
+                                            let is_root =
+                                                worktree.root_entry().map(|entry| entry.id)
+                                                    == Some(current_entry.id);
+                                            if is_root {
+                                                root_entries.insert(current_entry.id);
+                                                if auto_fold_dirs {
+                                                    unfolded_dirs.insert(current_entry.id);
+                                                }
+                                            }
+
+                                            if is_new {
+                                                collapsed_dirs.remove(&current_entry.id);
+                                            } else if collapsed_dirs.contains(&current_entry.id) {
+                                                entries_to_add.clear();
+                                            }
+                                        }
+
+                                        let new_entry_added = entries_to_add.insert(current_entry);
+                                        if new_entry_added && traversal.back_to_parent() {
+                                            if let Some(parent_entry) = traversal.entry() {
+                                                current_entry = parent_entry.clone();
+                                                continue;
+                                            }
+                                        }
+                                        break;
+                                    }
+                                    new_worktree_entries
+                                        .entry(worktree.id())
+                                        .or_insert_with(|| (worktree.clone(), HashSet::default()))
+                                        .1
+                                        .extend(entries_to_add);
+                                }
+                                None => {
+                                    if processed_external_buffers.insert(buffer_id) {
+                                        external_entries.push(FsEntry::ExternalFile(buffer_id));
+                                    }
+                                }
+                            }
+                        } else if processed_external_buffers.insert(buffer_id) {
+                            external_entries.push(FsEntry::ExternalFile(buffer_id));
+                        }
+                    }
+
+                    external_entries.sort_by(|entry_a, entry_b| match (entry_a, entry_b) {
+                        (
+                            FsEntry::ExternalFile(buffer_id_a),
+                            FsEntry::ExternalFile(buffer_id_b),
+                        ) => buffer_id_a.cmp(&buffer_id_b),
+                        (FsEntry::ExternalFile(..), _) => cmp::Ordering::Less,
+                        (_, FsEntry::ExternalFile(..)) => cmp::Ordering::Greater,
+                        _ => cmp::Ordering::Equal,
+                    });
+
+                    #[derive(Clone, Copy, Default)]
+                    struct Children {
+                        files: usize,
+                        dirs: usize,
+                    }
+                    let mut children_count =
+                        HashMap::<WorktreeId, HashMap<PathBuf, Children>>::default();
+
+                    let worktree_entries = new_worktree_entries
+                        .into_iter()
+                        .map(|(worktree_id, (worktree_snapshot, entries))| {
+                            let mut entries = entries.into_iter().collect::<Vec<_>>();
+                            sort_worktree_entries(&mut entries);
+                            worktree_snapshot.propagate_git_statuses(&mut entries);
+                            (worktree_id, entries)
+                        })
+                        .flat_map(|(worktree_id, entries)| {
+                            {
+                                entries
+                                    .into_iter()
+                                    .map(|entry| {
+                                        if auto_fold_dirs {
+                                            if let Some(parent) = entry.path.parent() {
+                                                let children = children_count
+                                                    .entry(worktree_id)
+                                                    .or_default()
+                                                    .entry(parent.to_path_buf())
+                                                    .or_default();
+                                                if entry.is_dir() {
+                                                    children.dirs += 1;
+                                                } else {
+                                                    children.files += 1;
+                                                }
+                                            }
+                                        }
+
+                                        if entry.is_dir() {
+                                            FsEntry::Directory(worktree_id, entry)
+                                        } else {
+                                            FsEntry::File(worktree_id, entry)
+                                        }
+                                    })
+                                    .collect::<Vec<_>>()
+                            }
+                        })
+                        .collect::<Vec<_>>();
+
+                    let mut visited_dirs = Vec::new();
+                    let mut new_depth_map = HashMap::default();
+                    let new_visible_entries = external_entries
+                        .into_iter()
+                        .chain(worktree_entries)
+                        .filter(|visible_item| {
+                            match visible_item {
+                                FsEntry::Directory(worktree_id, dir_entry) => {
+                                    let parent_id = back_to_common_visited_parent(
+                                        &mut visited_dirs,
+                                        worktree_id,
+                                        dir_entry,
+                                    );
+
+                                    visited_dirs.push((dir_entry.id, dir_entry.path.clone()));
+                                    let depth = if root_entries.contains(&dir_entry.id) {
+                                        0
+                                    } else if auto_fold_dirs {
+                                        let (parent_folded, parent_depth) = match parent_id {
+                                            Some((worktree_id, id)) => (
+                                                new_unfolded_dirs
+                                                    .get(&worktree_id)
+                                                    .map_or(true, |unfolded_dirs| {
+                                                        !unfolded_dirs.contains(&id)
+                                                    }),
+                                                new_depth_map
+                                                    .get(&(worktree_id, id))
+                                                    .map(|&(_, depth)| depth)
+                                                    .unwrap_or(0),
+                                            ),
+
+                                            None => (false, 0),
+                                        };
+
+                                        let children = children_count
+                                            .get(&worktree_id)
+                                            .and_then(|children_count| {
+                                                children_count.get(&dir_entry.path.to_path_buf())
+                                            })
+                                            .copied()
+                                            .unwrap_or_default();
+                                        let folded = if children.dirs > 1
+                                            || (children.dirs == 1 && children.files > 0)
+                                            || (children.dirs == 0
+                                                && visited_dirs
+                                                    .last()
+                                                    .map(|(parent_dir_id, _)| {
+                                                        root_entries.contains(parent_dir_id)
+                                                    })
+                                                    .unwrap_or(true))
+                                        {
+                                            new_unfolded_dirs
+                                                .entry(*worktree_id)
+                                                .or_default()
+                                                .insert(dir_entry.id);
+                                            false
+                                        } else {
+                                            new_unfolded_dirs.get(&worktree_id).map_or(
+                                                true,
+                                                |unfolded_dirs| {
+                                                    !unfolded_dirs.contains(&dir_entry.id)
+                                                },
+                                            )
+                                        };
+
+                                        if parent_folded && folded {
+                                            parent_depth
+                                        } else {
+                                            parent_depth + 1
+                                        }
+                                    } else {
+                                        parent_id
+                                            .and_then(|(worktree_id, id)| {
+                                                new_depth_map
+                                                    .get(&(worktree_id, id))
+                                                    .map(|&(_, depth)| depth)
+                                            })
+                                            .unwrap_or(0)
+                                            + 1
+                                    };
+                                    new_depth_map
+                                        .insert((*worktree_id, dir_entry.id), (true, depth));
+                                }
+                                FsEntry::File(worktree_id, file_entry) => {
+                                    let parent_id = back_to_common_visited_parent(
+                                        &mut visited_dirs,
+                                        worktree_id,
+                                        file_entry,
+                                    );
+                                    let depth = if root_entries.contains(&file_entry.id) {
+                                        0
+                                    } else {
+                                        parent_id
+                                            .and_then(|(worktree_id, id)| {
+                                                new_depth_map
+                                                    .get(&(worktree_id, id))
+                                                    .map(|&(_, depth)| depth)
+                                            })
+                                            .unwrap_or(0)
+                                            + 1
+                                    };
+                                    new_depth_map
+                                        .insert((*worktree_id, file_entry.id), (false, depth));
+                                }
+                                FsEntry::ExternalFile(..) => {
+                                    visited_dirs.clear();
+                                }
+                            }
+
+                            true
+                        })
+                        .collect::<Vec<_>>();
+
+                    anyhow::Ok((
+                        new_collapsed_dirs,
+                        new_unfolded_dirs,
+                        new_visible_entries,
+                        new_depth_map,
+                    ))
+                })
+                .await
+                .log_err()
+            else {
+                return;
+            };
+
+            outline_panel
+                .update(&mut cx, |outline_panel, cx| {
+                    outline_panel.collapsed_dirs = new_collapsed_dirs;
+                    outline_panel.unfolded_dirs = new_unfolded_dirs;
+                    outline_panel.fs_entries = new_fs_entries;
+                    outline_panel.fs_entries_depth = new_depth_map;
+                    outline_panel.cached_entries_with_depth = None;
+                    if new_selected_entry.is_some() {
+                        outline_panel.selected_entry = new_selected_entry;
+                    }
+                    if prefetch {
+                        let range = if outline_panel.last_visible_range.is_empty() {
+                            0..(outline_panel.entries_with_depths(cx).len() / 4).min(50)
+                        } else {
+                            outline_panel.last_visible_range.clone()
+                        };
+                        outline_panel.fetch_outlines(&range, cx);
+                    }
+
+                    outline_panel.autoscroll(cx);
+                    cx.notify();
+                })
+                .ok();
+        });
+    }
+
+    fn replace_visible_entries(
+        &mut self,
+        new_active_editor: View<Editor>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.clear_previous();
+        self.active_item = Some(ActiveItem {
+            item_id: new_active_editor.item_id(),
+            _editor_subscrpiption: subscribe_for_editor_events(&new_active_editor, cx),
+            active_editor: new_active_editor.downgrade(),
+        });
+        let new_entries =
+            HashSet::from_iter(new_active_editor.read(cx).buffer().read(cx).excerpt_ids());
+        self.update_fs_entries(&new_active_editor, new_entries, None, None, true, cx);
+    }
+
+    fn clear_previous(&mut self) {
+        self.collapsed_dirs.clear();
+        self.unfolded_dirs.clear();
+        self.last_visible_range = 0..0;
+        self.selected_entry = None;
+        self.update_task = Task::ready(());
+        self.active_item = None;
+        self.fs_entries.clear();
+        self.fs_entries_depth.clear();
+        self.outline_fetch_tasks.clear();
+        self.outlines.clear();
+        self.cached_entries_with_depth = None;
+    }
+
+    fn location_for_editor_selection(
+        &self,
+        editor: &View<Editor>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<(OutlinesContainer, Option<Outline>)> {
+        let selection = editor
+            .read(cx)
+            .selections
+            .newest::<language::Point>(cx)
+            .head();
+        let multi_buffer = editor.read(cx).buffer();
+        let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
+        let selection = multi_buffer_snapshot.anchor_before(selection);
+        let buffer_snapshot = multi_buffer_snapshot.buffer_for_excerpt(selection.excerpt_id)?;
+
+        let container = match File::from_dyn(buffer_snapshot.file())
+            .and_then(|file| Some(file.worktree.read(cx).id()).zip(file.entry_id))
+        {
+            Some((worktree_id, id)) => OutlinesContainer::File(worktree_id, id),
+            None => OutlinesContainer::ExternalFile(buffer_snapshot.remote_id()),
+        };
+
+        let outline_item = self
+            .outlines
+            .get(&container)
+            .into_iter()
+            .flatten()
+            .filter(|outline| {
+                outline.range.start.buffer_id == selection.buffer_id
+                    || outline.range.end.buffer_id == selection.buffer_id
+            })
+            .filter(|outline_item| {
+                range_contains(&outline_item.range, selection.text_anchor, buffer_snapshot)
+            })
+            .min_by_key(|outline| {
+                let range = outline.range.start.offset..outline.range.end.offset;
+                let cursor_offset = selection.text_anchor.offset as isize;
+                let distance_to_closest_endpoint = cmp::min(
+                    (range.start as isize - cursor_offset).abs(),
+                    (range.end as isize - cursor_offset).abs(),
+                );
+                distance_to_closest_endpoint
+            })
+            .cloned();
+
+        Some((container, outline_item))
+    }
+
+    fn fetch_outlines(&mut self, range: &Range<usize>, cx: &mut ViewContext<Self>) {
+        let Some(editor) = self
+            .active_item
+            .as_ref()
+            .and_then(|item| item.active_editor.upgrade())
+        else {
+            return;
+        };
+
+        let range_len = range.len();
+        let half_range = range_len / 2;
+        let entries = self.entries_with_depths(cx);
+        let expanded_range =
+            range.start.saturating_sub(half_range)..(range.end + half_range).min(entries.len());
+        let containers = entries
+            .get(expanded_range)
+            .into_iter()
+            .flatten()
+            .flat_map(|(_, entry)| entry.outlines_container())
+            .collect::<Vec<_>>();
+        let fetch_outlines_for = containers
+            .into_iter()
+            .filter(|container| match self.outlines.entry(*container) {
+                hash_map::Entry::Occupied(_) => false,
+                hash_map::Entry::Vacant(v) => {
+                    v.insert(Vec::new());
+                    true
+                }
+            })
+            .collect::<HashSet<_>>();
+
+        let outlines_to_fetch = editor
+            .read(cx)
+            .buffer()
+            .read(cx)
+            .snapshot(cx)
+            .excerpts()
+            .filter_map(|(_, buffer_snapshot, excerpt_range)| {
+                let container = match File::from_dyn(buffer_snapshot.file()) {
+                    Some(file) => {
+                        let entry_id = file.project_entry_id(cx);
+                        let worktree_id = file.worktree.read(cx).id();
+                        entry_id.map(|entry_id| OutlinesContainer::File(worktree_id, entry_id))
+                    }
+                    None => Some(OutlinesContainer::ExternalFile(buffer_snapshot.remote_id())),
+                }?;
+                Some((container, (buffer_snapshot.clone(), excerpt_range)))
+            })
+            .filter(|(container, _)| fetch_outlines_for.contains(container))
+            .collect::<Vec<_>>();
+        if outlines_to_fetch.is_empty() {
+            return;
+        }
+
+        let syntax_theme = cx.theme().syntax().clone();
+        self.outline_fetch_tasks
+            .push(cx.spawn(|outline_panel, mut cx| async move {
+                let mut processed_outlines =
+                    HashMap::<OutlinesContainer, HashSet<Outline>>::default();
+                let fetched_outlines = cx
+                    .background_executor()
+                    .spawn(async move {
+                        outlines_to_fetch
+                            .into_iter()
+                            .map(|(container, (buffer_snapshot, excerpt_range))| {
+                                (
+                                    container,
+                                    buffer_snapshot
+                                        .outline_items_containing(
+                                            excerpt_range.context,
+                                            false,
+                                            Some(&syntax_theme),
+                                        )
+                                        .unwrap_or_default(),
+                                )
+                            })
+                            .fold(
+                                HashMap::default(),
+                                |mut outlines, (container, new_outlines)| {
+                                    outlines
+                                        .entry(container)
+                                        .or_insert_with(Vec::new)
+                                        .extend(new_outlines);
+                                    outlines
+                                },
+                            )
+                    })
+                    .await;
+                outline_panel
+                    .update(&mut cx, |outline_panel, cx| {
+                        for (container, fetched_outlines) in fetched_outlines {
+                            let existing_outlines =
+                                outline_panel.outlines.entry(container).or_default();
+                            let processed_outlines =
+                                processed_outlines.entry(container).or_default();
+                            processed_outlines.extend(existing_outlines.iter().cloned());
+                            for fetched_outline in fetched_outlines {
+                                if processed_outlines.insert(fetched_outline.clone()) {
+                                    existing_outlines.push(fetched_outline);
+                                }
+                            }
+                        }
+                        outline_panel.cached_entries_with_depth = None;
+                        cx.notify();
+                    })
+                    .ok();
+            }));
+    }
+
+    fn entries_with_depths(&mut self, cx: &AppContext) -> &[(usize, EntryOwned)] {
+        self.cached_entries_with_depth.get_or_insert_with(|| {
+            let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
+            let mut folded_dirs_entry = None::<(usize, WorktreeId, Vec<Entry>)>;
+            let mut entries = Vec::new();
+
+            for entry in &self.fs_entries {
+                let mut depth = match entry {
+                    FsEntry::Directory(worktree_id, dir_entry) => {
+                        let depth = self
+                            .fs_entries_depth
+                            .get(&(*worktree_id, dir_entry.id))
+                            .map(|&(_, depth)| depth)
+                            .unwrap_or(0);
+                        if auto_fold_dirs {
+                            let folded = self
+                                .unfolded_dirs
+                                .get(worktree_id)
+                                .map_or(true, |unfolded_dirs| {
+                                    !unfolded_dirs.contains(&dir_entry.id)
+                                });
+                            if folded {
+                                if let Some((folded_depth, folded_worktree_id, mut folded_dirs)) =
+                                    folded_dirs_entry.take()
+                                {
+                                    if worktree_id == &folded_worktree_id
+                                        && dir_entry.path.parent()
+                                            == folded_dirs.last().map(|entry| entry.path.as_ref())
+                                    {
+                                        folded_dirs.push(dir_entry.clone());
+                                        folded_dirs_entry =
+                                            Some((folded_depth, folded_worktree_id, folded_dirs))
+                                    } else {
+                                        entries.push((
+                                            folded_depth,
+                                            EntryOwned::FoldedDirs(folded_worktree_id, folded_dirs),
+                                        ));
+                                        folded_dirs_entry =
+                                            Some((depth, *worktree_id, vec![dir_entry.clone()]))
+                                    }
+                                } else {
+                                    folded_dirs_entry =
+                                        Some((depth, *worktree_id, vec![dir_entry.clone()]))
+                                }
+
+                                continue;
+                            }
+                        }
+                        depth
+                    }
+                    FsEntry::ExternalFile(_) => 0,
+                    FsEntry::File(worktree_id, file_entry) => self
+                        .fs_entries_depth
+                        .get(&(*worktree_id, file_entry.id))
+                        .map(|&(_, depth)| depth)
+                        .unwrap_or(0),
+                };
+                if let Some((folded_depth, worktree_id, folded_dirs)) = folded_dirs_entry.take() {
+                    entries.push((
+                        folded_depth,
+                        EntryOwned::FoldedDirs(worktree_id, folded_dirs),
+                    ));
+                }
+
+                entries.push((depth, EntryOwned::Entry(entry.clone())));
+                let mut outline_depth = None::<usize>;
+                entries.extend(
+                    entry
+                        .outlines_container()
+                        .and_then(|container| Some((container, self.outlines.get(&container)?)))
+                        .into_iter()
+                        .flat_map(|(container, outlines)| {
+                            outlines.iter().map(move |outline| (container, outline))
+                        })
+                        .map(move |(container, outline)| {
+                            if let Some(outline_depth) = outline_depth {
+                                match outline_depth.cmp(&outline.depth) {
+                                    cmp::Ordering::Less => depth += 1,
+                                    cmp::Ordering::Equal => {}
+                                    cmp::Ordering::Greater => depth -= 1,
+                                };
+                            }
+                            outline_depth = Some(outline.depth);
+                            (depth, EntryOwned::Outline(container, outline.clone()))
+                        }),
+                )
+            }
+            if let Some((folded_depth, worktree_id, folded_dirs)) = folded_dirs_entry.take() {
+                entries.push((
+                    folded_depth,
+                    EntryOwned::FoldedDirs(worktree_id, folded_dirs),
+                ));
+            }
+            entries
+        })
+    }
+}
+
+fn back_to_common_visited_parent(
+    visited_dirs: &mut Vec<(ProjectEntryId, Arc<Path>)>,
+    worktree_id: &WorktreeId,
+    new_entry: &Entry,
+) -> Option<(WorktreeId, ProjectEntryId)> {
+    while let Some((visited_dir_id, visited_path)) = visited_dirs.last() {
+        match new_entry.path.parent() {
+            Some(parent_path) => {
+                if parent_path == visited_path.as_ref() {
+                    return Some((*worktree_id, *visited_dir_id));
+                }
+            }
+            None => {
+                break;
+            }
+        }
+        visited_dirs.pop();
+    }
+    None
+}
+
+fn sort_worktree_entries(entries: &mut Vec<Entry>) {
+    entries.sort_by(|entry_a, entry_b| {
+        let mut components_a = entry_a.path.components().peekable();
+        let mut components_b = entry_b.path.components().peekable();
+        loop {
+            match (components_a.next(), components_b.next()) {
+                (Some(component_a), Some(component_b)) => {
+                    let a_is_file = components_a.peek().is_none() && entry_a.is_file();
+                    let b_is_file = components_b.peek().is_none() && entry_b.is_file();
+                    let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
+                        let maybe_numeric_ordering = maybe!({
+                            let num_and_remainder_a = Path::new(component_a.as_os_str())
+                                .file_stem()
+                                .and_then(|s| s.to_str())
+                                .and_then(NumericPrefixWithSuffix::from_numeric_prefixed_str)?;
+                            let num_and_remainder_b = Path::new(component_b.as_os_str())
+                                .file_stem()
+                                .and_then(|s| s.to_str())
+                                .and_then(NumericPrefixWithSuffix::from_numeric_prefixed_str)?;
+
+                            num_and_remainder_a.partial_cmp(&num_and_remainder_b)
+                        });
+
+                        maybe_numeric_ordering.unwrap_or_else(|| {
+                            let name_a = UniCase::new(component_a.as_os_str().to_string_lossy());
+                            let name_b = UniCase::new(component_b.as_os_str().to_string_lossy());
+
+                            name_a.cmp(&name_b)
+                        })
+                    });
+                    if !ordering.is_eq() {
+                        return ordering;
+                    }
+                }
+                (Some(_), None) => break cmp::Ordering::Greater,
+                (None, Some(_)) => break cmp::Ordering::Less,
+                (None, None) => break cmp::Ordering::Equal,
+            }
+        }
+    });
+}
+
+fn file_name(path: &Path) -> String {
+    let mut current_path = path;
+    loop {
+        if let Some(file_name) = current_path.file_name() {
+            return file_name.to_string_lossy().into_owned();
+        }
+        match current_path.parent() {
+            Some(parent) => current_path = parent,
+            None => return path.to_string_lossy().into_owned(),
+        }
+    }
+}
+
+fn directory_contains(directory_entry: &Entry, child_entry: &Entry) -> bool {
+    debug_assert!(directory_entry.is_dir());
+    let Some(relative_path) = child_entry.path.strip_prefix(&directory_entry.path).ok() else {
+        return false;
+    };
+    relative_path.iter().count() == 1
+}
+
+impl Panel for OutlinePanel {
+    fn persistent_name() -> &'static str {
+        "Outline Panel"
+    }
+
+    fn position(&self, cx: &WindowContext) -> DockPosition {
+        match OutlinePanelSettings::get_global(cx).dock {
+            OutlinePanelDockPosition::Left => DockPosition::Left,
+            OutlinePanelDockPosition::Right => DockPosition::Right,
+        }
+    }
+
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        matches!(position, DockPosition::Left | DockPosition::Right)
+    }
+
+    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+        settings::update_settings_file::<OutlinePanelSettings>(
+            self.fs.clone(),
+            cx,
+            move |settings| {
+                let dock = match position {
+                    DockPosition::Left | DockPosition::Bottom => OutlinePanelDockPosition::Left,
+                    DockPosition::Right => OutlinePanelDockPosition::Right,
+                };
+                settings.dock = Some(dock);
+            },
+        );
+    }
+
+    fn size(&self, cx: &WindowContext) -> Pixels {
+        self.width
+            .unwrap_or_else(|| OutlinePanelSettings::get_global(cx).default_width)
+    }
+
+    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
+        self.width = size;
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn icon(&self, cx: &WindowContext) -> Option<IconName> {
+        OutlinePanelSettings::get_global(cx)
+            .button
+            .then(|| IconName::ListTree)
+    }
+
+    fn icon_tooltip(&self, _: &WindowContext) -> Option<&'static str> {
+        Some("Outline Panel")
+    }
+
+    fn toggle_action(&self) -> Box<dyn Action> {
+        Box::new(ToggleFocus)
+    }
+
+    fn starts_open(&self, _: &WindowContext) -> bool {
+        self.active_item.is_some()
+    }
+
+    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        let old_active = self.active;
+        self.active = active;
+        if active && old_active != active {
+            if let Some(active_editor) = self
+                .active_item
+                .as_ref()
+                .and_then(|item| item.active_editor.upgrade())
+            {
+                self.replace_visible_entries(active_editor, cx);
+            }
+        }
+    }
+}
+
+impl FocusableView for OutlinePanel {
+    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl EventEmitter<Event> for OutlinePanel {}
+
+impl EventEmitter<PanelEvent> for OutlinePanel {}
+
+impl Render for OutlinePanel {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let project = self.project.read(cx);
+        if self.fs_entries.is_empty() {
+            v_flex()
+                .id("empty-outline_panel")
+                .size_full()
+                .p_4()
+                .track_focus(&self.focus_handle)
+                .child(Label::new("No editor outlines available"))
+        } else {
+            h_flex()
+                .id("outline-panel")
+                .size_full()
+                .relative()
+                .key_context(self.dispatch_context(cx))
+                .on_action(cx.listener(Self::select_next))
+                .on_action(cx.listener(Self::select_prev))
+                .on_action(cx.listener(Self::select_first))
+                .on_action(cx.listener(Self::select_last))
+                .on_action(cx.listener(Self::select_parent))
+                .on_action(cx.listener(Self::expand_selected_entry))
+                .on_action(cx.listener(Self::collapse_selected_entry))
+                .on_action(cx.listener(Self::collapse_all_entries))
+                .on_action(cx.listener(Self::copy_path))
+                .on_action(cx.listener(Self::copy_relative_path))
+                .on_action(cx.listener(Self::unfold_directory))
+                .on_action(cx.listener(Self::fold_directory))
+                .when(project.is_local(), |el| {
+                    el.on_action(cx.listener(Self::reveal_in_finder))
+                        .on_action(cx.listener(Self::open_in_terminal))
+                })
+                .on_mouse_down(
+                    MouseButton::Right,
+                    cx.listener(move |outline_panel, event: &MouseDownEvent, cx| {
+                        if let Some(entry) = outline_panel.selected_entry.clone() {
+                            outline_panel.deploy_context_menu(
+                                event.position,
+                                entry.to_ref_entry(),
+                                cx,
+                            )
+                        } else if let Some(entry) = outline_panel.fs_entries.first().cloned() {
+                            outline_panel.deploy_context_menu(
+                                event.position,
+                                EntryRef::Entry(&entry),
+                                cx,
+                            )
+                        }
+                    }),
+                )
+                .track_focus(&self.focus_handle)
+                .child({
+                    let items_len = self.entries_with_depths(cx).len();
+                    uniform_list(cx.view().clone(), "entries", items_len, {
+                        move |outline_panel, range, cx| {
+                            outline_panel.last_visible_range = range.clone();
+                            outline_panel.fetch_outlines(&range, cx);
+                            outline_panel
+                                .entries_with_depths(cx)
+                                .get(range)
+                                .map(|entries| entries.to_vec())
+                                .into_iter()
+                                .flatten()
+                                .map(|(depth, dipslayed_item)| match dipslayed_item {
+                                    EntryOwned::Entry(entry) => {
+                                        outline_panel.render_entry(&entry, depth, cx)
+                                    }
+                                    EntryOwned::FoldedDirs(worktree_id, entries) => outline_panel
+                                        .render_folded_dirs(worktree_id, &entries, depth, cx),
+                                    EntryOwned::Outline(container, outline) => {
+                                        outline_panel.render_outline(container, &outline, depth, cx)
+                                    }
+                                })
+                                .collect()
+                        }
+                    })
+                    .size_full()
+                    .track_scroll(self.scroll_handle.clone())
+                })
+                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
+                    deferred(
+                        anchored()
+                            .position(*position)
+                            .anchor(gpui::AnchorCorner::TopLeft)
+                            .child(menu.clone()),
+                    )
+                    .with_priority(1)
+                }))
+        }
+    }
+}
+
+fn subscribe_for_editor_events(
+    editor: &View<Editor>,
+    cx: &mut ViewContext<OutlinePanel>,
+) -> Option<Subscription> {
+    if OutlinePanelSettings::get_global(cx).auto_reveal_entries {
+        let debounce = Some(Duration::from_millis(UPDATE_DEBOUNCE_MILLIS));
+        Some(cx.subscribe(
+            editor,
+            move |outline_panel, editor, e: &EditorEvent, cx| match e {
+                EditorEvent::SelectionsChanged { local: true } => {
+                    outline_panel.reveal_entry_for_selection(&editor, cx);
+                    cx.notify();
+                }
+                EditorEvent::ExcerptsAdded { excerpts, .. } => {
+                    outline_panel.update_fs_entries(
+                        &editor,
+                        excerpts.iter().map(|&(excerpt_id, _)| excerpt_id).collect(),
+                        None,
+                        debounce,
+                        false,
+                        cx,
+                    );
+                }
+                EditorEvent::ExcerptsRemoved { .. } => {
+                    outline_panel.update_fs_entries(
+                        &editor,
+                        HashSet::default(),
+                        None,
+                        debounce,
+                        false,
+                        cx,
+                    );
+                }
+                EditorEvent::ExcerptsExpanded { .. } => {
+                    outline_panel.update_fs_entries(
+                        &editor,
+                        HashSet::default(),
+                        None,
+                        debounce,
+                        true,
+                        cx,
+                    );
+                }
+                EditorEvent::Reparsed => {
+                    outline_panel.outline_fetch_tasks.clear();
+                    outline_panel.outlines.clear();
+                    outline_panel.update_fs_entries(
+                        &editor,
+                        HashSet::default(),
+                        None,
+                        debounce,
+                        true,
+                        cx,
+                    );
+                }
+                _ => {}
+            },
+        ))
+    } else {
+        None
+    }
+}
+
+fn range_contains(
+    range: &Range<language::Anchor>,
+    anchor: language::Anchor,
+    buffer_snapshot: &language::BufferSnapshot,
+) -> bool {
+    range.start.cmp(&anchor, buffer_snapshot).is_le()
+        && range.end.cmp(&anchor, buffer_snapshot).is_ge()
+}

crates/outline_panel/src/outline_panel_settings.rs 🔗

@@ -0,0 +1,81 @@
+use anyhow;
+use gpui::Pixels;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsSources};
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Copy, PartialEq)]
+#[serde(rename_all = "snake_case")]
+pub enum OutlinePanelDockPosition {
+    Left,
+    Right,
+}
+
+#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
+pub struct OutlinePanelSettings {
+    pub button: bool,
+    pub default_width: Pixels,
+    pub dock: OutlinePanelDockPosition,
+    pub file_icons: bool,
+    pub folder_icons: bool,
+    pub git_status: bool,
+    pub indent_size: f32,
+    pub auto_reveal_entries: bool,
+    pub auto_fold_dirs: bool,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct OutlinePanelSettingsContent {
+    /// Whether to show the outline panel button in the status bar.
+    ///
+    /// Default: true
+    pub button: Option<bool>,
+    /// Customise default width (in pixels) taken by outline panel
+    ///
+    /// Default: 240
+    pub default_width: Option<f32>,
+    /// The position of outline panel
+    ///
+    /// Default: left
+    pub dock: Option<OutlinePanelDockPosition>,
+    /// Whether to show file icons in the outline panel.
+    ///
+    /// Default: true
+    pub file_icons: Option<bool>,
+    /// Whether to show folder icons or chevrons for directories in the outline panel.
+    ///
+    /// Default: true
+    pub folder_icons: Option<bool>,
+    /// Whether to show the git status in the outline panel.
+    ///
+    /// Default: true
+    pub git_status: Option<bool>,
+    /// Amount of indentation (in pixels) for nested items.
+    ///
+    /// Default: 20
+    pub indent_size: Option<f32>,
+    /// Whether to reveal it in the outline panel automatically,
+    /// when a corresponding project entry becomes active.
+    /// Gitignored entries are never auto revealed.
+    ///
+    /// Default: true
+    pub auto_reveal_entries: Option<bool>,
+    /// Whether to fold directories automatically
+    /// when directory has only one directory inside.
+    ///
+    /// Default: true
+    pub auto_fold_dirs: Option<bool>,
+}
+
+impl Settings for OutlinePanelSettings {
+    const KEY: Option<&'static str> = Some("outline_panel");
+
+    type FileContent = OutlinePanelSettingsContent;
+
+    fn load(
+        sources: SettingsSources<Self::FileContent>,
+        _: &mut gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        sources.json_merge()
+    }
+}

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

@@ -144,6 +144,7 @@ pub enum IconName {
     InlayHint,
     Library,
     Link,
+    ListTree,
     MagicWand,
     MagnifyingGlass,
     MailOpen,
@@ -274,6 +275,7 @@ impl IconName {
             IconName::InlayHint => "icons/inlay_hint.svg",
             IconName::Library => "icons/library.svg",
             IconName::Link => "icons/link.svg",
+            IconName::ListTree => "icons/list_tree.svg",
             IconName::MagicWand => "icons/magic_wand.svg",
             IconName::MagnifyingGlass => "icons/magnifying_glass.svg",
             IconName::MailOpen => "icons/mail_open.svg",

crates/worktree/src/worktree.rs 🔗

@@ -2000,7 +2000,7 @@ impl Snapshot {
         }
     }
 
-    fn traverse_from_path(
+    pub fn traverse_from_path(
         &self,
         include_files: bool,
         include_dirs: bool,
@@ -2991,7 +2991,7 @@ impl File {
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
 pub struct Entry {
     pub id: ProjectEntryId,
     pub kind: EntryKind,
@@ -3020,7 +3020,7 @@ pub struct Entry {
     pub is_private: bool,
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
 pub enum EntryKind {
     UnloadedDir,
     PendingDir,
@@ -4818,6 +4818,14 @@ impl<'a> Traversal<'a> {
         false
     }
 
+    pub fn back_to_parent(&mut self) -> bool {
+        let Some(parent_path) = self.cursor.item().and_then(|entry| entry.path.parent()) else {
+            return false;
+        };
+        self.cursor
+            .seek(&TraversalTarget::Path(parent_path), Bias::Left, &())
+    }
+
     pub fn entry(&self) -> Option<&'a Entry> {
         self.cursor.item()
     }

crates/zed/Cargo.toml 🔗

@@ -68,6 +68,7 @@ nix = {workspace = true, features = ["pthread", "signal"] }
 node_runtime.workspace = true
 notifications.workspace = true
 outline.workspace = true
+outline_panel.workspace = true
 parking_lot.workspace = true
 profiling.workspace = true
 project.workspace = true

crates/zed/src/main.rs 🔗

@@ -185,6 +185,7 @@ fn init_ui(app_state: Arc<AppState>, cx: &mut AppContext) -> Result<()> {
     outline::init(cx);
     project_symbols::init(cx);
     project_panel::init(Assets, cx);
+    outline_panel::init(Assets, cx);
     tasks_ui::init(cx);
     channel::init(&app_state.client.clone(), app_state.user_store.clone(), cx);
     search::init(cx);

crates/zed/src/zed.rs 🔗

@@ -18,6 +18,7 @@ pub use open_listener::*;
 use anyhow::Context as _;
 use assets::Assets;
 use futures::{channel::mpsc, select_biased, StreamExt};
+use outline_panel::OutlinePanel;
 use project::TaskSourceKind;
 use project_panel::ProjectPanel;
 use quick_action_bar::QuickActionBar;
@@ -190,6 +191,7 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
             let assistant_panel =
                 assistant::AssistantPanel::load(workspace_handle.clone(), cx.clone());
             let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
+            let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone());
             let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone());
             let channels_panel =
                 collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
@@ -202,6 +204,7 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
 
             let (
                 project_panel,
+                outline_panel,
                 terminal_panel,
                 assistant_panel,
                 channels_panel,
@@ -209,6 +212,7 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
                 notification_panel,
             ) = futures::try_join!(
                 project_panel,
+                outline_panel,
                 terminal_panel,
                 assistant_panel,
                 channels_panel,
@@ -219,6 +223,7 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
             workspace_handle.update(&mut cx, |workspace, cx| {
                 workspace.add_panel(assistant_panel, cx);
                 workspace.add_panel(project_panel, cx);
+                workspace.add_panel(outline_panel, cx);
                 workspace.add_panel(terminal_panel, cx);
                 workspace.add_panel(channels_panel, cx);
                 workspace.add_panel(chat_panel, cx);
@@ -377,6 +382,13 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
                     workspace.toggle_panel_focus::<ProjectPanel>(cx);
                 },
             )
+            .register_action(
+                |workspace: &mut Workspace,
+                 _: &outline_panel::ToggleFocus,
+                 cx: &mut ViewContext<Workspace>| {
+                    workspace.toggle_panel_focus::<OutlinePanel>(cx);
+                },
+            )
             .register_action(
                 |workspace: &mut Workspace,
                  _: &collab_ui::collab_panel::ToggleFocus,
@@ -3093,9 +3105,9 @@ mod tests {
             command_palette::init(cx);
             language::init(cx);
             editor::init(cx);
-            project_panel::init_settings(cx);
             collab_ui::init(&app_state, cx);
             project_panel::init((), cx);
+            outline_panel::init((), cx);
             terminal_view::init(cx);
             assistant::init(app_state.client.clone(), cx);
             tasks_ui::init(cx);

crates/zed/src/zed/app_menus.rs 🔗

@@ -123,6 +123,7 @@ pub fn app_menus() -> Vec<Menu<'static>> {
                 }),
                 MenuItem::separator(),
                 MenuItem::action("Project Panel", project_panel::ToggleFocus),
+                MenuItem::action("Outline Panel", outline_panel::ToggleFocus),
                 MenuItem::action("Collab Panel", collab_panel::ToggleFocus),
                 MenuItem::action("Terminal Panel", terminal_panel::ToggleFocus),
                 MenuItem::separator(),