Add scrollbars to outline panel (#19969)

Kirill Bulatov and Nate Butler created

Part of https://github.com/zed-industries/zed/issues/15324


![image](https://github.com/user-attachments/assets/4f32d585-9bd2-46be-8234-3658a71906ee)

Repeats the approach used in the project panel.

Release Notes:

- Added scrollbars to outline panel

---------

Co-authored-by: Nate Butler <nate@zed.dev>

Change summary

assets/settings/default.json                       |  17 
crates/outline_panel/src/outline_panel.rs          | 849 ++++++++++-----
crates/outline_panel/src/outline_panel_settings.rs |  20 
docs/src/configuring-zed.md                        |   3 
4 files changed, 598 insertions(+), 291 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -414,6 +414,23 @@
       // 2. Never show indent guides:
       //    "never"
       "show": "always"
+    },
+    /// Scrollbar-related settings
+    "scrollbar": {
+      /// When to show the scrollbar in the project panel.
+      /// This setting can take four values:
+      ///
+      /// 1. null (default): Inherit editor settings
+      /// 2. Show the scrollbar if there's important information or
+      ///    follow the system's configured behavior (default):
+      ///   "auto"
+      /// 3. Match the system's configured behavior:
+      ///    "system"
+      /// 4. Always show the scrollbar:
+      ///    "always"
+      /// 5. Never show the scrollbar:
+      ///    "never"
+      "show": null
     }
   },
   "collaboration_panel": {

crates/outline_panel/src/outline_panel.rs 🔗

@@ -5,7 +5,7 @@ use std::{
     cmp,
     hash::Hash,
     ops::Range,
-    path::{Path, PathBuf},
+    path::{Path, PathBuf, MAIN_SEPARATOR_STR},
     sync::{atomic::AtomicBool, Arc, OnceLock},
     time::Duration,
     u32,
@@ -17,9 +17,9 @@ use db::kvp::KEY_VALUE_STORE;
 use editor::{
     display_map::ToDisplayPoint,
     items::{entry_git_aware_label_color, entry_label_color},
-    scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor},
-    AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, EditorMode, ExcerptId, ExcerptRange,
-    MultiBufferSnapshot, RangeToAnchorExt,
+    scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor, ScrollbarAutoHide},
+    AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, EditorMode, EditorSettings, ExcerptId,
+    ExcerptRange, MultiBufferSnapshot, RangeToAnchorExt, ShowScrollbar,
 };
 use file_icons::FileIcons;
 use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
@@ -27,8 +27,9 @@ use gpui::{
     actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action,
     AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent,
     Div, ElementId, EventEmitter, FocusHandle, FocusableView, HighlightStyle, InteractiveElement,
-    IntoElement, KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
-    Render, SharedString, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View,
+    IntoElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton,
+    MouseDownEvent, ParentElement, Pixels, Point, Render, SharedString, Stateful,
+    StatefulInteractiveElement as _, Styled, Subscription, Task, UniformListScrollHandle, View,
     ViewContext, VisualContext, WeakView, WindowContext,
 };
 use itertools::Itertools;
@@ -51,7 +52,8 @@ use workspace::{
     ui::{
         h_flex, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, FluentBuilder,
         HighlightedLabel, Icon, IconButton, IconButtonShape, IconName, IconSize, Label,
-        LabelCommon, ListItem, Selectable, Spacing, StyledExt, StyledTypography, Tooltip,
+        LabelCommon, ListItem, Scrollbar, ScrollbarState, Selectable, Spacing, StyledExt,
+        StyledTypography, Tooltip,
     },
     OpenInTerminal, WeakItemHandle, Workspace,
 };
@@ -116,6 +118,11 @@ pub struct OutlinePanel {
     cached_entries: Vec<CachedEntry>,
     filter_editor: View<Editor>,
     mode: ItemsDisplayMode,
+    show_scrollbar: bool,
+    vertical_scrollbar_state: ScrollbarState,
+    horizontal_scrollbar_state: ScrollbarState,
+    hide_scrollbar_task: Option<Task<()>>,
+    max_width_item_index: Option<usize>,
 }
 
 enum ItemsDisplayMode {
@@ -624,6 +631,9 @@ impl OutlinePanel {
 
             let focus_handle = cx.focus_handle();
             let focus_subscription = cx.on_focus(&focus_handle, Self::focus_in);
+            let focus_out_subscription = cx.on_focus_out(&focus_handle, |outline_panel, _, cx| {
+                outline_panel.hide_scrollbar(cx);
+            });
             let workspace_subscription = cx.subscribe(
                 &workspace
                     .weak_handle()
@@ -674,6 +684,8 @@ impl OutlinePanel {
                     }
                 });
 
+            let scroll_handle = UniformListScrollHandle::new();
+
             let mut outline_panel = Self {
                 mode: ItemsDisplayMode::Outline,
                 active: false,
@@ -681,7 +693,14 @@ impl OutlinePanel {
                 workspace: workspace_handle,
                 project,
                 fs: workspace.app_state().fs.clone(),
-                scroll_handle: UniformListScrollHandle::new(),
+                show_scrollbar: !Self::should_autohide_scrollbar(cx),
+                hide_scrollbar_task: None,
+                vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
+                    .parent_view(cx.view()),
+                horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
+                    .parent_view(cx.view()),
+                max_width_item_index: None,
+                scroll_handle,
                 focus_handle,
                 filter_editor,
                 fs_entries: Vec::new(),
@@ -705,6 +724,7 @@ impl OutlinePanel {
                     settings_subscription,
                     icons_subscription,
                     focus_subscription,
+                    focus_out_subscription,
                     workspace_subscription,
                     filter_update_subscription,
                 ],
@@ -1606,16 +1626,11 @@ impl OutlinePanel {
         }
         .unwrap_or_else(empty_icon);
 
-        let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx)?;
-        let excerpt_range = range.context.to_point(&buffer_snapshot);
-        let label_element = Label::new(format!(
-            "Lines {}- {}",
-            excerpt_range.start.row + 1,
-            excerpt_range.end.row + 1,
-        ))
-        .single_line()
-        .color(color)
-        .into_any_element();
+        let label = self.excerpt_label(buffer_id, range, cx)?;
+        let label_element = Label::new(label)
+            .single_line()
+            .color(color)
+            .into_any_element();
 
         Some(self.entry_element(
             PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, range.clone())),
@@ -1628,6 +1643,21 @@ impl OutlinePanel {
         ))
     }
 
