git_ui: Panel Horizontal Scroll (#26402)

Nate Butler , Max Brunsfeld , Cole Miller , and Cole Miller created

Known Issues:
- When items can horizontal scroll, the right selected border is hidden

TODO:
- [ ] Width calculation is off
- [ ] When scrollbars should autohide they don't until hovering the
panel
- [ ] When switching to and from scrollbar track being visible we are
missing a notify somewhere.

Release Notes:

- Git Panel: Added horizontal scrolling in the git panel

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Cole Miller <m@cole-miller.net>
Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

crates/git_ui/src/git_panel.rs | 622 ++++++++++++++++++++++++++---------
1 file changed, 454 insertions(+), 168 deletions(-)

Detailed changes

crates/git_ui/src/git_panel.rs 🔗

@@ -28,9 +28,9 @@ use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
 use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
 use gpui::{
     actions, anchored, deferred, percentage, uniform_list, Action, Animation, AnimationExt as _,
-    ClickEvent, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
-    ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent,
-    MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Stateful, Subscription, Task,
+    Axis, ClickEvent, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent,
+    MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task,
     Transformation, UniformListScrollHandle, WeakEntity,
 };
 use itertools::Itertools;
@@ -205,6 +205,21 @@ pub struct GitStatusEntry {
     pub(crate) staging: StageStatus,
 }
 
+impl GitStatusEntry {
+    fn display_name(&self) -> String {
+        self.worktree_path
+            .file_name()
+            .map(|name| name.to_string_lossy().into_owned())
+            .unwrap_or_else(|| self.worktree_path.to_string_lossy().into_owned())
+    }
+
+    fn parent_dir(&self) -> Option<String> {
+        self.worktree_path
+            .parent()
+            .map(|parent| parent.to_string_lossy().into_owned())
+    }
+}
+
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 enum TargetStatus {
     Staged,
@@ -222,6 +237,61 @@ struct PendingOperation {
 
 type RemoteOperations = Rc<RefCell<HashSet<u32>>>;
 
+// computed state related to how to render scrollbars
+// one per axis
+// on render we just read this off the panel
+// we update it when
+// - settings change
+// - on focus in, on focus out, on hover, etc.
+#[derive(Debug)]
+struct ScrollbarProperties {
+    axis: Axis,
+    show_scrollbar: bool,
+    show_track: bool,
+    auto_hide: bool,
+    hide_task: Option<Task<()>>,
+    state: ScrollbarState,
+}
+
+impl ScrollbarProperties {
+    // Shows the scrollbar and cancels any pending hide task
+    fn show(&mut self, cx: &mut Context<GitPanel>) {
+        if !self.auto_hide {
+            return;
+        }
+        self.show_scrollbar = true;
+        self.hide_task.take();
+        cx.notify();
+    }
+
+    fn hide(&mut self, window: &mut Window, cx: &mut Context<GitPanel>) {
+        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
+
+        if !self.auto_hide {
+            return;
+        }
+
+        let axis = self.axis;
+        self.hide_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
+            cx.background_executor()
+                .timer(SCROLLBAR_SHOW_INTERVAL)
+                .await;
+
+            if let Some(panel) = panel.upgrade() {
+                panel
+                    .update(&mut cx, |panel, cx| {
+                        match axis {
+                            Axis::Vertical => panel.vertical_scrollbar.show_scrollbar = false,
+                            Axis::Horizontal => panel.horizontal_scrollbar.show_scrollbar = false,
+                        }
+                        cx.notify();
+                    })
+                    .log_err();
+            }
+        }));
+    }
+}
+
 pub struct GitPanel {
     remote_operation_id: u32,
     pending_remote_operations: RemoteOperations,
@@ -237,7 +307,8 @@ pub struct GitPanel {
     single_tracked_entry: Option<GitStatusEntry>,
     focus_handle: FocusHandle,
     fs: Arc<dyn Fs>,
-    hide_scrollbar_task: Option<Task<()>>,
+    horizontal_scrollbar: ScrollbarProperties,
+    vertical_scrollbar: ScrollbarProperties,
     new_count: usize,
     entry_count: usize,
     new_staged_count: usize,
@@ -246,10 +317,9 @@ pub struct GitPanel {
     pending_serialization: Task<Option<()>>,
     pub(crate) project: Entity<Project>,
     scroll_handle: UniformListScrollHandle,
-    scrollbar_state: ScrollbarState,
+    max_width_item_index: Option<usize>,
     selected_entry: Option<usize>,
     marked_entries: Vec<usize>,
-    show_scrollbar: bool,
     tracked_count: usize,
     tracked_staged_count: usize,
     update_visible_entries_task: Task<()>,
@@ -316,7 +386,7 @@ impl GitPanel {
         let focus_handle = cx.focus_handle();
         cx.on_focus(&focus_handle, window, Self::focus_in).detach();
         cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
-            this.hide_scrollbar(window, cx);
+            this.hide_scrollbars(window, cx);
         })
         .detach();
 
@@ -355,8 +425,23 @@ impl GitPanel {
         )
         .detach();
 
-        let scrollbar_state =
-            ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
+        let vertical_scrollbar = ScrollbarProperties {
+            axis: Axis::Vertical,
+            state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
+            show_scrollbar: false,
+            show_track: false,
+            auto_hide: false,
+            hide_task: None,
+        };
+
+        let horizontal_scrollbar = ScrollbarProperties {
+            axis: Axis::Horizontal,
+            state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
+            show_scrollbar: false,
+            show_track: false,
+            auto_hide: false,
+            hide_task: None,
+        };
 
         let mut git_panel = Self {
             pending_remote_operations: Default::default(),
@@ -371,7 +456,6 @@ impl GitPanel {
             entries: Vec::new(),
             focus_handle: cx.focus_handle(),
             fs,
-            hide_scrollbar_task: None,
             new_count: 0,
             new_staged_count: 0,
             pending: Vec::new(),
@@ -381,10 +465,9 @@ impl GitPanel {
             single_tracked_entry: None,
             project,
             scroll_handle,
-            scrollbar_state,
+            max_width_item_index: None,
             selected_entry: None,
             marked_entries: Vec::new(),
-            show_scrollbar: false,
             tracked_count: 0,
             tracked_staged_count: 0,
             update_visible_entries_task: Task::ready(()),
@@ -393,12 +476,93 @@ impl GitPanel {
             workspace,
             modal_open: false,
             entry_count: 0,
+            horizontal_scrollbar,
+            vertical_scrollbar,
         };
         git_panel.schedule_update(false, window, cx);
-        git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
         git_panel
     }
 
+    fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.horizontal_scrollbar.hide(window, cx);
+        self.vertical_scrollbar.hide(window, cx);
+    }
+
+    fn update_scrollbar_properties(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        // TODO: This PR should have defined Editor's `scrollbar.axis`
+        // as an Option<ScrollbarAxis>, not a ScrollbarAxes as it would allow you to
+        // `.unwrap_or(EditorSettings::get_global(cx).scrollbar.show)`.
+        //
+        // Once this is fixed we can extend the GitPanelSettings with a `scrollbar.axis`
+        // so we can show each axis based on the settings.
+        //
+        // We should fix this. PR: https://github.com/zed-industries/zed/pull/19495
+
+        let show_setting = GitPanelSettings::get_global(cx)
+            .scrollbar
+            .show
+            .unwrap_or(EditorSettings::get_global(cx).scrollbar.show);
+
+        let scroll_handle = self.scroll_handle.0.borrow();
+
+        let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| 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 => false,
+        };
+
+        let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
+            (size.contents.width > size.item.width).then_some(size.contents.width)
+        });
+
+        // is there an item long enough that we should show a horizontal scrollbar?
+        let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
+            longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
+        } else {
+            true
+        };
+
+        let show_horizontal = match (show_setting, item_wider_than_container) {
+            (_, false) => false,
+            (ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always, true) => true,
+            (ShowScrollbar::Never, true) => false,
+        };
+
+        let show_vertical = match show_setting {
+            ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
+            ShowScrollbar::Never => false,
+        };
+
+        let show_horizontal_track =
+            show_horizontal && matches!(show_setting, ShowScrollbar::Always);
+
+        // TODO: we probably should hide the scroll track when the list doesn't need to scroll
+        let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
+
+        self.vertical_scrollbar = ScrollbarProperties {
+            axis: self.vertical_scrollbar.axis,
+            state: self.vertical_scrollbar.state.clone(),
+            show_scrollbar: show_vertical,
+            show_track: show_vertical_track,
+            auto_hide: autohide(show_setting, cx),
+            hide_task: None,
+        };
+
+        self.horizontal_scrollbar = ScrollbarProperties {
+            axis: self.horizontal_scrollbar.axis,
+            state: self.horizontal_scrollbar.state.clone(),
+            show_scrollbar: show_horizontal,
+            show_track: show_horizontal_track,
+            auto_hide: autohide(show_setting, cx),
+            hide_task: None,
+        };
+
+        cx.notify();
+    }
+
     pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
         fn binary_search<F>(mut low: usize, mut high: usize, is_target: F) -> Option<usize>
         where
