ui: Improve the `end_hover` method API in the `ListItem` component (#52862)

Danilo Leal created

This PR changes the API for the `ListItem`'s `end_hover` slot so that
whatever is in there is always part of the flex stack, as opposed to an
absolutely-positioned element. Additionally, I'm also improving the API
for swapping content between the default state and the hovered state
(e.g., list items where by default we render X, but when you hover, we
show something else). Lastly, I'm adding buttons to some Git picker
items that were only previously available through modal footer buttons.

Release Notes:

- N/A

Change summary

assets/icons/maximize_alt.svg                       |  6 +
crates/agent_ui/src/config_options.rs               |  2 
crates/agent_ui/src/ui/model_selector_components.rs |  2 
crates/git_ui/src/branch_picker.rs                  | 18 ---
crates/git_ui/src/stash_picker.rs                   | 54 +++++++++---
crates/git_ui/src/worktree_picker.rs                | 30 +++++-
crates/icons/src/icons.rs                           |  1 
crates/recent_projects/src/recent_projects.rs       | 27 +-----
crates/recent_projects/src/remote_servers.rs        | 40 ++++----
crates/rules_library/src/rules_library.rs           |  2 
crates/tab_switcher/src/tab_switcher.rs             |  2 
crates/tasks_ui/src/modal.rs                        |  2 
crates/ui/src/components/list/list_item.rs          | 63 +++++++++-----
13 files changed, 144 insertions(+), 105 deletions(-)

Detailed changes

assets/icons/maximize_alt.svg 🔗

@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2.25 4.80555V3.52777C2.25 3.18889 2.38462 2.86388 2.62425 2.62425C2.86388 2.38462 3.18889 2.25 3.52777 2.25H4.80555" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.1945 2.25H12.4722C12.8111 2.25 13.1361 2.38462 13.3758 2.62425C13.6154 2.86388 13.75 3.18889 13.75 3.52777V4.80555" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.75 11.1945V12.4722C13.75 12.8111 13.6154 13.1361 13.3758 13.3758C13.1361 13.6154 12.8111 13.75 12.4722 13.75H11.1945" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.80555 13.75H3.52777C3.18889 13.75 2.86388 13.6154 2.62425 13.3758C2.38462 13.1361 2.25 12.8111 2.25 12.4722V11.1945" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

crates/agent_ui/src/config_options.rs 🔗

@@ -650,7 +650,7 @@ impl PickerDelegate for ConfigOptionPickerDelegate {
                                 .end_slot(div().pr_2().when(is_selected, |this| {
                                     this.child(Icon::new(IconName::Check).color(Color::Accent))
                                 }))
-                                .end_hover_slot(div().pr_1p5().child({
+                                .end_slot_on_hover(div().pr_1p5().child({
                                     let (icon, color, tooltip) = if is_favorite {
                                         (IconName::StarFilled, Color::Accent, "Unfavorite")
                                     } else {

crates/agent_ui/src/ui/model_selector_components.rs 🔗

@@ -160,7 +160,7 @@ impl RenderOnce for ModelSelectorListItem {
             .end_slot(div().pr_2().when(self.is_selected, |this| {
                 this.child(Icon::new(IconName::Check).color(Color::Accent))
             }))
-            .end_hover_slot(div().pr_1p5().when_some(self.on_toggle_favorite, {
+            .end_slot_on_hover(div().pr_1p5().when_some(self.on_toggle_favorite, {
                 |this, handle_click| {
                     let (icon, color, tooltip) = if is_favorite {
                         (IconName::StarFilled, Color::Accent, "Unfavorite Model")

crates/git_ui/src/branch_picker.rs 🔗

@@ -1087,13 +1087,8 @@ impl PickerDelegate for BranchListDelegate {
                         ),
                 )
                 .when(!is_new_items && !is_head_branch, |this| {
-                    this.map(|this| {
-                        if self.selected_index() == ix {
-                            this.end_slot(deleted_branch_icon(ix))
-                        } else {
-                            this.end_hover_slot(deleted_branch_icon(ix))
-                        }
-                    })
+                    this.end_slot(deleted_branch_icon(ix))
+                        .show_end_slot_on_hover()
                 })
                 .when_some(
                     if is_new_items {
@@ -1102,13 +1097,8 @@ impl PickerDelegate for BranchListDelegate {
                         None
                     },
                     |this, create_from_default_button| {
-                        this.map(|this| {
-                            if self.selected_index() == ix {
-                                this.end_slot(create_from_default_button)
-                            } else {
-                                this.end_hover_slot(create_from_default_button)
-                            }
-                        })
+                        this.end_slot(create_from_default_button)
+                            .show_end_slot_on_hover()
                     },
                 ),
         )

crates/git_ui/src/stash_picker.rs 🔗

@@ -501,16 +501,39 @@ impl PickerDelegate for StashListDelegate {
                     .size(LabelSize::Small),
             );
 
-        let focus_handle = self.focus_handle.clone();
+        let view_button = {
+            let focus_handle = self.focus_handle.clone();
+            IconButton::new(("view-stash", ix), IconName::Eye)
+                .icon_size(IconSize::Small)
+                .tooltip(move |_, cx| {
+                    Tooltip::for_action_in("View Stash", &ShowStashItem, &focus_handle, cx)
+                })
+                .on_click(cx.listener(move |this, _, window, cx| {
+                    this.delegate.show_stash_at(ix, window, cx);
+                }))
+        };
+
+        let pop_button = {
+            let focus_handle = self.focus_handle.clone();
+            IconButton::new(("pop-stash", ix), IconName::MaximizeAlt)
+                .icon_size(IconSize::Small)
+                .tooltip(move |_, cx| {
+                    Tooltip::for_action_in("Pop Stash", &menu::SecondaryConfirm, &focus_handle, cx)
+                })
+                .on_click(|_, window, cx| {
+                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
+                })
+        };
 
-        let drop_button = |entry_ix: usize| {
-            IconButton::new(("drop-stash", entry_ix), IconName::Trash)
+        let drop_button = {
+            let focus_handle = self.focus_handle.clone();
+            IconButton::new(("drop-stash", ix), IconName::Trash)
                 .icon_size(IconSize::Small)
                 .tooltip(move |_, cx| {
                     Tooltip::for_action_in("Drop Stash", &DropStashItem, &focus_handle, cx)
                 })
                 .on_click(cx.listener(move |this, _, window, cx| {
-                    this.delegate.drop_stash_at(entry_ix, window, cx);
+                    this.delegate.drop_stash_at(ix, window, cx);
                 }))
         };
 
@@ -530,17 +553,14 @@ impl PickerDelegate for StashListDelegate {
                         )
                         .child(div().w_full().child(stash_label).child(branch_info)),
                 )
-                .tooltip(Tooltip::text(format!(
-                    "stash@{{{}}}",
-                    entry_match.entry.index
-                )))
-                .map(|this| {
-                    if selected {
-                        this.end_slot(drop_button(ix))
-                    } else {
-                        this.end_hover_slot(drop_button(ix))
-                    }
-                }),
+                .end_slot(
+                    h_flex()
+                        .gap_0p5()
+                        .child(view_button)
+                        .child(pop_button)
+                        .child(drop_button),
+                )
+                .show_end_slot_on_hover(),
         )
     }
 
@@ -549,6 +569,10 @@ impl PickerDelegate for StashListDelegate {
     }
 
     fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
+        if self.matches.is_empty() {
+            return None;
+        }
+
         let focus_handle = self.focus_handle.clone();
 
         Some(

crates/git_ui/src/worktree_picker.rs 🔗

@@ -884,12 +884,30 @@ impl PickerDelegate for WorktreeListDelegate {
                             }
                         })),
                 )
-                .when(can_delete, |this| {
-                    if selected {
-                        this.end_slot(delete_button(ix))
-                    } else {
-                        this.end_hover_slot(delete_button(ix))
-                    }
+                .when(!entry.is_new, |this| {
+                    let focus_handle = self.focus_handle.clone();
+                    let open_in_new_window_button =
+                        IconButton::new(("open-new-window", ix), IconName::ArrowUpRight)
+                            .icon_size(IconSize::Small)
+                            .tooltip(move |_, cx| {
+                                Tooltip::for_action_in(
+                                    "Open in New Window",
+                                    &menu::SecondaryConfirm,
+                                    &focus_handle,
+                                    cx,
+                                )
+                            })
+                            .on_click(|_, window, cx| {
+                                window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
+                            });
+
+                    this.end_slot(
+                        h_flex()
+                            .gap_0p5()
+                            .child(open_in_new_window_button)
+                            .when(can_delete, |this| this.child(delete_button(ix))),
+                    )
+                    .show_end_slot_on_hover()
                 }),
         )
     }

crates/icons/src/icons.rs 🔗

@@ -174,6 +174,7 @@ pub enum IconName {
     LockOutlined,
     MagnifyingGlass,
     Maximize,
+    MaximizeAlt,
     Menu,
     MenuAltTemp,
     Mic,

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1264,13 +1264,8 @@ impl PickerDelegate for RecentProjectsDelegate {
                                     this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
                                 }),
                         )
-                        .map(|el| {
-                            if self.selected_index == ix {
-                                el.end_slot(secondary_actions)
-                            } else {
-                                el.end_hover_slot(secondary_actions)
-                            }
-                        })
+                        .end_slot(secondary_actions)
+                        .show_end_slot_on_hover()
                         .into_any_element(),
                 )
             }
@@ -1363,13 +1358,8 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 })
                                 .tooltip(Tooltip::text(tooltip_path)),
                         )
-                        .map(|el| {
-                            if self.selected_index == ix {
-                                el.end_slot(secondary_actions)
-                            } else {
-                                el.end_hover_slot(secondary_actions)
-                            }
-                        })
+                        .end_slot(secondary_actions)
+                        .show_end_slot_on_hover()
                         .into_any_element(),
                 )
             }
@@ -1503,13 +1493,8 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 })
                                 .tooltip(Tooltip::text(tooltip_path)),
                         )
-                        .map(|el| {
-                            if self.selected_index == ix {
-                                el.end_slot(secondary_actions)
-                            } else {
-                                el.end_hover_slot(secondary_actions)
-                            }
-                        })
+                        .end_slot(secondary_actions)
+                        .show_end_slot_on_hover()
                         .into_any_element(),
                 )
             }