+    fn excerpt_label(
+        &self,
+        buffer_id: BufferId,
+        range: &ExcerptRange<language::Anchor>,
+        cx: &AppContext,
+    ) -> Option<String> {
+        let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx)?;
+        let excerpt_range = range.context.to_point(&buffer_snapshot);
+        Some(format!(
+            "Lines {}- {}",
+            excerpt_range.start.row + 1,
+            excerpt_range.end.row + 1,
+        ))
+    }
+
     fn render_outline(
         &self,
         buffer_id: BufferId,
@@ -2793,10 +2823,11 @@ impl OutlinePanel {
             else {
                 return;
             };
-            let new_cached_entries = new_cached_entries.await;
+            let (new_cached_entries, max_width_item_index) = new_cached_entries.await;
             outline_panel
                 .update(&mut cx, |outline_panel, cx| {
                     outline_panel.cached_entries = new_cached_entries;
+                    outline_panel.max_width_item_index = max_width_item_index;
                     if outline_panel.selected_entry.is_invalidated() {
                         if let Some(new_selected_entry) =
                             outline_panel.active_editor().and_then(|active_editor| {
@@ -2819,11 +2850,10 @@ impl OutlinePanel {
         is_singleton: bool,
         query: Option<String>,
         cx: &mut ViewContext<'_, Self>,
-    ) -> Task<Vec<CachedEntry>> {
+    ) -> Task<(Vec<CachedEntry>, Option<usize>)> {
         let project = self.project.clone();
         cx.spawn(|outline_panel, mut cx| async move {
-            let mut entries = Vec::new();
-            let mut match_candidates = Vec::new();
+            let mut generation_state = GenerationState::default();
 
             let Ok(()) = outline_panel.update(&mut cx, |outline_panel, cx| {
                 let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
@@ -2943,8 +2973,7 @@ impl OutlinePanel {
                                                 folded_dirs,
                                             );
                                             outline_panel.push_entry(
-                                                &mut entries,
-                                                &mut match_candidates,
+                                                &mut generation_state,
                                                 track_matches,
                                                 new_folded_dirs,
                                                 folded_depth,
@@ -2981,8 +3010,7 @@ impl OutlinePanel {
                                     .map_or(true, |parent| parent.expanded);
                                 if !is_singleton && (parent_expanded || query.is_some()) {
                                     outline_panel.push_entry(
-                                        &mut entries,
-                                        &mut match_candidates,
+                                        &mut generation_state,
                                         track_matches,
                                         PanelEntry::FoldedDirs(worktree_id, folded_dirs),
                                         folded_depth,
@@ -3006,8 +3034,7 @@ impl OutlinePanel {
                                     .map_or(true, |parent| parent.expanded);
                                 if !is_singleton && (parent_expanded || query.is_some()) {
                                     outline_panel.push_entry(
-                                        &mut entries,
-                                        &mut match_candidates,
+                                        &mut generation_state,
                                         track_matches,
                                         PanelEntry::FoldedDirs(worktree_id, folded_dirs),
                                         folded_depth,
@@ -3042,8 +3069,7 @@ impl OutlinePanel {
                         && (should_add || (query.is_some() && folded_dirs_entry.is_none()))
                     {
                         outline_panel.push_entry(
-                            &mut entries,
-                            &mut match_candidates,
+                            &mut generation_state,
                             track_matches,
                             PanelEntry::Fs(entry.clone()),
                             depth,
@@ -3055,8 +3081,7 @@ impl OutlinePanel {
                         ItemsDisplayMode::Search(_) => {
                             if is_singleton || query.is_some() || (should_add && is_expanded) {
                                 outline_panel.add_search_entries(
-                                    &mut entries,
-                                    &mut match_candidates,
+                                    &mut generation_state,
                                     entry.clone(),
                                     depth,
                                     query.clone(),
@@ -3082,14 +3107,13 @@ impl OutlinePanel {
                                 };
                             if let Some((buffer_id, entry_excerpts)) = excerpts_to_consider {
                                 outline_panel.add_excerpt_entries(
+                                    &mut generation_state,
                                     buffer_id,
                                     entry_excerpts,
                                     depth,
                                     track_matches,
                                     is_singleton,
                                     query.as_deref(),
-                                    &mut entries,
-                                    &mut match_candidates,
                                     cx,
                                 );
                             }
@@ -3098,13 +3122,12 @@ impl OutlinePanel {
 
                     if is_singleton
                         && matches!(entry, FsEntry::File(..) | FsEntry::ExternalFile(..))
-                        && !entries.iter().any(|item| {
+                        && !generation_state.entries.iter().any(|item| {
                             matches!(item.entry, PanelEntry::Outline(..) | PanelEntry::Search(_))
                         })
                     {
                         outline_panel.push_entry(
-                            &mut entries,
-                            &mut match_candidates,
+                            &mut generation_state,
                             track_matches,
                             PanelEntry::Fs(entry.clone()),
                             0,
@@ -3121,8 +3144,7 @@ impl OutlinePanel {
                         .map_or(true, |parent| parent.expanded);
                     if parent_expanded || query.is_some() {
                         outline_panel.push_entry(
-                            &mut entries,
-                            &mut match_candidates,
+                            &mut generation_state,
                             track_matches,
                             PanelEntry::FoldedDirs(worktree_id, folded_dirs),
                             folded_depth,
@@ -3131,15 +3153,20 @@ impl OutlinePanel {
                     }
                 }
             }) else {
-                return Vec::new();
+                return (Vec::new(), None);
             };
 
             let Some(query) = query else {
-                return entries;
+                return (
+                    generation_state.entries,
+                    generation_state
+                        .max_width_estimate_and_index
+                        .map(|(_, index)| index),
+                );
             };
 
             let mut matched_ids = match_strings(
-                &match_candidates,
+                &generation_state.match_candidates,
                 &query,
                 true,
                 usize::MAX,
@@ -3152,7 +3179,7 @@ impl OutlinePanel {
             .collect::<HashMap<_, _>>();
 
             let mut id = 0;
-            entries.retain_mut(|cached_entry| {
+            generation_state.entries.retain_mut(|cached_entry| {
                 let retain = match matched_ids.remove(&id) {
                     Some(string_match) => {
                         cached_entry.string_match = Some(string_match);
@@ -3164,15 +3191,19 @@ impl OutlinePanel {
                 retain
             });
 
-            entries
+            (
+                generation_state.entries,
+                generation_state
+                    .max_width_estimate_and_index
+                    .map(|(_, index)| index),
+            )
         })
     }
 
     #[allow(clippy::too_many_arguments)]
     fn push_entry(
         &self,
-        entries: &mut Vec<CachedEntry>,
-        match_candidates: &mut Vec<StringMatchCandidate>,
+        state: &mut GenerationState,
         track_matches: bool,
         entry: PanelEntry,
         depth: usize,
@@ -3192,13 +3223,13 @@ impl OutlinePanel {
         };
 
         if track_matches {
-            let id = entries.len();
+            let id = state.entries.len();
             match &entry {
                 PanelEntry::Fs(fs_entry) => {
                     if let Some(file_name) =
                         self.relative_path(fs_entry, cx).as_deref().map(file_name)
                     {
-                        match_candidates.push(StringMatchCandidate {
+                        state.match_candidates.push(StringMatchCandidate {
                             id,
                             string: file_name.to_string(),
                             char_bag: file_name.chars().collect(),
@@ -3208,7 +3239,7 @@ impl OutlinePanel {
                 PanelEntry::FoldedDirs(worktree_id, entries) => {
                     let dir_names = self.dir_names_string(entries, *worktree_id, cx);
                     {
-                        match_candidates.push(StringMatchCandidate {
+                        state.match_candidates.push(StringMatchCandidate {
                             id,
                             string: dir_names.clone(),
                             char_bag: dir_names.chars().collect(),
@@ -3217,7 +3248,7 @@ impl OutlinePanel {
                 }
                 PanelEntry::Outline(outline_entry) => match outline_entry {
                     OutlineEntry::Outline(_, _, outline) => {
-                        match_candidates.push(StringMatchCandidate {
+                        state.match_candidates.push(StringMatchCandidate {
                             id,
                             string: outline.text.clone(),
                             char_bag: outline.text.chars().collect(),
@@ -3226,7 +3257,7 @@ impl OutlinePanel {
                     OutlineEntry::Excerpt(..) => {}
                 },
                 PanelEntry::Search(new_search_entry) => {
-                    match_candidates.push(StringMatchCandidate {
+                    state.match_candidates.push(StringMatchCandidate {
                         id,
                         char_bag: new_search_entry.render_data.context_text.chars().collect(),
                         string: new_search_entry.render_data.context_text.clone(),
@@ -3234,7 +3265,16 @@ impl OutlinePanel {
                 }
             }
         }
-        entries.push(CachedEntry {
+
+        let width_estimate = self.width_estimate(depth, &entry, cx);
+        if Some(width_estimate)
+            > state
+                .max_width_estimate_and_index
+                .map(|(estimate, _)| estimate)
+        {
+            state.max_width_estimate_and_index = Some((width_estimate, state.entries.len()));
+        }
+        state.entries.push(CachedEntry {
             depth,
             entry,
             string_match: None,
@@ -3369,14 +3409,13 @@ impl OutlinePanel {
     #[allow(clippy::too_many_arguments)]
     fn add_excerpt_entries(
         &self,
+        state: &mut GenerationState,
         buffer_id: BufferId,
         entries_to_add: &[ExcerptId],
         parent_depth: usize,
         track_matches: bool,
         is_singleton: bool,
         query: Option<&str>,
-        entries: &mut Vec<CachedEntry>,
-        match_candidates: &mut Vec<StringMatchCandidate>,
         cx: &mut ViewContext<Self>,
     ) {
         if let Some(excerpts) = self.excerpts.get(&buffer_id) {
@@ -3386,8 +3425,7 @@ impl OutlinePanel {
                 };
                 let excerpt_depth = parent_depth + 1;
                 self.push_entry(
-                    entries,
-                    match_candidates,
+                    state,
                     track_matches,
                     PanelEntry::Outline(OutlineEntry::Excerpt(
                         buffer_id,
@@ -3401,8 +3439,7 @@ impl OutlinePanel {
                 let mut outline_base_depth = excerpt_depth + 1;
                 if is_singleton {
                     outline_base_depth = 0;
-                    entries.clear();
-                    match_candidates.clear();
+                    state.clear();
                 } else if query.is_none()
                     && self
                         .collapsed_entries
@@ -3413,8 +3450,7 @@ impl OutlinePanel {
 
                 for outline in excerpt.iter_outlines() {
                     self.push_entry(
-                        entries,
-                        match_candidates,
+                        state,
                         track_matches,
                         PanelEntry::Outline(OutlineEntry::Outline(
                             buffer_id,
@@ -3432,8 +3468,7 @@ impl OutlinePanel {
     #[allow(clippy::too_many_arguments)]
     fn add_search_entries(
         &mut self,
-        entries: &mut Vec<CachedEntry>,
-        match_candidates: &mut Vec<StringMatchCandidate>,
+        state: &mut GenerationState,
         parent_entry: FsEntry,
         parent_depth: usize,
         filter_query: Option<String>,
@@ -3464,7 +3499,8 @@ impl OutlinePanel {
                 || related_excerpts.contains(&match_range.end.excerpt_id)
         });
 
-        let previous_search_matches = entries
+        let previous_search_matches = state
+            .entries
             .iter()
             .skip_while(|entry| {
                 if let PanelEntry::Fs(entry) = &entry.entry {
@@ -3519,8 +3555,7 @@ impl OutlinePanel {
             .collect::<Vec<_>>();
         for new_search_entry in new_search_entries {
             self.push_entry(
-                entries,
-                match_candidates,
+                state,
                 filter_query.is_some(),
                 PanelEntry::Search(new_search_entry),
                 depth,
@@ -3589,6 +3624,430 @@ impl OutlinePanel {
         self.autoscroll(cx);
         cx.notify();
     }
+
+    fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
+        if !Self::should_show_scrollbar(cx)
+            || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
+        {
+            return None;
+        }
+        Some(
+            div()
+                .occlude()
+                .id("project-panel-vertical-scroll")
+                .on_mouse_move(cx.listener(|_, _, cx| {
+                    cx.notify();
+                    cx.stop_propagation()
+                }))
+                .on_hover(|_, cx| {
+                    cx.stop_propagation();
+                })
+                .on_any_mouse_down(|_, cx| {
+                    cx.stop_propagation();
+                })
+                .on_mouse_up(
+                    MouseButton::Left,
+                    cx.listener(|outline_panel, _, cx| {
+                        if !outline_panel.vertical_scrollbar_state.is_dragging()
+                            && !outline_panel.focus_handle.contains_focused(cx)
+                        {
+                            outline_panel.hide_scrollbar(cx);
+                            cx.notify();
+                        }
+
+                        cx.stop_propagation();
+                    }),
+                )
+                .on_scroll_wheel(cx.listener(|_, _, cx| {
+                    cx.notify();
+                }))
+                .h_full()
+                .absolute()
+                .right_1()
+                .top_1()
+                .bottom_0()
+                .w(px(12.))
+                .cursor_default()
+                .children(Scrollbar::vertical(self.vertical_scrollbar_state.clone())),
+        )
+    }
+
+    fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
+        if !Self::should_show_scrollbar(cx)
+            || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
+        {
+            return None;
+        }
+
+        let scroll_handle = self.scroll_handle.0.borrow();
+        let longest_item_width = scroll_handle
+            .last_item_size
+            .filter(|size| size.contents.width > size.item.width)?
+            .contents
+            .width
+            .0 as f64;
+        if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
+            return None;
+        }
+
+        Some(
+            div()
+                .occlude()
+                .id("project-panel-horizontal-scroll")
+                .on_mouse_move(cx.listener(|_, _, cx| {
+                    cx.notify();
+                    cx.stop_propagation()
+                }))
+                .on_hover(|_, cx| {
+                    cx.stop_propagation();
+                })
+                .on_any_mouse_down(|_, cx| {
+                    cx.stop_propagation();
+                })
+                .on_mouse_up(
+                    MouseButton::Left,
+                    cx.listener(|outline_panel, _, cx| {
+                        if !outline_panel.horizontal_scrollbar_state.is_dragging()
+                            && !outline_panel.focus_handle.contains_focused(cx)
+                        {
+                            outline_panel.hide_scrollbar(cx);
+                            cx.notify();
+                        }
+
+                        cx.stop_propagation();
+                    }),
+                )
+                .on_scroll_wheel(cx.listener(|_, _, cx| {
+                    cx.notify();
+                }))
+                .w_full()
+                .absolute()
+                .right_1()
+                .left_1()
+                .bottom_0()
+                .h(px(12.))
+                .cursor_default()
+                .when(self.width.is_some(), |this| {
+                    this.children(Scrollbar::horizontal(
+                        self.horizontal_scrollbar_state.clone(),
+                    ))
+                }),
+        )
+    }
+
+    fn should_show_scrollbar(cx: &AppContext) -> bool {
+        let show = OutlinePanelSettings::get_global(cx)
+            .scrollbar
+            .show
+            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
+        match show {
+            ShowScrollbar::Auto => true,
+            ShowScrollbar::System => true,
+            ShowScrollbar::Always => true,
+            ShowScrollbar::Never => false,
+        }
+    }
+
+    fn should_autohide_scrollbar(cx: &AppContext) -> bool {
+        let show = OutlinePanelSettings::get_global(cx)
+            .scrollbar
+            .show
+            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
+        match show {
+            ShowScrollbar::Auto => true,
+            ShowScrollbar::System => cx
+                .try_global::<ScrollbarAutoHide>()
+                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
+            ShowScrollbar::Always => false,
+            ShowScrollbar::Never => true,
+        }
+    }
+
+    fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
+        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
+        if !Self::should_autohide_scrollbar(cx) {
+            return;
+        }
+        self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
+            cx.background_executor()
+                .timer(SCROLLBAR_SHOW_INTERVAL)
+                .await;
+            panel
+                .update(&mut cx, |panel, cx| {
+                    panel.show_scrollbar = false;
+                    cx.notify();
+                })
+                .log_err();
+        }))
+    }
+
+    fn width_estimate(&self, depth: usize, entry: &PanelEntry, cx: &AppContext) -> u64 {
+        let item_text_chars = match entry {
+            PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => self
+                .buffer_snapshot_for_id(*buffer_id, cx)
+                .and_then(|snapshot| {
+                    Some(snapshot.file()?.path().file_name()?.to_string_lossy().len())
+                })
+                .unwrap_or_default(),
+            PanelEntry::Fs(FsEntry::Directory(_, directory)) => directory
+                .path
+                .file_name()
+                .map(|name| name.to_string_lossy().len())
+                .unwrap_or_default(),
+            PanelEntry::Fs(FsEntry::File(_, file, _, _)) => file
+                .path
+                .file_name()
+                .map(|name| name.to_string_lossy().len())
+                .unwrap_or_default(),
+            PanelEntry::FoldedDirs(_, dirs) => {
+                dirs.iter()
+                    .map(|dir| {
+                        dir.path
+                            .file_name()
+                            .map(|name| name.to_string_lossy().len())
+                            .unwrap_or_default()
+                    })
+                    .sum::<usize>()
+                    + dirs.len().saturating_sub(1) * MAIN_SEPARATOR_STR.len()
+            }
+            PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, _, range)) => self
+                .excerpt_label(*buffer_id, range, cx)
+                .map(|label| label.len())
+                .unwrap_or_default(),
+            PanelEntry::Outline(OutlineEntry::Outline(_, _, outline)) => outline.text.len(),
+            PanelEntry::Search(search) => search.render_data.context_text.len(),
+        };
+
+        (item_text_chars + depth) as u64
+    }
+
+    fn render_main_contents(
+        &mut self,
+        query: Option<String>,
+        show_indent_guides: bool,
+        indent_size: f32,
+        cx: &mut ViewContext<'_, Self>,
+    ) -> Div {
+        let contents = if self.cached_entries.is_empty() {
+            let header = if self.updating_fs_entries {
+                "Loading outlines"
+            } else if query.is_some() {
+                "No matches for query"
+            } else {
+                "No outlines available"
+            };
+
+            v_flex()
+                .flex_1()
+                .justify_center()
+                .size_full()
+                .child(h_flex().justify_center().child(Label::new(header)))
+                .when_some(query.clone(), |panel, query| {
+                    panel.child(h_flex().justify_center().child(Label::new(query)))
+                })
+                .child(
+                    h_flex()
+                        .pt(Spacing::Small.rems(cx))
+                        .justify_center()
+                        .child({
+                            let keystroke = match self.position(cx) {
+                                DockPosition::Left => {
+                                    cx.keystroke_text_for(&workspace::ToggleLeftDock)
+                                }
+                                DockPosition::Bottom => {
+                                    cx.keystroke_text_for(&workspace::ToggleBottomDock)
+                                }
+                                DockPosition::Right => {
+                                    cx.keystroke_text_for(&workspace::ToggleRightDock)
+                                }
+                            };
+                            Label::new(format!("Toggle this panel with {keystroke}"))
+                        }),
+                )
+        } else {
+            let list_contents = {
+                let items_len = self.cached_entries.len();
+                let multi_buffer_snapshot = self
+                    .active_editor()
+                    .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx));
+                uniform_list(cx.view().clone(), "entries", items_len, {
+                    move |outline_panel, range, cx| {
+                        let entries = outline_panel.cached_entries.get(range);
+                        entries
+                            .map(|entries| entries.to_vec())
+                            .unwrap_or_default()
+                            .into_iter()
+                            .filter_map(|cached_entry| match cached_entry.entry {
+                                PanelEntry::Fs(entry) => Some(outline_panel.render_entry(
+                                    &entry,
+                                    cached_entry.depth,
+                                    cached_entry.string_match.as_ref(),
+                                    cx,
+                                )),
+                                PanelEntry::FoldedDirs(worktree_id, entries) => {
+                                    Some(outline_panel.render_folded_dirs(
+                                        worktree_id,
+                                        &entries,
+                                        cached_entry.depth,
+                                        cached_entry.string_match.as_ref(),
+                                        cx,
+                                    ))
+                                }
+                                PanelEntry::Outline(OutlineEntry::Excerpt(
+                                    buffer_id,
+                                    excerpt_id,
+                                    excerpt,
+                                )) => outline_panel.render_excerpt(
+                                    buffer_id,
+                                    excerpt_id,
+                                    &excerpt,
+                                    cached_entry.depth,
+                                    cx,
+                                ),
+                                PanelEntry::Outline(OutlineEntry::Outline(
+                                    buffer_id,
+                                    excerpt_id,
+                                    outline,
+                                )) => Some(outline_panel.render_outline(
+                                    buffer_id,
+                                    excerpt_id,
+                                    &outline,
+                                    cached_entry.depth,
+                                    cached_entry.string_match.as_ref(),
+                                    cx,
+                                )),
+                                PanelEntry::Search(SearchEntry {
+                                    match_range,
+                                    render_data,
+                                    kind,
+                                    ..
+                                }) => Some(outline_panel.render_search_match(
+                                    multi_buffer_snapshot.as_ref(),
+                                    &match_range,
+                                    &render_data,
+                                    kind,
+                                    cached_entry.depth,
+                                    cached_entry.string_match.as_ref(),
+                                    cx,
+                                )),
+                            })
+                            .collect()
+                    }
+                })
+                .with_sizing_behavior(ListSizingBehavior::Infer)
+                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
+                .with_width_from_item(self.max_width_item_index)
+                .track_scroll(self.scroll_handle.clone())
+                .when(show_indent_guides, |list| {
+                    list.with_decoration(
+                        ui::indent_guides(
+                            cx.view().clone(),
+                            px(indent_size),
+                            IndentGuideColors::panel(cx),
+                            |outline_panel, range, _| {
+                                let entries = outline_panel.cached_entries.get(range);
+                                if let Some(entries) = entries {
+                                    entries.into_iter().map(|item| item.depth).collect()
+                                } else {
+                                    smallvec::SmallVec::new()
+                                }
+                            },
+                        )
+                        .with_render_fn(
+                            cx.view().clone(),
+                            move |outline_panel, params, _| {
+                                const LEFT_OFFSET: f32 = 14.;
+
+                                let indent_size = params.indent_size;
+                                let item_height = params.item_height;
+                                let active_indent_guide_ix = find_active_indent_guide_ix(
+                                    outline_panel,
+                                    &params.indent_guides,
+                                );
+
+                                params
+                                    .indent_guides
+                                    .into_iter()
+                                    .enumerate()
+                                    .map(|(ix, layout)| {
+                                        let bounds = Bounds::new(
+                                            point(
+                                                px(layout.offset.x as f32) * indent_size
+                                                    + px(LEFT_OFFSET),
+                                                px(layout.offset.y as f32) * item_height,
+                                            ),
+                                            size(px(1.), px(layout.length as f32) * item_height),
+                                        );
+                                        ui::RenderedIndentGuide {
+                                            bounds,
+                                            layout,
+                                            is_active: active_indent_guide_ix == Some(ix),
+                                            hitbox: None,
+                                        }
+                                    })
+                                    .collect()
+                            },
+                        ),
+                    )
+                })
+            };
+
+            v_flex()
+                .flex_shrink()
+                .size_full()
+                .child(list_contents.size_full().flex_shrink())
+                .children(self.render_vertical_scrollbar(cx))
+                .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
+                    this.pb_4().child(scrollbar)
+                })
+        }
+        .children(self.context_menu.as_ref().map(|(menu, position, _)| {
+            deferred(
+                anchored()
+                    .position(*position)
+                    .anchor(gpui::AnchorCorner::TopLeft)
+                    .child(menu.clone()),
+            )
+            .with_priority(1)
+        }));
+
+        v_flex().w_full().flex_1().overflow_hidden().child(contents)
+    }
+
+    fn render_filter_footer(&mut self, pinned: bool, cx: &mut ViewContext<'_, Self>) -> Div {
+        v_flex().flex_none().child(horizontal_separator(cx)).child(
+            h_flex()
+                .p_2()
+                .w_full()
+                .child(self.filter_editor.clone())
+                .child(
+                    div().child(
+                        IconButton::new(
+                            "outline-panel-menu",
+                            if pinned {
+                                IconName::Unpin
+                            } else {
+                                IconName::Pin
+                            },
+                        )
+                        .tooltip(move |cx| {
+                            Tooltip::text(
+                                if pinned {
+                                    "Unpin Outline"
+                                } else {
+                                    "Pin Active Outline"
+                                },
+                                cx,
+                            )
+                        })
+                        .shape(IconButtonShape::Square)
+                        .on_click(cx.listener(|outline_panel, _, cx| {
+                            outline_panel.toggle_active_editor_pin(&ToggleActiveEditorPin, cx);
+                        })),
+                    ),
+                ),
+        )
+    }
 }
 
 fn workspace_active_editor(
@@ -3741,17 +4200,34 @@ 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);
+        let (is_local, is_via_ssh) = self
+            .project
+            .read_with(cx, |project, _| (project.is_local(), project.is_via_ssh()));
         let query = self.query(cx);
         let pinned = self.pinned;
         let settings = OutlinePanelSettings::get_global(cx);
         let indent_size = settings.indent_size;
         let show_indent_guides = settings.indent_guides.show == ShowIndentGuides::Always;
 
-        let outline_panel = v_flex()
+        let search_query = match &self.mode {
+            ItemsDisplayMode::Search(search_query) => Some(search_query),
+            _ => None,
+        };
+
+        v_flex()
             .id("outline-panel")
             .size_full()
+            .overflow_hidden()
             .relative()
+            .on_hover(cx.listener(|this, hovered, cx| {
+                if *hovered {
+                    this.show_scrollbar = true;
+                    this.hide_scrollbar_task.take();
+                    cx.notify();
+                } else if !this.focus_handle.contains_focused(cx) {
+                    this.hide_scrollbar(cx);
+                }
+            }))
             .key_context(self.dispatch_context(cx))
             .on_action(cx.listener(Self::open))
             .on_action(cx.listener(Self::cancel))
@@ -3769,10 +4245,10 @@ impl Render for OutlinePanel {
             .on_action(cx.listener(Self::toggle_active_editor_pin))
             .on_action(cx.listener(Self::unfold_directory))
             .on_action(cx.listener(Self::fold_directory))
-            .when(project.is_local(), |el| {
+            .when(is_local, |el| {
                 el.on_action(cx.listener(Self::reveal_in_finder))
             })
-            .when(project.is_local() || project.is_via_ssh(), |el| {
+            .when(is_local || is_via_ssh, |el| {
                 el.on_action(cx.listener(Self::open_in_terminal))
             })
             .on_mouse_down(
@@ -3785,229 +4261,20 @@ impl Render for OutlinePanel {
                     }
                 }),
             )
-            .track_focus(&self.focus_handle(cx));
-
-        if self.cached_entries.is_empty() {
-            let header = if self.updating_fs_entries {
-                "Loading outlines"
-            } else if query.is_some() {
-                "No matches for query"
-            } else {
-                "No outlines available"
-            };
-
-            outline_panel.child(
-                v_flex()
-                    .justify_center()
-                    .size_full()
-                    .child(h_flex().justify_center().child(Label::new(header)))
-                    .when_some(query.clone(), |panel, query| {
-                        panel.child(h_flex().justify_center().child(Label::new(query)))
-                    })
-                    .child(
-                        h_flex()
-                            .pt(Spacing::Small.rems(cx))
-                            .justify_center()
-                            .child({
-                                let keystroke = match self.position(cx) {
-                                    DockPosition::Left => {
-                                        cx.keystroke_text_for(&workspace::ToggleLeftDock)
-                                    }
-                                    DockPosition::Bottom => {
-                                        cx.keystroke_text_for(&workspace::ToggleBottomDock)
-                                    }
-                                    DockPosition::Right => {
-                                        cx.keystroke_text_for(&workspace::ToggleRightDock)
-                                    }
-                                };
-                                Label::new(format!("Toggle this panel with {keystroke}"))
-                            }),
-                    ),
-            )
-        } else {
-            let search_query = match &self.mode {
-                ItemsDisplayMode::Search(search_query) => Some(search_query),
-                _ => None,
-            };
-            outline_panel
-                .when_some(search_query, |outline_panel, search_state| {
-                    outline_panel.child(
-                        div()
-                            .mx_2()
-                            .child(
-                                Label::new(format!("Searching: '{}'", search_state.query))
-                                    .color(Color::Muted),
-                            )
-                            .child(horizontal_separator(cx)),
-                    )
-                })
-                .child({
-                    let items_len = self.cached_entries.len();
-                    let multi_buffer_snapshot = self
-                        .active_editor()
-                        .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx));
-                    uniform_list(cx.view().clone(), "entries", items_len, {
-                        move |outline_panel, range, cx| {
-                            let entries = outline_panel.cached_entries.get(range);
-                            entries
-                                .map(|entries| entries.to_vec())
-                                .unwrap_or_default()
-                                .into_iter()
-                                .filter_map(|cached_entry| match cached_entry.entry {
-                                    PanelEntry::Fs(entry) => Some(outline_panel.render_entry(
-                                        &entry,
-                                        cached_entry.depth,
-                                        cached_entry.string_match.as_ref(),
-                                        cx,
-                                    )),
-                                    PanelEntry::FoldedDirs(worktree_id, entries) => {
-                                        Some(outline_panel.render_folded_dirs(
-                                            worktree_id,
-                                            &entries,
-                                            cached_entry.depth,
-                                            cached_entry.string_match.as_ref(),
-                                            cx,
-                                        ))
-                                    }
-                                    PanelEntry::Outline(OutlineEntry::Excerpt(
-                                        buffer_id,
-                                        excerpt_id,
-                                        excerpt,
-                                    )) => outline_panel.render_excerpt(
-                                        buffer_id,
-                                        excerpt_id,
-                                        &excerpt,
-                                        cached_entry.depth,
-                                        cx,
-                                    ),
-                                    PanelEntry::Outline(OutlineEntry::Outline(
-                                        buffer_id,
-                                        excerpt_id,
-                                        outline,
-                                    )) => Some(outline_panel.render_outline(
-                                        buffer_id,
-                                        excerpt_id,
-                                        &outline,
-                                        cached_entry.depth,
-                                        cached_entry.string_match.as_ref(),
-                                        cx,
-                                    )),
-                                    PanelEntry::Search(SearchEntry {
-                                        match_range,
-                                        render_data,
-                                        kind,
-                                        ..
-                                    }) => Some(outline_panel.render_search_match(
-                                        multi_buffer_snapshot.as_ref(),
-                                        &match_range,
-                                        &render_data,
-                                        kind,
-                                        cached_entry.depth,
-                                        cached_entry.string_match.as_ref(),
-                                        cx,
-                                    )),
-                                })
-                                .collect()
-                        }
-                    })
-                    .size_full()
-                    .track_scroll(self.scroll_handle.clone())
-                    .when(show_indent_guides, |list| {
-                        list.with_decoration(
-                            ui::indent_guides(
-                                cx.view().clone(),
-                                px(indent_size),
-                                IndentGuideColors::panel(cx),
-                                |outline_panel, range, _| {
-                                    let entries = outline_panel.cached_entries.get(range);
-                                    if let Some(entries) = entries {
-                                        entries.into_iter().map(|item| item.depth).collect()
-                                    } else {
-                                        smallvec::SmallVec::new()
-                                    }
-                                },
-                            )
-                            .with_render_fn(
-                                cx.view().clone(),
-                                move |outline_panel, params, _| {
-                                    const LEFT_OFFSET: f32 = 14.;
-
-                                    let indent_size = params.indent_size;
-                                    let item_height = params.item_height;
-                                    let active_indent_guide_ix = find_active_indent_guide_ix(
-                                        outline_panel,
-                                        &params.indent_guides,
-                                    );
-
-                                    params
-                                        .indent_guides
-                                        .into_iter()
-                                        .enumerate()
-                                        .map(|(ix, layout)| {
-                                            let bounds = Bounds::new(
-                                                point(
-                                                    px(layout.offset.x as f32) * indent_size
-                                                        + px(LEFT_OFFSET),
-                                                    px(layout.offset.y as f32) * item_height,
-                                                ),
-                                                size(
-                                                    px(1.),
-                                                    px(layout.length as f32) * item_height,
-                                                ),
-                                            );
-                                            ui::RenderedIndentGuide {
-                                                bounds,
-                                                layout,
-                                                is_active: active_indent_guide_ix == Some(ix),
-                                                hitbox: None,
-                                            }
-                                        })
-                                        .collect()
-                                },
-                            ),
-                        )
-                    })
-                })
-        }
-        .children(self.context_menu.as_ref().map(|(menu, position, _)| {
-            deferred(
-                anchored()
-                    .position(*position)
-                    .anchor(gpui::AnchorCorner::TopLeft)
-                    .child(menu.clone()),
-            )
-            .with_priority(1)
-        }))
-        .child(
-            v_flex().child(horizontal_separator(cx)).child(
-                h_flex().p_2().child(self.filter_editor.clone()).child(
-                    div().child(
-                        IconButton::new(
-                            "outline-panel-menu",
-                            if pinned {
-                                IconName::Unpin
-                            } else {
-                                IconName::Pin
-                            },
+            .track_focus(&self.focus_handle(cx))
+            .when_some(search_query, |outline_panel, search_state| {
+                outline_panel.child(
+                    v_flex()
+                        .child(
+                            Label::new(format!("Searching: '{}'", search_state.query))
+                                .color(Color::Muted)
+                                .mx_2(),
                         )
-                        .tooltip(move |cx| {
-                            Tooltip::text(
-                                if pinned {
-                                    "Unpin Outline"
-                                } else {
-                                    "Pin Active Outline"
-                                },
-                                cx,
-                            )
-                        })
-                        .shape(IconButtonShape::Square)
-                        .on_click(cx.listener(|outline_panel, _, cx| {
-                            outline_panel.toggle_active_editor_pin(&ToggleActiveEditorPin, cx);
-                        })),
-                    ),
-                ),
-            ),
-        )
+                        .child(horizontal_separator(cx)),
+                )
+            })
+            .child(self.render_main_contents(query, show_indent_guides, indent_size, cx))
+            .child(self.render_filter_footer(pinned, cx))
     }
 }
 

crates/outline_panel/src/outline_panel_settings.rs 🔗

@@ -1,3 +1,4 @@
+use editor::ShowScrollbar;
 use gpui::Pixels;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -29,6 +30,23 @@ pub struct OutlinePanelSettings {
     pub indent_guides: IndentGuidesSettings,
     pub auto_reveal_entries: bool,
     pub auto_fold_dirs: bool,
+    pub scrollbar: ScrollbarSettings,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+pub struct ScrollbarSettings {
+    /// When to show the scrollbar in the project panel.
+    ///
+    /// Default: inherits editor scrollbar settings
+    pub show: Option<ShowScrollbar>,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+pub struct ScrollbarSettingsContent {
+    /// When to show the scrollbar in the project panel.
+    ///
+    /// Default: inherits editor scrollbar settings
+    pub show: Option<Option<ShowScrollbar>>,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -85,6 +103,8 @@ pub struct OutlinePanelSettingsContent {
     pub auto_fold_dirs: Option<bool>,
     /// Settings related to indent guides in the outline panel.
     pub indent_guides: Option<IndentGuidesSettingsContent>,
+    /// Scrollbar-related settings
+    pub scrollbar: Option<ScrollbarSettingsContent>,
 }
 
 impl Settings for OutlinePanelSettings {

docs/src/configuring-zed.md 🔗

@@ -2271,6 +2271,9 @@ Run the `theme selector: toggle` action in the command palette to see a current
   "auto_fold_dirs": true,
   "indent_guides": {
     "show": "always"
+  },
+  "scrollbar": {
+    "show": null
   }
 }
 ```