@@ -555,53 +719,6 @@ impl GitPanel {
         }
     }
 
-    fn show_scrollbar(&self, cx: &mut Context<Self>) -> ShowScrollbar {
-        GitPanelSettings::get_global(cx)
-            .scrollbar
-            .show
-            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
-    }
-
-    fn should_show_scrollbar(&self, cx: &mut Context<Self>) -> bool {
-        let show = self.show_scrollbar(cx);
-        match show {
-            ShowScrollbar::Auto => true,
-            ShowScrollbar::System => true,
-            ShowScrollbar::Always => true,
-            ShowScrollbar::Never => false,
-        }
-    }
-
-    fn should_autohide_scrollbar(&self, cx: &mut Context<Self>) -> bool {
-        let show = self.show_scrollbar(cx);
-        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, window: &mut Window, cx: &mut Context<Self>) {
-        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
-        if !self.should_autohide_scrollbar(cx) {
-            return;
-        }
-        self.hide_scrollbar_task = Some(cx.spawn_in(window, |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 handle_modifiers_changed(
         &mut self,
         event: &ModifiersChangedEvent,
@@ -1972,12 +2089,13 @@ impl GitPanel {
             cx.background_executor().timer(UPDATE_DEBOUNCE).await;
             if let Some(git_panel) = handle.upgrade() {
                 git_panel
-                    .update_in(&mut cx, |git_panel, _, cx| {
+                    .update_in(&mut cx, |git_panel, window, cx| {
                         if clear_pending {
                             git_panel.clear_pending();
                         }
                         git_panel.update_visible_entries(cx);
                         git_panel.update_editor_placeholder(cx);
+                        git_panel.update_scrollbar_properties(window, cx);
                     })
                     .ok();
             }
@@ -2038,6 +2156,7 @@ impl GitPanel {
         let mut conflict_entries = Vec::new();
         let mut last_staged = None;
         let mut staged_count = 0;
+        let mut max_width_item: Option<(RepoPath, usize)> = None;
 
         let Some(repo) = self.active_repository.as_ref() else {
             // Just clear entries if no repository is active.
@@ -2083,6 +2202,21 @@ impl GitPanel {
                 last_staged = Some(entry.clone());
             }
 
+            let width_estimate = Self::item_width_estimate(
+                entry.parent_dir().map(|s| s.len()).unwrap_or(0),
+                entry.display_name().len(),
+            );
+
+            match max_width_item.as_mut() {
+                Some((repo_path, estimate)) => {
+                    if width_estimate > *estimate {
+                        *repo_path = entry.repo_path.clone();
+                        *estimate = width_estimate;
+                    }
+                }
+                None => max_width_item = Some((entry.repo_path.clone(), width_estimate)),
+            }
+
             if is_conflict {
                 conflict_entries.push(entry);
             } else if is_new {
@@ -2155,6 +2289,15 @@ impl GitPanel {
                 .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
         }
 
+        if let Some((repo_path, _)) = max_width_item {
+            self.max_width_item_index = self.entries.iter().position(|entry| match entry {
+                GitListEntry::GitStatusEntry(git_status_entry) => {
+                    git_status_entry.repo_path == repo_path
+                }
+                GitListEntry::Header(_) => false,
+            });
+        }
+
         self.update_counts(repo);
 
         self.select_first_entry_if_none(cx);
@@ -2376,6 +2519,12 @@ impl GitPanel {
         self.has_staged_changes()
     }
 
+    // eventually we'll need to take depth into account here
+    // if we add a tree view
+    fn item_width_estimate(path: usize, file_name: usize) -> usize {
+        path + file_name
+    }
+
     fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
         let focus_handle = self.focus_handle.clone();
         PopoverMenu::new(id.into())
@@ -2795,58 +2944,108 @@ impl GitPanel {
             )
     }
 
-    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
-        let scroll_bar_style = self.show_scrollbar(cx);
-        let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
-
-        if !self.should_show_scrollbar(cx)
-            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
-        {
-            return None;
-        }
+    fn render_vertical_scrollbar(
+        &self,
+        show_horizontal_scrollbar_container: bool,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        div()
+            .id("git-panel-vertical-scroll")
+            .occlude()
+            .flex_none()
+            .h_full()
+            .cursor_default()
+            .absolute()
+            .right_0()
+            .top_0()
+            .bottom_0()
+            .w(px(12.))
+            .when(show_horizontal_scrollbar_container, |this| {
+                this.pb_neg_3p5()
+            })
+            .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, _, window, cx| {
+                    if !this.vertical_scrollbar.state.is_dragging()
+                        && !this.focus_handle.contains_focused(window, cx)
+                    {
+                        this.vertical_scrollbar.hide(window, cx);
+                        cx.notify();
+                    }
 
-        Some(
-            div()
-                .id("git-panel-vertical-scroll")
-                .occlude()
-                .flex_none()
-                .h_full()
-                .cursor_default()
-                .when(show_container, |this| this.pl_1().px_1p5())
-                .when(!show_container, |this| {
-                    this.absolute().right_1().top_1().bottom_1().w(px(12.))
-                })
-                .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, _, window, cx| {
-                        if !this.scrollbar_state.is_dragging()
-                            && !this.focus_handle.contains_focused(window, cx)
-                        {
-                            this.hide_scrollbar(window, cx);
-                            cx.notify();
-                        }
+                }),
+            )
+            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
+                cx.notify();
+            }))
+            .children(Scrollbar::vertical(
+                // percentage as f32..end_offset as f32,
+                self.vertical_scrollbar.state.clone(),
+            ))
+    }
 
-                        cx.stop_propagation();
-                    }),
-                )
-                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                    cx.notify();
-                }))
-                .children(Scrollbar::vertical(
-                    // percentage as f32..end_offset as f32,
-                    self.scrollbar_state.clone(),
-                )),
-        )
+    /// Renders the horizontal scrollbar.
+    ///
+    /// The right offset is used to determine how far to the right the
+    /// scrollbar should extend to, useful for ensuring it doesn't collide
+    /// with the vertical scrollbar when visible.
+    fn render_horizontal_scrollbar(
+        &self,
+        right_offset: Pixels,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        div()
+            .id("git-panel-horizontal-scroll")
+            .occlude()
+            .flex_none()
+            .w_full()
+            .cursor_default()
+            .absolute()
+            .bottom_neg_px()
+            .left_0()
+            .right_0()
+            .pr(right_offset)
+            .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, _, window, cx| {
+                    if !this.horizontal_scrollbar.state.is_dragging()
+                        && !this.focus_handle.contains_focused(window, cx)
+                    {
+                        this.horizontal_scrollbar.hide(window, cx);
+                        cx.notify();
+                    }
+
+                    cx.stop_propagation();
+                }),
+            )
+            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
+                cx.notify();
+            }))
+            .children(Scrollbar::horizontal(
+                // percentage as f32..end_offset as f32,
+                self.horizontal_scrollbar.state.clone(),
+            ))
     }
 
     fn render_buffer_header_controls(
@@ -2900,54 +3099,147 @@ impl GitPanel {
     ) -> impl IntoElement {
         let entry_count = self.entries.len();
 
-        h_flex()
+        let scroll_track_size = px(16.);
+
+        let h_scroll_offset = if self.vertical_scrollbar.show_scrollbar {
+            // magic number
+            px(3.)
+        } else {
+            px(0.)
+        };
+
+        v_flex()
+            .flex_1()
             .size_full()
-            .flex_grow()
             .overflow_hidden()
+            .relative()
+            // Show a border on the top and bottom of the container when
+            // the vertical scrollbar container is visible so we don't have a
+            // floating left border in the panel.
+            .when(self.vertical_scrollbar.show_track, |this| {
+                this.border_t_1()
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border)
+            })
             .child(
-                uniform_list(cx.entity().clone(), "entries", entry_count, {
-                    move |this, range, window, cx| {
-                        let mut items = Vec::with_capacity(range.end - range.start);
-
-                        for ix in range {
-                            match &this.entries.get(ix) {
-                                Some(GitListEntry::GitStatusEntry(entry)) => {
-                                    items.push(this.render_entry(
-                                        ix,
-                                        entry,
-                                        has_write_access,
-                                        window,
-                                        cx,
-                                    ));
-                                }
-                                Some(GitListEntry::Header(header)) => {
-                                    items.push(this.render_list_header(
-                                        ix,
-                                        header,
-                                        has_write_access,
-                                        window,
-                                        cx,
-                                    ));
+                h_flex()
+                    .flex_1()
+                    .size_full()
+                    .relative()
+                    .overflow_hidden()
+                    .child(
+                        uniform_list(cx.entity().clone(), "entries", entry_count, {
+                            move |this, range, window, cx| {
+                                let mut items = Vec::with_capacity(range.end - range.start);
+
+                                for ix in range {
+                                    match &this.entries.get(ix) {
+                                        Some(GitListEntry::GitStatusEntry(entry)) => {
+                                            items.push(this.render_entry(
+                                                ix,
+                                                entry,
+                                                has_write_access,
+                                                window,
+                                                cx,
+                                            ));
+                                        }
+                                        Some(GitListEntry::Header(header)) => {
+                                            items.push(this.render_list_header(
+                                                ix,
+                                                header,
+                                                has_write_access,
+                                                window,
+                                                cx,
+                                            ));
+                                        }
+                                        None => {}
+                                    }
                                 }
-                                None => {}
-                            }
-                        }
 
-                        items
-                    }
-                })
-                .size_full()
-                .with_sizing_behavior(ListSizingBehavior::Auto)
-                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
-                .track_scroll(self.scroll_handle.clone()),
-            )
-            .on_mouse_down(
-                MouseButton::Right,
-                cx.listener(move |this, event: &MouseDownEvent, window, cx| {
-                    this.deploy_panel_context_menu(event.position, window, cx)
-                }),
+                                items
+                            }
+                        })
+                        .size_full()
+                        .flex_grow()
+                        .with_sizing_behavior(ListSizingBehavior::Auto)
+                        .with_horizontal_sizing_behavior(
+                            ListHorizontalSizingBehavior::Unconstrained,
+                        )
+                        .with_width_from_item(self.max_width_item_index)
+                        .track_scroll(self.scroll_handle.clone()),
+                    )
+                    .on_mouse_down(
+                        MouseButton::Right,
+                        cx.listener(move |this, event: &MouseDownEvent, window, cx| {
+                            this.deploy_panel_context_menu(event.position, window, cx)
+                        }),
+                    )
+                    .when(self.vertical_scrollbar.show_track, |this| {
+                        this.child(
+                            v_flex()
+                                .h_full()
+                                .flex_none()
+                                .w(scroll_track_size)
+                                .bg(cx.theme().colors().panel_background)
+                                .child(
+                                    div()
+                                        .size_full()
+                                        .flex_1()
+                                        .border_l_1()
+                                        .border_color(cx.theme().colors().border),
+                                ),
+                        )
+                    })
+                    .when(self.vertical_scrollbar.show_scrollbar, |this| {
+                        this.child(
+                            self.render_vertical_scrollbar(
+                                self.horizontal_scrollbar.show_track,
+                                cx,
+                            ),
+                        )
+                    }),
             )
-            .children(self.render_scrollbar(cx))
+            .when(self.horizontal_scrollbar.show_track, |this| {
+                this.child(
+                    h_flex()
+                        .w_full()
+                        .h(scroll_track_size)
+                        .flex_none()
+                        .relative()
+                        .child(
+                            div()
+                                .w_full()
+                                .flex_1()
+                                // for some reason the horizontal scrollbar is 1px
+                                // taller than the vertical scrollbar??
+                                .h(scroll_track_size - px(1.))
+                                .bg(cx.theme().colors().panel_background)
+                                .border_t_1()
+                                .border_color(cx.theme().colors().border),
+                        )
+                        .when(self.vertical_scrollbar.show_track, |this| {
+                            this.child(
+                                div()
+                                    .flex_none()
+                                    // -1px prevents a missing pixel between the two container borders
+                                    .w(scroll_track_size - px(1.))
+                                    .h_full(),
+                            )
+                            .child(
+                                // HACK: Fill the missing 1px 🥲
+                                div()
+                                    .absolute()
+                                    .right(scroll_track_size - px(1.))
+                                    .bottom(scroll_track_size - px(1.))
+                                    .size_px()
+                                    .bg(cx.theme().colors().border),
+                            )
+                        }),
+                )
+            })
+            .when(self.horizontal_scrollbar.show_scrollbar, |this| {
+                this.child(self.render_horizontal_scrollbar(h_scroll_offset, cx))
+            })
     }
 
     fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
@@ -3074,13 +3366,8 @@ impl GitPanel {
         window: &Window,
         cx: &Context<Self>,
     ) -> AnyElement {
-        let display_name = entry
-            .worktree_path
-            .file_name()
-            .map(|name| name.to_string_lossy().into_owned())
-            .unwrap_or_else(|| entry.worktree_path.to_string_lossy().into_owned());
+        let display_name = entry.display_name();
 
-        let worktree_path = entry.worktree_path.clone();
         let selected = self.selected_entry == Some(ix);
         let marked = self.marked_entries.contains(&ix);
         let status_style = GitPanelSettings::get_global(cx).status_style;
@@ -3272,12 +3559,12 @@ impl GitPanel {
             .child(
                 h_flex()
                     .items_center()
-                    .overflow_hidden()
-                    .when_some(worktree_path.parent(), |this, parent| {
-                        let parent_str = parent.to_string_lossy();
-                        if !parent_str.is_empty() {
+                    .flex_1()
+                    // .overflow_hidden()
+                    .when_some(entry.parent_dir(), |this, parent| {
+                        if !parent.is_empty() {
                             this.child(
-                                self.entry_label(format!("{}/", parent_str), path_color)
+                                self.entry_label(format!("{}/", parent), path_color)
                                     .when(status.is_deleted(), |this| this.strikethrough()),
                             )
                         } else {
@@ -3351,14 +3638,13 @@ impl Render for GitPanel {
             .when(has_write_access && has_co_authors, |git_panel| {
                 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
             })
-            // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
-            .on_hover(cx.listener(|this, hovered, window, cx| {
+            .on_hover(cx.listener(move |this, hovered, window, cx| {
                 if *hovered {
-                    this.show_scrollbar = true;
-                    this.hide_scrollbar_task.take();
+                    this.horizontal_scrollbar.show(cx);
+                    this.vertical_scrollbar.show(cx);
                     cx.notify();
                 } else if !this.focus_handle.contains_focused(window, cx) {
-                    this.hide_scrollbar(window, cx);
+                    this.hide_scrollbars(window, cx);
                 }
             }))
             .size_full()