crates/recent_projects/src/remote_servers.rs 🔗

@@ -1621,23 +1621,24 @@ impl RemoteServerProjects {
                     }))
                     .tooltip(Tooltip::text(project.paths.join("\n")))
                     .when(is_from_zed, |server_list_item| {
-                        server_list_item.end_hover_slot::<AnyElement>(Some(
-                            div()
-                                .mr_2()
-                                .child({
-                                    let project = project.clone();
-                                    // Right-margin to offset it from the Scrollbar
-                                    IconButton::new("remove-remote-project", IconName::Trash)
-                                        .icon_size(IconSize::Small)
-                                        .shape(IconButtonShape::Square)
-                                        .size(ButtonSize::Large)
-                                        .tooltip(Tooltip::text("Delete Remote Project"))
-                                        .on_click(cx.listener(move |this, _, _, cx| {
-                                            this.delete_remote_project(server_ix, &project, cx)
-                                        }))
-                                })
-                                .into_any_element(),
-                        ))
+                        server_list_item
+                            .end_slot(
+                                div()
+                                    .mr_2()
+                                    .child({
+                                        let project = project.clone();
+                                        IconButton::new("remove-remote-project", IconName::Trash)
+                                            .icon_size(IconSize::Small)
+                                            .shape(IconButtonShape::Square)
+                                            .size(ButtonSize::Large)
+                                            .tooltip(Tooltip::text("Delete Remote Project"))
+                                            .on_click(cx.listener(move |this, _, _, cx| {
+                                                this.delete_remote_project(server_ix, &project, cx)
+                                            }))
+                                    })
+                                    .into_any_element(),
+                            )
+                            .show_end_slot_on_hover()
                     }),
             )
     }
