diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 10a208bf8bdfc2589bc05c7a096411247ce9cdf8..3998ed11af95bb04bfbaf7471c66ca8bed50ae49 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/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 { + 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>>; +// 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>, + state: ScrollbarState, +} + +impl ScrollbarProperties { + // Shows the scrollbar and cancels any pending hide task + fn show(&mut self, cx: &mut Context) { + 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) { + 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, focus_handle: FocusHandle, fs: Arc, - hide_scrollbar_task: Option>, + 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>, pub(crate) project: Entity, scroll_handle: UniformListScrollHandle, - scrollbar_state: ScrollbarState, + max_width_item_index: Option, selected_entry: Option, marked_entries: Vec, - 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.horizontal_scrollbar.hide(window, cx); + self.vertical_scrollbar.hide(window, cx); + } + + fn update_scrollbar_properties(&mut self, _window: &mut Window, cx: &mut Context) { + // TODO: This PR should have defined Editor's `scrollbar.axis` + // as an Option, 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| match show { + ShowScrollbar::Auto => true, + ShowScrollbar::System => cx + .try_global::() + .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 { fn binary_search(mut low: usize, mut high: usize, is_target: F) -> Option where @@ -555,53 +719,6 @@ impl GitPanel { } } - fn show_scrollbar(&self, cx: &mut Context) -> ShowScrollbar { - GitPanelSettings::get_global(cx) - .scrollbar - .show - .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show) - } - - fn should_show_scrollbar(&self, cx: &mut Context) -> 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) -> bool { - let show = self.show_scrollbar(cx); - match show { - ShowScrollbar::Auto => true, - ShowScrollbar::System => cx - .try_global::() - .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) { - 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) -> 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) -> Option> { - 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, + ) -> 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, + ) -> 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, color: Color) -> Label { @@ -3074,13 +3366,8 @@ impl GitPanel { window: &Window, cx: &Context, ) -> 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()