From 22c123868af5109309c36cff293e525633039d90 Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Tue, 31 Mar 2026 21:23:42 -0300
Subject: [PATCH] ui: Improve the `end_hover` method API in the `ListItem`
component (#52862)
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
---
assets/icons/maximize_alt.svg | 6 ++
crates/agent_ui/src/config_options.rs | 2 +-
.../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(-)
create mode 100644 assets/icons/maximize_alt.svg
diff --git a/assets/icons/maximize_alt.svg b/assets/icons/maximize_alt.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b8b8705f902c2469ed959f93f89ca3caf3b8fc51
--- /dev/null
+++ b/assets/icons/maximize_alt.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs
index b8cf7e5d57921c7710392911829fc2b5045a0f90..44c0baa232222c0ba7c1d54acdecaabacfa85f12 100644
--- a/crates/agent_ui/src/config_options.rs
+++ b/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 {
diff --git a/crates/agent_ui/src/ui/model_selector_components.rs b/crates/agent_ui/src/ui/model_selector_components.rs
index 01ba6c4511854e83b97b1fc053e41e5d0e82ff1e..88bf546a0e7beef53c8043fd04f8e6e9e5e92c88 100644
--- a/crates/agent_ui/src/ui/model_selector_components.rs
+++ b/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")
diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs
index 438df6839949d46d3ba8e0509995beb1300b7c80..83c8119a077ac1c024dbb3b3df948f762b072ec1 100644
--- a/crates/git_ui/src/branch_picker.rs
+++ b/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()
},
),
)
diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs
index 9987190f45b73f3f1132ce1295de6f412022abe2..2d3515e833e4d353c323f533f1f0f39bb1d76561 100644
--- a/crates/git_ui/src/stash_picker.rs
+++ b/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>) -> Option {
+ if self.matches.is_empty() {
+ return None;
+ }
+
let focus_handle = self.focus_handle.clone();
Some(
diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs
index 3e14b56f9bf4a95452855bc6cbef6f764e2b3530..c3e2259e411c7a3a56a36b92735f8d5e014e53d7 100644
--- a/crates/git_ui/src/worktree_picker.rs
+++ b/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()
}),
)
}
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index ad7faa6664d1ddc618c8984781a244af7dda6c97..6929ae4e4ca8ca0ee00c9793c948892043dd6dd6 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -174,6 +174,7 @@ pub enum IconName {
LockOutlined,
MagnifyingGlass,
Maximize,
+ MaximizeAlt,
Menu,
MenuAltTemp,
Mic,
diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs
index 4dc06036ef8416fd859cc815ab090ba5896c0040..22987f6c56669e1972a9bfc940449991d9f55642 100644
--- a/crates/recent_projects/src/recent_projects.rs
+++ b/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(),
)
}
diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs
index f7054687579155d4895ae191de1b7fa7cd14fbf6..26592a8035d50caa4e267a5478d5aceb9fba6e3e 100644
--- a/crates/recent_projects/src/remote_servers.rs
+++ b/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::(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| {
diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs
index 23e7b83772f755089c49f824719af389ec589bd9..7e5a56f22d48c4d51f60d7d200dc8384582beb23 100644
--- a/crates/rules_library/src/rules_library.rs
+++ b/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)
diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs
index 0fb13c85d21797e4d57728c88fc8bb014a898f78..d1e19ea4faee8d8259d06e2c24875faac7a0117c 100644
--- a/crates/tab_switcher/src/tab_switcher.rs
+++ b/crates/tab_switcher/src/tab_switcher.rs
@@ -875,7 +875,7 @@ impl PickerDelegate for TabSwitcherDelegate {
el.end_slot::(close_button)
} else {
el.end_slot::(indicator)
- .end_hover_slot::(close_button)
+ .end_slot_on_hover::(close_button)
}
}),
)
diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs
index 34f0cd809692d649bcfbabb7952f3075618ead04..285a07c9562849b26b4cbba3de3979614384d875 100644
--- a/crates/tasks_ui/src/modal.rs
+++ b/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
}
diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs
index 0d3efc024f1a202947fd0e7b0dab917c40ae8337..9a764efd58cfd3365d92e534a715a0f23ce46e90 100644
--- a/crates/ui/src/components/list/list_item.rs
+++ b/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,
- /// A slot for content that appears on hover after the children
- /// It will obscure the `end_slot` when visible.
- end_hover_slot: Option,
+ end_slot_visibility: EndSlotVisibility,
toggle: Option,
inset: bool,
on_click: Option>,
@@ -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(mut self, end_hover_slot: impl Into