@@ -2413,9 +2414,8 @@ impl RemoteServerProjects {
                             .spacing(ui::ListItemSpacing::Sparse)
                             .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
                             .child(Label::new("Copy Server Address"))
-                            .end_hover_slot(
-                                Label::new(connection_string.clone()).color(Color::Muted),
-                            )
+                            .end_slot(Label::new(connection_string.clone()).color(Color::Muted))
+                            .show_end_slot_on_hover()
                             .on_click({
                                 let connection_string = connection_string.clone();
                                 move |_, _, cx| {

crates/rules_library/src/rules_library.rs 🔗

@@ -389,7 +389,7 @@ impl PickerDelegate for RulePickerDelegate {
                                 }))
                         }))
                         .when(!prompt_id.is_built_in(), |this| {
-                            this.end_hover_slot(
+                            this.end_slot_on_hover(
                                 h_flex()
                                     .child(
                                         IconButton::new("delete-rule", IconName::Trash)

crates/tab_switcher/src/tab_switcher.rs 🔗

@@ -875,7 +875,7 @@ impl PickerDelegate for TabSwitcherDelegate {
                         el.end_slot::<AnyElement>(close_button)
                     } else {
                         el.end_slot::<AnyElement>(indicator)
-                            .end_hover_slot::<AnyElement>(close_button)
+                            .end_slot_on_hover::<AnyElement>(close_button)
                     }
                 }),
         )

crates/tasks_ui/src/modal.rs 🔗

@@ -570,7 +570,7 @@ impl PickerDelegate for TasksModalDelegate {
                                     Tooltip::simple("Delete Previously Scheduled Task", cx)
                                 }),
                         );
