Project panel horizontal scrollbar (#18513)

Kirill Bulatov and Piotr Osiewicz created

<img width="389" alt="image"
src="https://github.com/user-attachments/assets/c6718c6e-0fe1-40ed-b3db-7d576c4d98c8">


https://github.com/user-attachments/assets/734f1f52-70d9-4308-b1fc-36c7cfd4dd76

Closes https://github.com/zed-industries/zed/issues/7001
Closes https://github.com/zed-industries/zed/issues/4427
Part of https://github.com/zed-industries/zed/issues/15324
Part of https://github.com/zed-industries/zed/issues/14551

* Adjusts a `UniformList` to have a horizontal sizing behavior: the old
mode forced all items to have the size of the list exactly.
A new mode (with corresponding `ListItems` having `overflow_x` enabled)
lays out the uniform list elements with width of its widest element,
setting the same width to the list itself too.

* Using the new behavior, adds a new scrollbar into the project panel
and enhances its file name editor to scroll it during editing of long
file names

* Also restyles the scrollbar a bit, making it narrower and removing its
background

* Changes the project_panel.scrollbar.show settings to accept `null` and
be `null` by default, to inherit `editor`'s scrollbar settings. All
editor scrollbar settings are supported now.

Release Notes:

- Added a horizontal scrollbar to project panel
([#7001](https://github.com/zed-industries/zed/issues/7001))
([#4427](https://github.com/zed-industries/zed/issues/4427))

---------

Co-authored-by: Piotr Osiewicz <piotr@zed.dev>

Change summary

assets/settings/default.json                       |  14 
crates/editor/src/editor.rs                        |   2 
crates/gpui/src/elements/div.rs                    |  19 
crates/gpui/src/elements/list.rs                   |  10 
crates/gpui/src/elements/uniform_list.rs           |  96 +++
crates/gpui/src/style.rs                           |   3 
crates/language/src/language_settings.rs           |   2 
crates/project_panel/src/project_panel.rs          | 335 +++++++++++++--
crates/project_panel/src/project_panel_settings.rs |  22 
crates/project_panel/src/scrollbar.rs              | 192 +++++++--
crates/ui/src/components/list/list_item.rs         |  15 
docs/src/configuring-zed.md                        |   6 
12 files changed, 567 insertions(+), 149 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -356,9 +356,19 @@
     /// Scrollbar-related settings
     "scrollbar": {
       /// When to show the scrollbar in the project panel.
+      /// This setting can take four values:
       ///
-      /// Default: always
-      "show": "always"
+      /// 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
     }
   },
   "outline_panel": {

crates/editor/src/editor.rs 🔗

@@ -61,7 +61,7 @@ use debounced_delay::DebouncedDelay;
 use display_map::*;
 pub use display_map::{DisplayPoint, FoldPlaceholder};
 pub use editor_settings::{
-    CurrentLineHighlight, EditorSettings, ScrollBeyondLastLine, SearchSettings,
+    CurrentLineHighlight, EditorSettings, ScrollBeyondLastLine, SearchSettings, ShowScrollbar,
 };
 pub use editor_settings_controls::*;
 use element::LineWithInvisibles;

crates/gpui/src/elements/div.rs 🔗

@@ -2057,6 +2057,7 @@ impl Interactivity {
     fn paint_scroll_listener(&self, hitbox: &Hitbox, style: &Style, cx: &mut WindowContext) {
         if let Some(scroll_offset) = self.scroll_offset.clone() {
             let overflow = style.overflow;
+            let allow_concurrent_scroll = style.allow_concurrent_scroll;
             let line_height = cx.line_height();
             let hitbox = hitbox.clone();
             cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| {
@@ -2065,27 +2066,31 @@ impl Interactivity {
                     let old_scroll_offset = *scroll_offset;
                     let delta = event.delta.pixel_delta(line_height);
 
+                    let mut delta_x = Pixels::ZERO;
                     if overflow.x == Overflow::Scroll {
-                        let mut delta_x = Pixels::ZERO;
                         if !delta.x.is_zero() {
                             delta_x = delta.x;
                         } else if overflow.y != Overflow::Scroll {
                             delta_x = delta.y;
                         }
-
-                        scroll_offset.x += delta_x;
                     }
-
+                    let mut delta_y = Pixels::ZERO;
                     if overflow.y == Overflow::Scroll {
-                        let mut delta_y = Pixels::ZERO;
                         if !delta.y.is_zero() {
                             delta_y = delta.y;
                         } else if overflow.x != Overflow::Scroll {
                             delta_y = delta.x;
                         }
-
-                        scroll_offset.y += delta_y;
                     }
+                    if !allow_concurrent_scroll && !delta_x.is_zero() && !delta_y.is_zero() {
+                        if delta_x.abs() > delta_y.abs() {
+                            delta_y = Pixels::ZERO;
+                        } else {
+                            delta_x = Pixels::ZERO;
+                        }
+                    }
+                    scroll_offset.y += delta_y;
+                    scroll_offset.x += delta_x;
 
                     cx.stop_propagation();
                     if *scroll_offset != old_scroll_offset {

crates/gpui/src/elements/list.rs 🔗

@@ -89,6 +89,16 @@ pub enum ListSizingBehavior {
     Auto,
 }
 
+/// The horizontal sizing behavior to apply during layout.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum ListHorizontalSizingBehavior {
+    /// List items' width can never exceed the width of the list.
+    #[default]
+    FitList,
+    /// List items' width may go over the width of the list, if any item is wider.
+    Unconstrained,
+}
+
 struct LayoutItemsResponse {
     max_item_width: Pixels,
     scroll_top: ListOffset,

crates/gpui/src/elements/uniform_list.rs 🔗

@@ -5,8 +5,8 @@
 //! elements with uniform height.
 
 use crate::{
-    point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementId,
-    GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, LayoutId,
+    point, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementId,
+    GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId,
     ListSizingBehavior, Pixels, Render, ScrollHandle, Size, StyleRefinement, Styled, View,
     ViewContext, WindowContext,
 };
@@ -14,6 +14,8 @@ use smallvec::SmallVec;
 use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
 use taffy::style::Overflow;
 
+use super::ListHorizontalSizingBehavior;
+
 /// uniform_list provides lazy rendering for a set of items that are of uniform height.
 /// When rendered into a container with overflow-y: hidden and a fixed (or max) height,
 /// uniform_list will only render the visible subset of items.
@@ -57,6 +59,7 @@ where
         },
         scroll_handle: None,
         sizing_behavior: ListSizingBehavior::default(),
+        horizontal_sizing_behavior: ListHorizontalSizingBehavior::default(),
     }
 }
 
@@ -69,11 +72,11 @@ pub struct UniformList {
     interactivity: Interactivity,
     scroll_handle: Option<UniformListScrollHandle>,
     sizing_behavior: ListSizingBehavior,
+    horizontal_sizing_behavior: ListHorizontalSizingBehavior,
 }
 
 /// Frame state used by the [UniformList].
 pub struct UniformListFrameState {
-    item_size: Size<Pixels>,
     items: SmallVec<[AnyElement; 32]>,
 }
 
@@ -87,7 +90,18 @@ pub struct UniformListScrollHandle(pub Rc<RefCell<UniformListScrollState>>);
 pub struct UniformListScrollState {
     pub base_handle: ScrollHandle,
     pub deferred_scroll_to_item: Option<usize>,
-    pub last_item_height: Option<Pixels>,
+    /// Size of the item, captured during last layout.
+    pub last_item_size: Option<ItemSize>,
+}
+
+#[derive(Copy, Clone, Debug, Default)]
+/// The size of the item and its contents.
+pub struct ItemSize {
+    /// The size of the item.
+    pub item: Size<Pixels>,
+    /// The size of the item's contents, which may be larger than the item itself,
+    /// if the item was bounded by a parent element.
+    pub contents: Size<Pixels>,
 }
 
 impl UniformListScrollHandle {
@@ -96,7 +110,7 @@ impl UniformListScrollHandle {
         Self(Rc::new(RefCell::new(UniformListScrollState {
             base_handle: ScrollHandle::new(),
             deferred_scroll_to_item: None,
-            last_item_height: None,
+            last_item_size: None,
         })))
     }
 
@@ -170,7 +184,6 @@ impl Element for UniformList {
         (
             layout_id,
             UniformListFrameState {
-                item_size,
                 items: SmallVec::new(),
             },
         )
@@ -193,17 +206,30 @@ impl Element for UniformList {
                 - point(border.right + padding.right, border.bottom + padding.bottom),
         );
 
+        let can_scroll_horizontally = matches!(
+            self.horizontal_sizing_behavior,
+            ListHorizontalSizingBehavior::Unconstrained
+        );
+
+        let longest_item_size = self.measure_item(None, cx);
+        let content_width = if can_scroll_horizontally {
+            padded_bounds.size.width.max(longest_item_size.width)
+        } else {
+            padded_bounds.size.width
+        };
         let content_size = Size {
-            width: padded_bounds.size.width,
-            height: frame_state.item_size.height * self.item_count + padding.top + padding.bottom,
+            width: content_width,
+            height: longest_item_size.height * self.item_count + padding.top + padding.bottom,
         };
 
         let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
-
-        let item_height = self.measure_item(Some(padded_bounds.size.width), cx).height;
+        let item_height = longest_item_size.height;
         let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| {
             let mut handle = handle.0.borrow_mut();
-            handle.last_item_height = Some(item_height);
+            handle.last_item_size = Some(ItemSize {
+                item: padded_bounds.size,
+                contents: content_size,
+            });
             handle.deferred_scroll_to_item.take()
         });
 
@@ -228,12 +254,19 @@ impl Element for UniformList {
                 if self.item_count > 0 {
                     let content_height =
                         item_height * self.item_count + padding.top + padding.bottom;
-                    let min_scroll_offset = padded_bounds.size.height - content_height;
-                    let is_scrolled = scroll_offset.y != px(0.);
+                    let is_scrolled_vertically = !scroll_offset.y.is_zero();
+                    let min_vertical_scroll_offset = padded_bounds.size.height - content_height;
+                    if is_scrolled_vertically && scroll_offset.y < min_vertical_scroll_offset {
+                        shared_scroll_offset.borrow_mut().y = min_vertical_scroll_offset;
+                        scroll_offset.y = min_vertical_scroll_offset;
+                    }
 
-                    if is_scrolled && scroll_offset.y < min_scroll_offset {
-                        shared_scroll_offset.borrow_mut().y = min_scroll_offset;
-                        scroll_offset.y = min_scroll_offset;
+                    let content_width = content_size.width + padding.left + padding.right;
+                    let is_scrolled_horizontally =
+                        can_scroll_horizontally && !scroll_offset.x.is_zero();
+                    if is_scrolled_horizontally && content_width <= padded_bounds.size.width {
+                        shared_scroll_offset.borrow_mut().x = Pixels::ZERO;
+                        scroll_offset.x = Pixels::ZERO;
                     }
 
                     if let Some(ix) = shared_scroll_to_item {
@@ -263,9 +296,17 @@ impl Element for UniformList {
                     cx.with_content_mask(Some(content_mask), |cx| {
                         for (mut item, ix) in items.into_iter().zip(visible_range) {
                             let item_origin = padded_bounds.origin
-                                + point(px(0.), item_height * ix + scroll_offset.y + padding.top);
+                                + point(
+                                    scroll_offset.x + padding.left,
+                                    item_height * ix + scroll_offset.y + padding.top,
+                                );
+                            let available_width = if can_scroll_horizontally {
+                                padded_bounds.size.width + scroll_offset.x.abs()
+                            } else {
+                                padded_bounds.size.width
+                            };
                             let available_space = size(
-                                AvailableSpace::Definite(padded_bounds.size.width),
+                                AvailableSpace::Definite(available_width),
                                 AvailableSpace::Definite(item_height),
                             );
                             item.layout_as_root(available_space, cx);
@@ -318,6 +359,25 @@ impl UniformList {
         self
     }
 
+    /// Sets the horizontal sizing behavior, controlling the way list items laid out horizontally.
+    /// With [`ListHorizontalSizingBehavior::Unconstrained`] behavior, every item and the list itself will
+    /// have the size of the widest item and lay out pushing the `end_slot` to the right end.
+    pub fn with_horizontal_sizing_behavior(
+        mut self,
+        behavior: ListHorizontalSizingBehavior,
+    ) -> Self {
+        self.horizontal_sizing_behavior = behavior;
+        match behavior {
+            ListHorizontalSizingBehavior::FitList => {
+                self.interactivity.base_style.overflow.x = None;
+            }
+            ListHorizontalSizingBehavior::Unconstrained => {
+                self.interactivity.base_style.overflow.x = Some(Overflow::Scroll);
+            }
+        }
+        self
+    }
+
     fn measure_item(&self, list_width: Option<Pixels>, cx: &mut WindowContext) -> Size<Pixels> {
         if self.item_count == 0 {
             return Size::default();

crates/gpui/src/style.rs 🔗

@@ -156,6 +156,8 @@ pub struct Style {
     pub overflow: Point<Overflow>,
     /// How much space (in points) should be reserved for the scrollbars of `Overflow::Scroll` and `Overflow::Auto` nodes.
     pub scrollbar_width: f32,
+    /// Whether both x and y axis should be scrollable at the same time.
+    pub allow_concurrent_scroll: bool,
 
     // Position properties
     /// What should the `position` value of this struct use as a base offset?
@@ -667,6 +669,7 @@ impl Default for Style {
                 x: Overflow::Visible,
                 y: Overflow::Visible,
             },
+            allow_concurrent_scroll: false,
             scrollbar_width: 0.0,
             position: Position::Relative,
             inset: Edges::auto(),

crates/language/src/language_settings.rs 🔗

@@ -381,7 +381,7 @@ pub struct FeaturesContent {
 pub enum SoftWrap {
     /// Prefer a single line generally, unless an overly long line is encountered.
     None,
-    /// Deprecated: use None instead. Left to avoid breakin existing users' configs.
+    /// Deprecated: use None instead. Left to avoid breaking existing users' configs.
     /// Prefer a single line generally, unless an overly long line is encountered.
     PreferLine,
     /// Soft wrap lines that exceed the editor width.

crates/project_panel/src/project_panel.rs 🔗

@@ -8,20 +8,22 @@ use db::kvp::KEY_VALUE_STORE;
 use editor::{
     items::entry_git_aware_label_color,
     scroll::{Autoscroll, ScrollbarAutoHide},
-    Editor,
+    Editor, EditorEvent, EditorSettings, ShowScrollbar,
 };
 use file_icons::FileIcons;
 
-use anyhow::{anyhow, Result};
+use anyhow::{anyhow, Context as _, Result};
 use collections::{hash_map, BTreeSet, HashMap};
+use core::f32;
 use git::repository::GitFileStatus;
 use gpui::{
     actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
     AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent,
-    EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext,
-    ListSizingBehavior, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
-    PromptLevel, Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View,
-    ViewContext, VisualContext as _, WeakView, WindowContext,
+    Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement,
+    KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton,
+    MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled,
+    Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView,
+    WindowContext,
 };
 use indexmap::IndexMap;
 use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
@@ -29,7 +31,7 @@ use project::{
     relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree,
     WorktreeId,
 };
-use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings, ShowScrollbar};
+use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
 use serde::{Deserialize, Serialize};
 use std::{
     cell::{Cell, OnceCell},
@@ -80,8 +82,10 @@ pub struct ProjectPanel {
     width: Option<Pixels>,
     pending_serialization: Task<Option<()>>,
     show_scrollbar: bool,
-    scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
+    vertical_scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
+    horizontal_scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
     hide_scrollbar_task: Option<Task<()>>,
+    max_width_item_index: Option<usize>,
 }
 
 #[derive(Clone, Debug)]
@@ -90,6 +94,8 @@ struct EditState {
     entry_id: ProjectEntryId,
     is_new_entry: bool,
     is_dir: bool,
+    is_symlink: bool,
+    depth: usize,
     processing_filename: Option<String>,
 }
 
@@ -254,23 +260,26 @@ impl ProjectPanel {
 
             let filename_editor = cx.new_view(Editor::single_line);
 
-            cx.subscribe(&filename_editor, |this, _, event, cx| match event {
-                editor::EditorEvent::BufferEdited
-                | editor::EditorEvent::SelectionsChanged { .. } => {
-                    this.autoscroll(cx);
-                }
-                editor::EditorEvent::Blurred => {
-                    if this
-                        .edit_state
-                        .as_ref()
-                        .map_or(false, |state| state.processing_filename.is_none())
-                    {
-                        this.edit_state = None;
-                        this.update_visible_entries(None, cx);
+            cx.subscribe(
+                &filename_editor,
+                |project_panel, _, editor_event, cx| match editor_event {
+                    EditorEvent::BufferEdited | EditorEvent::SelectionsChanged { .. } => {
+                        project_panel.autoscroll(cx);
                     }
-                }
-                _ => {}
-            })
+                    EditorEvent::Blurred => {
+                        if project_panel
+                            .edit_state
+                            .as_ref()
+                            .map_or(false, |state| state.processing_filename.is_none())
+                        {
+                            project_panel.edit_state = None;
+                            project_panel.update_visible_entries(None, cx);
+                            cx.notify();
+                        }
+                    }
+                    _ => {}
+                },
+            )
             .detach();
 
             cx.observe_global::<FileIcons>(|_, cx| {
@@ -311,7 +320,9 @@ impl ProjectPanel {
                 pending_serialization: Task::ready(None),
                 show_scrollbar: !Self::should_autohide_scrollbar(cx),
                 hide_scrollbar_task: None,
-                scrollbar_drag_thumb_offset: Default::default(),
+                vertical_scrollbar_drag_thumb_offset: Default::default(),
+                horizontal_scrollbar_drag_thumb_offset: Default::default(),
+                max_width_item_index: None,
             };
             this.update_visible_entries(None, cx);
 
@@ -827,7 +838,7 @@ impl ProjectPanel {
         Some(cx.spawn(|project_panel, mut cx| async move {
             let new_entry = edit_task.await;
             project_panel.update(&mut cx, |project_panel, cx| {
-                project_panel.edit_state.take();
+                project_panel.edit_state = None;
                 cx.notify();
             })?;
 
@@ -970,6 +981,8 @@ impl ProjectPanel {
                 is_new_entry: true,
                 is_dir,
                 processing_filename: None,
+                is_symlink: false,
+                depth: 0,
             });
             self.filename_editor.update(cx, |editor, cx| {
                 editor.clear(cx);
@@ -992,6 +1005,7 @@ impl ProjectPanel {
             leaf_entry_id
         }
     }
+
     fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
         if let Some(SelectedEntry {
             worktree_id,
@@ -1007,6 +1021,8 @@ impl ProjectPanel {
                         is_new_entry: false,
                         is_dir: entry.is_dir(),
                         processing_filename: None,
+                        is_symlink: entry.is_symlink,
+                        depth: 0,
                     });
                     let file_name = entry
                         .path
@@ -1750,6 +1766,7 @@ impl ProjectPanel {
 
         let old_ancestors = std::mem::take(&mut self.ancestors);
         self.visible_entries.clear();
+        let mut max_width_item = None;
         for worktree in project.visible_worktrees(cx) {
             let snapshot = worktree.read(cx).snapshot();
             let worktree_id = snapshot.id();
@@ -1805,6 +1822,12 @@ impl ProjectPanel {
                         .get(&entry.id)
                         .map(|ancestor| ancestor.current_ancestor_depth)
                         .unwrap_or_default();
+                    if let Some(edit_state) = &mut self.edit_state {
+                        if edit_state.entry_id == entry.id {
+                            edit_state.is_symlink = entry.is_symlink;
+                            edit_state.depth = depth;
+                        }
+                    }
                     let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
                     if ancestors.len() > 1 {
                         ancestors.reverse();
@@ -1837,6 +1860,78 @@ impl ProjectPanel {
                         is_fifo: entry.is_fifo,
                     });
                 }
+                let worktree_abs_path = worktree.read(cx).abs_path();
+                let (depth, path) = if Some(entry) == worktree.read(cx).root_entry() {
+                    let Some(path_name) = worktree_abs_path
+                        .file_name()
+                        .with_context(|| {
+                            format!("Worktree abs path has no file name, root entry: {entry:?}")
+                        })
+                        .log_err()
+                    else {
+                        continue;
+                    };
+                    let path = Arc::from(Path::new(path_name));
+                    let depth = 0;
+                    (depth, path)
+                } else if entry.is_file() {
+                    let Some(path_name) = entry
+                        .path
+                        .file_name()
+                        .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
+                        .log_err()
+                    else {
+                        continue;
+                    };
+                    let path = Arc::from(Path::new(path_name));
+                    let depth = entry.path.ancestors().count() - 1;
+                    (depth, path)
+                } else {
+                    let path = self
+                        .ancestors
+                        .get(&entry.id)
+                        .and_then(|ancestors| {
+                            let outermost_ancestor = ancestors.ancestors.last()?;
+                            let root_folded_entry = worktree
+                                .read(cx)
+                                .entry_for_id(*outermost_ancestor)?
+                                .path
+                                .as_ref();
+                            entry
+                                .path
+                                .strip_prefix(root_folded_entry)
+                                .ok()
+                                .and_then(|suffix| {
+                                    let full_path = Path::new(root_folded_entry.file_name()?);
+                                    Some(Arc::<Path>::from(full_path.join(suffix)))
+                                })
+                        })
+                        .unwrap_or_else(|| entry.path.clone());
+                    let depth = path
+                        .strip_prefix(worktree_abs_path)
+                        .map(|suffix| suffix.components().count())
+                        .unwrap_or_default();
+                    (depth, path)
+                };
+                let width_estimate = item_width_estimate(
+                    depth,
+                    path.to_string_lossy().chars().count(),
+                    entry.is_symlink,
+                );
+
+                match max_width_item.as_mut() {
+                    Some((id, worktree_id, width)) => {
+                        if *width < width_estimate {
+                            *id = entry.id;
+                            *worktree_id = worktree.read(cx).id();
+                            *width = width_estimate;
+                        }
+                    }
+                    None => {
+                        max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
+                    }
+                }
+
                 if expanded_dir_ids.binary_search(&entry.id).is_err()
                     && entry_iter.advance_to_sibling()
                 {
@@ -1851,6 +1946,22 @@ impl ProjectPanel {
                 .push((worktree_id, visible_worktree_entries, OnceCell::new()));
         }
 
+        if let Some((project_entry_id, worktree_id, _)) = max_width_item {
+            let mut visited_worktrees_length = 0;
+            let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
+                if worktree_id == *id {
+                    entries
+                        .iter()
+                        .position(|entry| entry.id == project_entry_id)
+                } else {
+                    visited_worktrees_length += entries.len();
+                    None
+                }
+            });
+            if let Some(index) = index {
+                self.max_width_item_index = Some(visited_worktrees_length + index);
+            }
+        }
         if let Some((worktree_id, entry_id)) = new_selected_entry {
             self.selection = Some(SelectedEntry {
                 worktree_id,
@@ -2474,7 +2585,8 @@ impl ProjectPanel {
                             cx.stop_propagation();
                             this.deploy_context_menu(event.position, entry_id, cx);
                         },
-                    )),
+                    ))
+                    .overflow_x(),
             )
             .border_1()
             .border_r_2()
@@ -2498,22 +2610,19 @@ impl ProjectPanel {
             )
     }
 
-    fn render_scrollbar(
-        &self,
-        items_count: usize,
-        cx: &mut ViewContext<Self>,
-    ) -> Option<Stateful<Div>> {
-        let settings = ProjectPanelSettings::get_global(cx);
-        if settings.scrollbar.show == ShowScrollbar::Never {
+    fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
+        if !Self::should_show_scrollbar(cx) {
             return None;
         }
         let scroll_handle = self.scroll_handle.0.borrow();
-
-        let height = scroll_handle
-            .last_item_height
-            .filter(|_| self.show_scrollbar || self.scrollbar_drag_thumb_offset.get().is_some())?;
-
-        let total_list_length = height.0 as f64 * items_count as f64;
+        let total_list_length = scroll_handle
+            .last_item_size
+            .filter(|_| {
+                self.show_scrollbar || self.vertical_scrollbar_drag_thumb_offset.get().is_some()
+            })?
+            .contents
+            .height
+            .0 as f64;
         let current_offset = scroll_handle.base_handle.offset().y.0.min(0.).abs() as f64;
         let mut percentage = current_offset / total_list_length;
         let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.height.0 as f64)
@@ -2536,7 +2645,7 @@ impl ProjectPanel {
         Some(
             div()
                 .occlude()
-                .id("project-panel-scroll")
+                .id("project-panel-vertical-scroll")
                 .on_mouse_move(cx.listener(|_, _, cx| {
                     cx.notify();
                     cx.stop_propagation()
@@ -2550,7 +2659,7 @@ impl ProjectPanel {
                 .on_mouse_up(
                     MouseButton::Left,
                     cx.listener(|this, _, cx| {
-                        if this.scrollbar_drag_thumb_offset.get().is_none()
+                        if this.vertical_scrollbar_drag_thumb_offset.get().is_none()
                             && !this.focus_handle.contains_focused(cx)
                         {
                             this.hide_scrollbar(cx);
@@ -2565,21 +2674,101 @@ impl ProjectPanel {
                 }))
                 .h_full()
                 .absolute()
-                .right_0()
-                .top_0()
-                .bottom_0()
+                .right_1()
+                .top_1()
+                .bottom_1()
                 .w(px(12.))
                 .cursor_default()
-                .child(ProjectPanelScrollbar::new(
+                .child(ProjectPanelScrollbar::vertical(
                     percentage as f32..end_offset as f32,
                     self.scroll_handle.clone(),
-                    self.scrollbar_drag_thumb_offset.clone(),
-                    cx.view().clone().into(),
-                    items_count,
+                    self.vertical_scrollbar_drag_thumb_offset.clone(),
+                    cx.view().entity_id(),
                 )),
         )
     }
 
+    fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
+        if !Self::should_show_scrollbar(cx) {
+            return None;
+        }
+        let scroll_handle = self.scroll_handle.0.borrow();
+        let longest_item_width = scroll_handle
+            .last_item_size
+            .filter(|_| {
+                self.show_scrollbar || self.horizontal_scrollbar_drag_thumb_offset.get().is_some()
+            })
+            .filter(|size| size.contents.width > size.item.width)?
+            .contents
+            .width
+            .0 as f64;
+        let current_offset = scroll_handle.base_handle.offset().x.0.min(0.).abs() as f64;
+        let mut percentage = current_offset / longest_item_width;
+        let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.width.0 as f64)
+            / longest_item_width;
+        // Uniform scroll handle might briefly report an offset greater than the length of a list;
+        // in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable.
+        let overshoot = (end_offset - 1.).clamp(0., 1.);
+        if overshoot > 0. {
+            percentage -= overshoot;
+        }
+        const MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH: f64 = 0.005;
+        if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH > 1.0 || end_offset > longest_item_width
+        {
+            return None;
+        }
+        if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
+            return None;
+        }
+        let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH, 1.);
+        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(|this, _, cx| {
+                        if this.horizontal_scrollbar_drag_thumb_offset.get().is_none()
+                            && !this.focus_handle.contains_focused(cx)
+                        {
+                            this.hide_scrollbar(cx);
+                            cx.notify();
+                        }
+
+                        cx.stop_propagation();
+                    }),
+                )
+                .on_scroll_wheel(cx.listener(|_, _, cx| {
+                    cx.notify();
+                }))
+                .w_full()
+                .absolute()
+                .right_1()
+                .left_1()
+                .bottom_1()
+                .h(px(12.))
+                .cursor_default()
+                .when(self.width.is_some(), |this| {
+                    this.child(ProjectPanelScrollbar::horizontal(
+                        percentage as f32..end_offset as f32,
+                        self.scroll_handle.clone(),
+                        self.horizontal_scrollbar_drag_thumb_offset.clone(),
+                        cx.view().entity_id(),
+                    ))
+                }),
+        )
+    }
+
     fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
         let mut dispatch_context = KeyContext::new_with_defaults();
         dispatch_context.add("ProjectPanel");
@@ -2595,9 +2784,32 @@ impl ProjectPanel {
         dispatch_context
     }
 
+    fn should_show_scrollbar(cx: &AppContext) -> bool {
+        let show = ProjectPanelSettings::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 {
-        cx.try_global::<ScrollbarAutoHide>()
-            .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0)
+        let show = ProjectPanelSettings::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>) {
@@ -2623,7 +2835,7 @@ impl ProjectPanel {
         project: Model<Project>,
         entry_id: ProjectEntryId,
         skip_ignored: bool,
-        cx: &mut ViewContext<'_, ProjectPanel>,
+        cx: &mut ViewContext<'_, Self>,
     ) {
         if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
             let worktree = worktree.read(cx);
@@ -2645,13 +2857,22 @@ impl ProjectPanel {
     }
 }
 
+fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
+    const ICON_SIZE_FACTOR: usize = 2;
+    let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
+    if is_symlink {
+        item_width += ICON_SIZE_FACTOR;
+    }
+    item_width
+}
+
 impl Render for ProjectPanel {
     fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
         let has_worktree = !self.visible_entries.is_empty();
         let project = self.project.read(cx);
 
         if has_worktree {
-            let items_count = self
+            let item_count = self
                 .visible_entries
                 .iter()
                 .map(|(_, worktree_entries, _)| worktree_entries.len())
@@ -2742,7 +2963,7 @@ impl Render for ProjectPanel {
                 )
                 .track_focus(&self.focus_handle)
                 .child(
-                    uniform_list(cx.view().clone(), "entries", items_count, {
+                    uniform_list(cx.view().clone(), "entries", item_count, {
                         |this, range, cx| {
                             let mut items = Vec::with_capacity(range.end - range.start);
                             this.for_each_visible_entry(range, cx, |id, details, cx| {
@@ -2753,9 +2974,12 @@ impl Render for ProjectPanel {
                     })
                     .size_full()
                     .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()),
                 )
-                .children(self.render_scrollbar(items_count, cx))
+                .children(self.render_vertical_scrollbar(cx))
+                .children(self.render_horizontal_scrollbar(cx))
                 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
                     deferred(
                         anchored()
@@ -2934,6 +3158,7 @@ mod tests {
     use serde_json::json;
     use settings::SettingsStore;
     use std::path::{Path, PathBuf};
+    use ui::Context;
     use workspace::{
         item::{Item, ProjectItem},
         register_project_item, AppState,

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -1,3 +1,4 @@
+use editor::ShowScrollbar;
 use gpui::Pixels;
 use schemars::JsonSchema;
 use serde_derive::{Deserialize, Serialize};
@@ -24,33 +25,20 @@ pub struct ProjectPanelSettings {
     pub scrollbar: ScrollbarSettings,
 }
 
-/// When to show the scrollbar in the project panel.
-///
-/// Default: always
-#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
-#[serde(rename_all = "snake_case")]
-pub enum ShowScrollbar {
-    #[default]
-    /// Always show the scrollbar.
-    Always,
-    /// Never show the scrollbar.
-    Never,
-}
-
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
 pub struct ScrollbarSettings {
     /// When to show the scrollbar in the project panel.
     ///
-    /// Default: always
-    pub show: ShowScrollbar,
+    /// 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: always
-    pub show: Option<ShowScrollbar>,
+    /// Default: inherits editor scrollbar settings
+    pub show: Option<Option<ShowScrollbar>>,
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]

crates/project_panel/src/scrollbar.rs 🔗

@@ -1,34 +1,54 @@
 use std::{cell::Cell, ops::Range, rc::Rc};
 
 use gpui::{
-    point, AnyView, Bounds, ContentMask, Hitbox, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
-    ScrollWheelEvent, Style, UniformListScrollHandle,
+    point, quad, Bounds, ContentMask, Corners, Edges, EntityId, Hitbox, Hsla, MouseDownEvent,
+    MouseMoveEvent, MouseUpEvent, ScrollWheelEvent, Style, UniformListScrollHandle,
 };
 use ui::{prelude::*, px, relative, IntoElement};
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ScrollbarKind {
+    Horizontal,
+    Vertical,
+}
+
 pub(crate) struct ProjectPanelScrollbar {
     thumb: Range<f32>,
     scroll: UniformListScrollHandle,
     // If Some(), there's an active drag, offset by percentage from the top of thumb.
     scrollbar_drag_state: Rc<Cell<Option<f32>>>,
-    item_count: usize,
-    view: AnyView,
+    kind: ScrollbarKind,
+    parent_id: EntityId,
 }
 
 impl ProjectPanelScrollbar {
-    pub(crate) fn new(
+    pub(crate) fn vertical(
         thumb: Range<f32>,
         scroll: UniformListScrollHandle,
         scrollbar_drag_state: Rc<Cell<Option<f32>>>,
-        view: AnyView,
-        item_count: usize,
+        parent_id: EntityId,
     ) -> Self {
         Self {
             thumb,
             scroll,
             scrollbar_drag_state,
-            item_count,
-            view,
+            kind: ScrollbarKind::Vertical,
+            parent_id,
+        }
+    }
+
+    pub(crate) fn horizontal(
+        thumb: Range<f32>,
+        scroll: UniformListScrollHandle,
+        scrollbar_drag_state: Rc<Cell<Option<f32>>>,
+        parent_id: EntityId,
+    ) -> Self {
+        Self {
+            thumb,
+            scroll,
+            scrollbar_drag_state,
+            kind: ScrollbarKind::Horizontal,
+            parent_id,
         }
     }
 }
@@ -50,8 +70,14 @@ impl gpui::Element for ProjectPanelScrollbar {
         let mut style = Style::default();
         style.flex_grow = 1.;
         style.flex_shrink = 1.;
-        style.size.width = px(12.).into();
-        style.size.height = relative(1.).into();
+        if self.kind == ScrollbarKind::Vertical {
+            style.size.width = px(12.).into();
+            style.size.height = relative(1.).into();
+        } else {
+            style.size.width = relative(1.).into();
+            style.size.height = px(12.).into();
+        }
+
         (cx.request_layout(style, None), ())
     }
 
@@ -77,25 +103,65 @@ impl gpui::Element for ProjectPanelScrollbar {
     ) {
         cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
             let colors = cx.theme().colors();
-            let scrollbar_background = colors.scrollbar_track_background;
             let thumb_background = colors.scrollbar_thumb_background;
-            cx.paint_quad(gpui::fill(bounds, scrollbar_background));
-
-            let thumb_offset = self.thumb.start * bounds.size.height;
-            let thumb_end = self.thumb.end * bounds.size.height;
+            let is_vertical = self.kind == ScrollbarKind::Vertical;
+            let extra_padding = px(5.0);
+            let padded_bounds = if is_vertical {
+                Bounds::from_corners(
+                    bounds.origin + point(Pixels::ZERO, extra_padding),
+                    bounds.lower_right() - point(Pixels::ZERO, extra_padding * 3),
+                )
+            } else {
+                Bounds::from_corners(
+                    bounds.origin + point(extra_padding, Pixels::ZERO),
+                    bounds.lower_right() - point(extra_padding * 3, Pixels::ZERO),
+                )
+            };
 
-            let thumb_percentage_size = self.thumb.end - self.thumb.start;
-            let thumb_bounds = {
-                let thumb_upper_left = point(bounds.origin.x, bounds.origin.y + thumb_offset);
+            let mut thumb_bounds = if is_vertical {
+                let thumb_offset = self.thumb.start * padded_bounds.size.height;
+                let thumb_end = self.thumb.end * padded_bounds.size.height;
+                let thumb_upper_left = point(
+                    padded_bounds.origin.x,
+                    padded_bounds.origin.y + thumb_offset,
+                );
                 let thumb_lower_right = point(
-                    bounds.origin.x + bounds.size.width,
-                    bounds.origin.y + thumb_end,
+                    padded_bounds.origin.x + padded_bounds.size.width,
+                    padded_bounds.origin.y + thumb_end,
                 );
                 Bounds::from_corners(thumb_upper_left, thumb_lower_right)
+            } else {
+                let thumb_offset = self.thumb.start * padded_bounds.size.width;
+                let thumb_end = self.thumb.end * padded_bounds.size.width;
+                let thumb_upper_left = point(
+                    padded_bounds.origin.x + thumb_offset,
+                    padded_bounds.origin.y,
+                );
+                let thumb_lower_right = point(
+                    padded_bounds.origin.x + thumb_end,
+                    padded_bounds.origin.y + padded_bounds.size.height,
+                );
+                Bounds::from_corners(thumb_upper_left, thumb_lower_right)
+            };
+            let corners = if is_vertical {
+                thumb_bounds.size.width /= 1.5;
+                Corners::all(thumb_bounds.size.width / 2.0)
+            } else {
+                thumb_bounds.size.height /= 1.5;
+                Corners::all(thumb_bounds.size.height / 2.0)
             };
-            cx.paint_quad(gpui::fill(thumb_bounds, thumb_background));
+            cx.paint_quad(quad(
+                thumb_bounds,
+                corners,
+                thumb_background,
+                Edges::default(),
+                Hsla::transparent_black(),
+            ));
+
             let scroll = self.scroll.clone();
-            let item_count = self.item_count;
+            let kind = self.kind;
+            let thumb_percentage_size = self.thumb.end - self.thumb.start;
+
             cx.on_mouse_event({
                 let scroll = self.scroll.clone();
                 let is_dragging = self.scrollbar_drag_state.clone();
@@ -103,20 +169,37 @@ impl gpui::Element for ProjectPanelScrollbar {
                     if phase.bubble() && bounds.contains(&event.position) {
                         if !thumb_bounds.contains(&event.position) {
                             let scroll = scroll.0.borrow();
-                            if let Some(last_height) = scroll.last_item_height {
-                                let max_offset = item_count as f32 * last_height;
-                                let percentage =
-                                    (event.position.y - bounds.origin.y) / bounds.size.height;
-
-                                let percentage = percentage.min(1. - thumb_percentage_size);
-                                scroll
-                                    .base_handle
-                                    .set_offset(point(px(0.), -max_offset * percentage));
+                            if let Some(item_size) = scroll.last_item_size {
+                                match kind {
+                                    ScrollbarKind::Horizontal => {
+                                        let percentage = (event.position.x - bounds.origin.x)
+                                            / bounds.size.width;
+                                        let max_offset = item_size.contents.width;
+                                        let percentage = percentage.min(1. - thumb_percentage_size);
+                                        scroll.base_handle.set_offset(point(
+                                            -max_offset * percentage,
+                                            scroll.base_handle.offset().y,
+                                        ));
+                                    }
+                                    ScrollbarKind::Vertical => {
+                                        let percentage = (event.position.y - bounds.origin.y)
+                                            / bounds.size.height;
+                                        let max_offset = item_size.contents.height;
+                                        let percentage = percentage.min(1. - thumb_percentage_size);
+                                        scroll.base_handle.set_offset(point(
+                                            scroll.base_handle.offset().x,
+                                            -max_offset * percentage,
+                                        ));
+                                    }
+                                }
                             }
                         } else {
-                            let thumb_top_offset =
-                                (event.position.y - thumb_bounds.origin.y) / bounds.size.height;
-                            is_dragging.set(Some(thumb_top_offset));
+                            let thumb_offset = if is_vertical {
+                                (event.position.y - thumb_bounds.origin.y) / bounds.size.height
+                            } else {
+                                (event.position.x - thumb_bounds.origin.x) / bounds.size.width
+                            };
+                            is_dragging.set(Some(thumb_offset));
                         }
                     }
                 }
@@ -127,6 +210,7 @@ impl gpui::Element for ProjectPanelScrollbar {
                     if phase.bubble() && bounds.contains(&event.position) {
                         let scroll = scroll.0.borrow_mut();
                         let current_offset = scroll.base_handle.offset();
+
                         scroll
                             .base_handle
                             .set_offset(current_offset + event.delta.pixel_delta(cx.line_height()));
@@ -134,19 +218,39 @@ impl gpui::Element for ProjectPanelScrollbar {
                 }
             });
             let drag_state = self.scrollbar_drag_state.clone();
-            let view_id = self.view.entity_id();
+            let view_id = self.parent_id;
+            let kind = self.kind;
             cx.on_mouse_event(move |event: &MouseMoveEvent, _, cx| {
                 if let Some(drag_state) = drag_state.get().filter(|_| event.dragging()) {
                     let scroll = scroll.0.borrow();
-                    if let Some(last_height) = scroll.last_item_height {
-                        let max_offset = item_count as f32 * last_height;
-                        let percentage =
-                            (event.position.y - bounds.origin.y) / bounds.size.height - drag_state;
+                    if let Some(item_size) = scroll.last_item_size {
+                        match kind {
+                            ScrollbarKind::Horizontal => {
+                                let max_offset = item_size.contents.width;
+                                let percentage = (event.position.x - bounds.origin.x)
+                                    / bounds.size.width
+                                    - drag_state;
+
+                                let percentage = percentage.min(1. - thumb_percentage_size);
+                                scroll.base_handle.set_offset(point(
+                                    -max_offset * percentage,
+                                    scroll.base_handle.offset().y,
+                                ));
+                            }
+                            ScrollbarKind::Vertical => {
+                                let max_offset = item_size.contents.height;
+                                let percentage = (event.position.y - bounds.origin.y)
+                                    / bounds.size.height
+                                    - drag_state;
+
+                                let percentage = percentage.min(1. - thumb_percentage_size);
+                                scroll.base_handle.set_offset(point(
+                                    scroll.base_handle.offset().x,
+                                    -max_offset * percentage,
+                                ));
+                            }
+                        };
 
-                        let percentage = percentage.min(1. - thumb_percentage_size);
-                        scroll
-                            .base_handle
-                            .set_offset(point(px(0.), -max_offset * percentage));
                         cx.notify(view_id);
                     }
                 } else {

crates/ui/src/components/list/list_item.rs 🔗

@@ -36,6 +36,7 @@ pub struct ListItem {
     on_secondary_mouse_down: Option<Box<dyn Fn(&MouseDownEvent, &mut WindowContext) + 'static>>,
     children: SmallVec<[AnyElement; 2]>,
     selectable: bool,
+    overflow_x: bool,
 }
 
 impl ListItem {
@@ -58,6 +59,7 @@ impl ListItem {
             tooltip: None,
             children: SmallVec::new(),
             selectable: true,
+            overflow_x: false,
         }
     }
 
@@ -131,6 +133,11 @@ impl ListItem {
         self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element);
         self
     }
+
+    pub fn overflow_x(mut self) -> Self {
+        self.overflow_x = true;
+        self
+    }
 }
 
 impl Disableable for ListItem {
@@ -239,7 +246,13 @@ impl RenderOnce for ListItem {
                             .flex_shrink_0()
                             .flex_basis(relative(0.25))
                             .gap(Spacing::Small.rems(cx))
-                            .overflow_hidden()
+                            .map(|list_content| {
+                                if self.overflow_x {
+                                    list_content
+                                } else {
+                                    list_content.overflow_hidden()
+                                }
+                            })
                             .children(self.start_slot)
                             .children(self.children),
                     )

docs/src/configuring-zed.md 🔗

@@ -1954,7 +1954,7 @@ Run the `theme selector: toggle` action in the command palette to see a current
     "auto_reveal_entries": true,
     "auto_fold_dirs": true,
     "scrollbar": {
-      "show": "always"
+      "show": null
     }
   }
 }
@@ -2074,13 +2074,13 @@ Run the `theme selector: toggle` action in the command palette to see a current
 
 ### Scrollbar
 
-- Description: Scrollbar related settings. Possible values: "always", "never".
+- Description: Scrollbar related settings. Possible values: null, "auto", "system", "always", "never". Inherits editor settings when absent, see its description for more details.
 - Setting: `scrollbar`
 - Default:
 
 ```json
 "scrollbar": {
-    "show": "always"
+    "show": null
 }
 ```