-                        item.end_hover_slot(delete_button)
+                        item.end_slot_on_hover(delete_button)
                     } else {
                         item
                     }

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

@@ -14,6 +14,14 @@ pub enum ListItemSpacing {
     Sparse,
 }
 
+#[derive(Default)]
+enum EndSlotVisibility {
+    #[default]
+    Always,
+    OnHover,
+    SwapOnHover(AnyElement),
+}
+
 #[derive(IntoElement, RegisterComponent)]
 pub struct ListItem {
     id: ElementId,
@@ -28,9 +36,7 @@ pub struct ListItem {
     /// A slot for content that appears after the children, usually on the other side of the header.
     /// This might be a button, a disclosure arrow, a face pile, etc.
     end_slot: Option<AnyElement>,
-    /// A slot for content that appears on hover after the children
-    /// It will obscure the `end_slot` when visible.
-    end_hover_slot: Option<AnyElement>,
+    end_slot_visibility: EndSlotVisibility,
     toggle: Option<bool>,
     inset: bool,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@@ -61,7 +67,7 @@ impl ListItem {
             indent_step_size: px(12.),
             start_slot: None,
             end_slot: None,
-            end_hover_slot: None,
+            end_slot_visibility: EndSlotVisibility::default(),
             toggle: None,
             inset: false,
             on_click: None,
@@ -165,8 +171,14 @@ impl ListItem {
         self
     }
 
-    pub fn end_hover_slot<E: IntoElement>(mut self, end_hover_slot: impl Into<Option<E>>) -> Self {
-        self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element);
+    pub fn end_slot_on_hover<E: IntoElement>(mut self, end_slot_on_hover: E) -> Self {
+        self.end_slot_visibility =
+            EndSlotVisibility::SwapOnHover(end_slot_on_hover.into_any_element());
+        self
+    }
+
+    pub fn show_end_slot_on_hover(mut self) -> Self {
+        self.end_slot_visibility = EndSlotVisibility::OnHover;
         self
     }
 
@@ -338,28 +350,31 @@ impl RenderOnce for ListItem {
                             .children(self.start_slot)
                             .children(self.children),
                     )
+                    .when(self.end_slot.is_some(), |this| this.justify_between())
                     .when_some(self.end_slot, |this, end_slot| {
-                        this.justify_between().child(
-                            h_flex()
+                        this.child(match self.end_slot_visibility {
+                            EndSlotVisibility::Always => {
+                                h_flex().flex_shrink().overflow_hidden().child(end_slot)
+                            }
+                            EndSlotVisibility::OnHover => h_flex()
                                 .flex_shrink()
                                 .overflow_hidden()
-                                .when(self.end_hover_slot.is_some(), |this| {
-                                    this.visible()
-                                        .group_hover("list_item", |this| this.invisible())
-                                })
-                                .child(end_slot),
-                        )
-                    })
-                    .when_some(self.end_hover_slot, |this, end_hover_slot| {
-                        this.child(
-                            h_flex()
-                                .h_full()
-                                .absolute()
-                                .right(DynamicSpacing::Base06.rems(cx))
-                                .top_0()
                                 .visible_on_hover("list_item")
-                                .child(end_hover_slot),
-                        )
+                                .child(end_slot),
+                            EndSlotVisibility::SwapOnHover(hover_slot) => h_flex()
+                                .relative()
+                                .flex_shrink()
+                                .child(h_flex().visible_on_hover("list_item").child(hover_slot))
+                                .child(
+                                    h_flex()
+                                        .absolute()
+                                        .inset_0()
+                                        .justify_end()
+                                        .overflow_hidden()
+                                        .group_hover("list_item", |this| this.invisible())
+                                        .child(end_slot),
+                                ),
+                        })
                     }),
             )
     }