From ca7de0fc5815e26c6836b373bb0c2cc7137baa7e Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Tue, 31 Mar 2026 19:29:07 -0300
Subject: [PATCH 01/22] sidebar: Add design adjustments to the thread import
feature (#52858)
- Adds a new icon for thread import
- Iterate on the design to access the import modal: it's now through a
button in the sidebar's footer, which only appears when the archive view
is toggled
- Fixed an issue where clicking on checkboxes within the import modal's
list items wouldn't do anything
Release Notes:
- N/A
---
assets/icons/thread_import.svg | 5 ++
crates/agent_ui/src/thread_import.rs | 49 +++++---------
crates/agent_ui/src/threads_archive_view.rs | 75 +--------------------
crates/icons/src/icons.rs | 1 +
crates/sidebar/src/sidebar.rs | 43 ++++++------
crates/ui/src/components/list/list_item.rs | 12 ++--
6 files changed, 52 insertions(+), 133 deletions(-)
create mode 100644 assets/icons/thread_import.svg
diff --git a/assets/icons/thread_import.svg b/assets/icons/thread_import.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a56b5a7cccc09c5795bfadff06f06d15833232f3
--- /dev/null
+++ b/assets/icons/thread_import.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs
index 9dd6b5efa0ae1cd3bc19dc6ae6a287218de8c668..f5fc89d3df4991ff5186e2af6d73ad6a840c09a1 100644
--- a/crates/agent_ui/src/thread_import.rs
+++ b/crates/agent_ui/src/thread_import.rs
@@ -121,18 +121,6 @@ impl ThreadImportModal {
.collect()
}
- fn set_agent_checked(&mut self, agent_id: AgentId, state: ToggleState, cx: &mut Context) {
- match state {
- ToggleState::Selected => {
- self.unchecked_agents.remove(&agent_id);
- }
- ToggleState::Unselected | ToggleState::Indeterminate => {
- self.unchecked_agents.insert(agent_id);
- }
- }
- cx.notify();
- }
-
fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context) {
if self.unchecked_agents.contains(&agent_id) {
self.unchecked_agents.remove(&agent_id);
@@ -283,6 +271,11 @@ impl ModalView for ThreadImportModal {}
impl Render for ThreadImportModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let has_agents = !self.agent_entries.is_empty();
+ let disabled_import_thread = self.is_importing
+ || !has_agents
+ || self.unchecked_agents.len() == self.agent_entries.len();
+
let agent_rows = self
.agent_entries
.iter()
@@ -295,6 +288,7 @@ impl Render for ThreadImportModal {
.rounded()
.spacing(ListItemSpacing::Sparse)
.focused(is_focused)
+ .disabled(self.is_importing)
.child(
h_flex()
.w_full()
@@ -311,22 +305,14 @@ impl Render for ThreadImportModal {
})
.child(Label::new(entry.display_name.clone())),
)
- .end_slot(
- Checkbox::new(
- ("thread-import-agent-checkbox", ix),
- if is_checked {
- ToggleState::Selected
- } else {
- ToggleState::Unselected
- },
- )
- .on_click({
- let agent_id = entry.agent_id.clone();
- cx.listener(move |this, state: &ToggleState, _window, cx| {
- this.set_agent_checked(agent_id.clone(), *state, cx);
- })
- }),
- )
+ .end_slot(Checkbox::new(
+ ("thread-import-agent-checkbox", ix),
+ if is_checked {
+ ToggleState::Selected
+ } else {
+ ToggleState::Unselected
+ },
+ ))
.on_click({
let agent_id = entry.agent_id.clone();
cx.listener(move |this, _event, _window, cx| {
@@ -336,11 +322,6 @@ impl Render for ThreadImportModal {
})
.collect::>();
- let has_agents = !self.agent_entries.is_empty();
- let disabled_import_thread = self.is_importing
- || !has_agents
- || self.unchecked_agents.len() == self.agent_entries.len();
-
v_flex()
.id("thread-import-modal")
.key_context("ThreadImportModal")
@@ -373,7 +354,7 @@ impl Render for ThreadImportModal {
v_flex()
.id("thread-import-agent-list")
.max_h(rems_from_px(320.))
- .pb_2()
+ .pb_1()
.overflow_y_scroll()
.when(has_agents, |this| this.children(agent_rows))
.when(!has_agents, |this| {
diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs
index 445d86c9ad4e37fa8b2502a754a5264cd1d4dc45..74a93129d387e0aaac6e7092d9e086dd64e369f7 100644
--- a/crates/agent_ui/src/threads_archive_view.rs
+++ b/crates/agent_ui/src/threads_archive_view.rs
@@ -1,5 +1,5 @@
use crate::agent_connection_store::AgentConnectionStore;
-use crate::thread_import::{AcpThreadImportOnboarding, ThreadImportModal};
+
use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
use crate::{Agent, RemoveSelectedThread};
@@ -15,15 +15,13 @@ use gpui::{
};
use itertools::Itertools as _;
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
-use project::{AgentId, AgentRegistryStore, AgentServerStore};
+use project::{AgentId, AgentServerStore};
use settings::Settings as _;
use theme::ActiveTheme;
use ui::ThreadItem;
use ui::{
Divider, KeyBinding, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height,
};
-use util::ResultExt;
-use workspace::{MultiWorkspace, Workspace};
use zed_actions::agents_sidebar::FocusSidebarFilter;
use zed_actions::editor::{MoveDown, MoveUp};
@@ -114,18 +112,12 @@ pub struct ThreadsArchiveView {
_refresh_history_task: Task<()>,
agent_connection_store: WeakEntity,
agent_server_store: WeakEntity,
- agent_registry_store: WeakEntity,
- workspace: WeakEntity,
- multi_workspace: WeakEntity,
}
impl ThreadsArchiveView {
pub fn new(
agent_connection_store: WeakEntity,
agent_server_store: WeakEntity,
- agent_registry_store: WeakEntity,
- workspace: WeakEntity,
- multi_workspace: WeakEntity,
window: &mut Window,
cx: &mut Context,
) -> Self {
@@ -184,11 +176,8 @@ impl ThreadsArchiveView {
thread_metadata_store_subscription,
],
_refresh_history_task: Task::ready(()),
- agent_registry_store,
agent_connection_store,
agent_server_store,
- workspace,
- multi_workspace,
};
this.update_items(cx);
@@ -550,43 +539,6 @@ impl ThreadsArchiveView {
.detach_and_log_err(cx);
}
- fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
- let has_external_agents = self
- .agent_server_store
- .upgrade()
- .map(|store| store.read(cx).has_external_agents())
- .unwrap_or(false);
-
- has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
- }
-
- fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context) {
- let Some(agent_server_store) = self.agent_server_store.upgrade() else {
- return;
- };
- let Some(agent_registry_store) = self.agent_registry_store.upgrade() else {
- return;
- };
-
- let workspace_handle = self.workspace.clone();
- let multi_workspace = self.multi_workspace.clone();
-
- self.workspace
- .update(cx, |workspace, cx| {
- workspace.toggle_modal(window, cx, |window, cx| {
- ThreadImportModal::new(
- agent_server_store,
- agent_registry_store,
- workspace_handle.clone(),
- multi_workspace.clone(),
- window,
- cx,
- )
- });
- })
- .log_err();
- }
-
fn render_header(&self, window: &Window, cx: &mut Context) -> impl IntoElement {
let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
let sidebar_on_left = matches!(
@@ -729,28 +681,5 @@ impl Render for ThreadsArchiveView {
.size_full()
.child(self.render_header(window, cx))
.child(content)
- .when(!self.should_render_acp_import_onboarding(cx), |this| {
- this.child(
- div()
- .w_full()
- .p_1p5()
- .border_t_1()
- .border_color(cx.theme().colors().border)
- .child(
- Button::new("import-acp", "Import ACP Threads")
- .full_width()
- .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
- .label_size(LabelSize::Small)
- .start_icon(
- Icon::new(IconName::ArrowDown)
- .size(IconSize::XSmall)
- .color(Color::Muted),
- )
- .on_click(cx.listener(|this, _, window, cx| {
- this.show_thread_import_modal(window, cx);
- })),
- ),
- )
- })
}
}
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index 400b2a22bc6071b62c6ce22a2b1bf1053c8cf871..ad7faa6664d1ddc618c8984781a244af7dda6c97 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -240,6 +240,7 @@ pub enum IconName {
ThinkingModeOff,
Thread,
ThreadFromSummary,
+ ThreadImport,
ThreadsSidebarLeftClosed,
ThreadsSidebarLeftOpen,
ThreadsSidebarRightClosed,
diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs
index 450a2674e0062d917003758c41c445048ee603f7..b1257b4c79c2ef193ec4594139cd1f57b93a5666 100644
--- a/crates/sidebar/src/sidebar.rs
+++ b/crates/sidebar/src/sidebar.rs
@@ -3309,10 +3309,24 @@ impl Sidebar {
}
fn render_sidebar_bottom_bar(&mut self, cx: &mut Context) -> impl IntoElement {
- let on_right = self.side(cx) == SidebarSide::Right;
let is_archive = matches!(self.view, SidebarView::Archive(..));
+ let show_import_button = is_archive && !self.should_render_acp_import_onboarding(cx);
+ let on_right = self.side(cx) == SidebarSide::Right;
+
let action_buttons = h_flex()
.gap_1()
+ .when(on_right, |this| this.flex_row_reverse())
+ .when(show_import_button, |this| {
+ this.child(
+ IconButton::new("thread-import", IconName::ThreadImport)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Import ACP Threads"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.show_archive(window, cx);
+ this.show_thread_import_modal(window, cx);
+ })),
+ )
+ })
.child(
IconButton::new("archive", IconName::Archive)
.icon_size(IconSize::Small)
@@ -3325,21 +3339,16 @@ impl Sidebar {
})),
)
.child(self.render_recent_projects_button(cx));
- let border_color = cx.theme().colors().border;
- let toggle_button = self.render_sidebar_toggle_button(cx);
- let bar = h_flex()
+ h_flex()
.p_1()
.gap_1()
+ .when(on_right, |this| this.flex_row_reverse())
.justify_between()
.border_t_1()
- .border_color(border_color);
-
- if on_right {
- bar.child(action_buttons).child(toggle_button)
- } else {
- bar.child(toggle_button).child(action_buttons)
- }
+ .border_color(cx.theme().colors().border)
+ .child(self.render_sidebar_toggle_button(cx))
+ .child(action_buttons)
}
fn active_workspace(&self, cx: &App) -> Option> {
@@ -3409,7 +3418,7 @@ impl Sidebar {
v_flex()
.min_w_0()
.w_full()
- .p_1p5()
+ .p_2()
.border_t_1()
.border_color(cx.theme().colors().border)
.bg(linear_gradient(
@@ -3437,8 +3446,8 @@ impl Sidebar {
.style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
.label_size(LabelSize::Small)
.start_icon(
- Icon::new(IconName::ArrowDown)
- .size(IconSize::XSmall)
+ Icon::new(IconName::ThreadImport)
+ .size(IconSize::Small)
.color(Color::Muted),
)
.on_click(cx.listener(|this, _, window, cx| {
@@ -3467,9 +3476,6 @@ impl Sidebar {
let Some(agent_panel) = active_workspace.read(cx).panel::(cx) else {
return;
};
- let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
- return;
- };
let agent_server_store = active_workspace
.read(cx)
@@ -3484,9 +3490,6 @@ impl Sidebar {
ThreadsArchiveView::new(
agent_connection_store.clone(),
agent_server_store.clone(),
- agent_registry_store.downgrade(),
- active_workspace.downgrade(),
- self.multi_workspace.clone(),
window,
cx,
)
diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs
index 693cf3d52e34369d04db445d1ddac765691fb429..0d3efc024f1a202947fd0e7b0dab917c40ae8337 100644
--- a/crates/ui/src/components/list/list_item.rs
+++ b/crates/ui/src/components/list/list_item.rs
@@ -234,9 +234,9 @@ impl RenderOnce for ListItem {
this.ml(self.indent_level as f32 * self.indent_step_size)
.px(DynamicSpacing::Base04.rems(cx))
})
- .when(!self.inset && !self.disabled, |this| {
+ .when(!self.inset, |this| {
this.when_some(self.focused, |this, focused| {
- if focused {
+ if focused && !self.disabled {
this.border_1()
.when(self.docked_right, |this| this.border_r_2())
.border_color(cx.theme().colors().border_focused)
@@ -244,7 +244,7 @@ impl RenderOnce for ListItem {
this.border_1()
}
})
- .when(self.selectable, |this| {
+ .when(self.selectable && !self.disabled, |this| {
this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
.active(|style| style.bg(cx.theme().colors().ghost_element_active))
.when(self.outlined, |this| this.rounded_sm())
@@ -268,16 +268,16 @@ impl RenderOnce for ListItem {
ListItemSpacing::ExtraDense => this.py_neg_px(),
ListItemSpacing::Sparse => this.py_1(),
})
- .when(self.inset && !self.disabled, |this| {
+ .when(self.inset, |this| {
this.when_some(self.focused, |this, focused| {
- if focused {
+ if focused && !self.disabled {
this.border_1()
.border_color(cx.theme().colors().border_focused)
} else {
this.border_1()
}
})
- .when(self.selectable, |this| {
+ .when(self.selectable && !self.disabled, |this| {
this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
.active(|style| style.bg(cx.theme().colors().ghost_element_active))
.when(self.selected, |this| {
From a12601f8631070ee2474783b9bfb61bca539a574 Mon Sep 17 00:00:00 2001
From: Eric Holk
Date: Tue, 31 Mar 2026 16:23:21 -0700
Subject: [PATCH 02/22] workspace: Break workspace tests into their own file
(#52854)
Splits the tests out of `multi_workspace.rs` into a dedicated
`multi_workspace_tests.rs` file for better organization.
Release Notes:
- N/A
Co-authored-by: Max Brunsfeld
---
crates/workspace/src/multi_workspace.rs | 175 ------------------
crates/workspace/src/multi_workspace_tests.rs | 172 +++++++++++++++++
crates/workspace/src/workspace.rs | 2 +
3 files changed, 174 insertions(+), 175 deletions(-)
create mode 100644 crates/workspace/src/multi_workspace_tests.rs
diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs
index 862f7c7b267721833fa395e501b604d30745a1b7..10a5ce70ead2d5aea7cc21a9af53ee9f216859c3 100644
--- a/crates/workspace/src/multi_workspace.rs
+++ b/crates/workspace/src/multi_workspace.rs
@@ -1113,178 +1113,3 @@ impl Render for MultiWorkspace {
)
}
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use fs::FakeFs;
- use gpui::TestAppContext;
- use settings::SettingsStore;
-
- fn init_test(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = SettingsStore::test(cx);
- cx.set_global(settings_store);
- theme_settings::init(theme::LoadThemes::JustBase, cx);
- DisableAiSettings::register(cx);
- cx.update_flags(false, vec!["agent-v2".into()]);
- });
- }
-
- #[gpui::test]
- async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
- init_test(cx);
- let fs = FakeFs::new(cx.executor());
- let project = Project::test(fs, [], cx).await;
-
- let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-
- multi_workspace.read_with(cx, |mw, cx| {
- assert!(mw.multi_workspace_enabled(cx));
- });
-
- multi_workspace.update_in(cx, |mw, _window, cx| {
- mw.open_sidebar(cx);
- assert!(mw.sidebar_open());
- });
-
- cx.update(|_window, cx| {
- DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
- });
- cx.run_until_parked();
-
- multi_workspace.read_with(cx, |mw, cx| {
- assert!(
- !mw.sidebar_open(),
- "Sidebar should be closed when disable_ai is true"
- );
- assert!(
- !mw.multi_workspace_enabled(cx),
- "Multi-workspace should be disabled when disable_ai is true"
- );
- });
-
- multi_workspace.update_in(cx, |mw, window, cx| {
- mw.toggle_sidebar(window, cx);
- });
- multi_workspace.read_with(cx, |mw, _cx| {
- assert!(
- !mw.sidebar_open(),
- "Sidebar should remain closed when toggled with disable_ai true"
- );
- });
-
- cx.update(|_window, cx| {
- DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
- });
- cx.run_until_parked();
-
- multi_workspace.read_with(cx, |mw, cx| {
- assert!(
- mw.multi_workspace_enabled(cx),
- "Multi-workspace should be enabled after re-enabling AI"
- );
- assert!(
- !mw.sidebar_open(),
- "Sidebar should still be closed after re-enabling AI (not auto-opened)"
- );
- });
-
- multi_workspace.update_in(cx, |mw, window, cx| {
- mw.toggle_sidebar(window, cx);
- });
- multi_workspace.read_with(cx, |mw, _cx| {
- assert!(
- mw.sidebar_open(),
- "Sidebar should open when toggled after re-enabling AI"
- );
- });
- }
-
- #[gpui::test]
- async fn test_replace(cx: &mut TestAppContext) {
- init_test(cx);
- let fs = FakeFs::new(cx.executor());
- let project_a = Project::test(fs.clone(), [], cx).await;
- let project_b = Project::test(fs.clone(), [], cx).await;
- let project_c = Project::test(fs.clone(), [], cx).await;
- let project_d = Project::test(fs.clone(), [], cx).await;
-
- let (multi_workspace, cx) = cx
- .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
-
- let workspace_a_id =
- multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id());
-
- // Replace the only workspace (single-workspace case).
- let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
- let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
- mw.replace(workspace.clone(), &*window, cx);
- workspace
- });
-
- multi_workspace.read_with(cx, |mw, _cx| {
- assert_eq!(mw.workspaces().len(), 1);
- assert_eq!(
- mw.workspaces()[0].entity_id(),
- workspace_b.entity_id(),
- "slot should now be project_b"
- );
- assert_ne!(
- mw.workspaces()[0].entity_id(),
- workspace_a_id,
- "project_a should be gone"
- );
- });
-
- // Add project_c as a second workspace, then replace it with project_d.
- let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
- mw.test_add_workspace(project_c.clone(), window, cx)
- });
-
- multi_workspace.read_with(cx, |mw, _cx| {
- assert_eq!(mw.workspaces().len(), 2);
- assert_eq!(mw.active_workspace_index(), 1);
- });
-
- let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| {
- let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx));
- mw.replace(workspace.clone(), &*window, cx);
- workspace
- });
-
- multi_workspace.read_with(cx, |mw, _cx| {
- assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces");
- assert_eq!(mw.active_workspace_index(), 1);
- assert_eq!(
- mw.workspaces()[1].entity_id(),
- workspace_d.entity_id(),
- "active slot should now be project_d"
- );
- assert_ne!(
- mw.workspaces()[1].entity_id(),
- workspace_c.entity_id(),
- "project_c should be gone"
- );
- });
-
- // Replace with workspace_b which is already in the list — should just switch.
- multi_workspace.update_in(cx, |mw, window, cx| {
- mw.replace(workspace_b.clone(), &*window, cx);
- });
-
- multi_workspace.read_with(cx, |mw, _cx| {
- assert_eq!(
- mw.workspaces().len(),
- 2,
- "no workspace should be added or removed"
- );
- assert_eq!(
- mw.active_workspace_index(),
- 0,
- "should have switched to workspace_b"
- );
- });
- }
-}
diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs
new file mode 100644
index 0000000000000000000000000000000000000000..50161121719ec7b2835fd11e389f24860e57d8f5
--- /dev/null
+++ b/crates/workspace/src/multi_workspace_tests.rs
@@ -0,0 +1,172 @@
+use super::*;
+use feature_flags::FeatureFlagAppExt;
+use fs::FakeFs;
+use gpui::TestAppContext;
+use project::DisableAiSettings;
+use settings::SettingsStore;
+
+fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ theme_settings::init(theme::LoadThemes::JustBase, cx);
+ DisableAiSettings::register(cx);
+ cx.update_flags(false, vec!["agent-v2".into()]);
+ });
+}
+
+#[gpui::test]
+async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+ multi_workspace.read_with(cx, |mw, cx| {
+ assert!(mw.multi_workspace_enabled(cx));
+ });
+
+ multi_workspace.update_in(cx, |mw, _window, cx| {
+ mw.open_sidebar(cx);
+ assert!(mw.sidebar_open());
+ });
+
+ cx.update(|_window, cx| {
+ DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
+ });
+ cx.run_until_parked();
+
+ multi_workspace.read_with(cx, |mw, cx| {
+ assert!(
+ !mw.sidebar_open(),
+ "Sidebar should be closed when disable_ai is true"
+ );
+ assert!(
+ !mw.multi_workspace_enabled(cx),
+ "Multi-workspace should be disabled when disable_ai is true"
+ );
+ });
+
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.toggle_sidebar(window, cx);
+ });
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert!(
+ !mw.sidebar_open(),
+ "Sidebar should remain closed when toggled with disable_ai true"
+ );
+ });
+
+ cx.update(|_window, cx| {
+ DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
+ });
+ cx.run_until_parked();
+
+ multi_workspace.read_with(cx, |mw, cx| {
+ assert!(
+ mw.multi_workspace_enabled(cx),
+ "Multi-workspace should be enabled after re-enabling AI"
+ );
+ assert!(
+ !mw.sidebar_open(),
+ "Sidebar should still be closed after re-enabling AI (not auto-opened)"
+ );
+ });
+
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.toggle_sidebar(window, cx);
+ });
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert!(
+ mw.sidebar_open(),
+ "Sidebar should open when toggled after re-enabling AI"
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_replace(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ let project_a = Project::test(fs.clone(), [], cx).await;
+ let project_b = Project::test(fs.clone(), [], cx).await;
+ let project_c = Project::test(fs.clone(), [], cx).await;
+ let project_d = Project::test(fs.clone(), [], cx).await;
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+
+ let workspace_a_id = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id());
+
+ // Replace the only workspace (single-workspace case).
+ let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+ let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
+ mw.replace(workspace.clone(), &*window, cx);
+ workspace
+ });
+
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert_eq!(mw.workspaces().len(), 1);
+ assert_eq!(
+ mw.workspaces()[0].entity_id(),
+ workspace_b.entity_id(),
+ "slot should now be project_b"
+ );
+ assert_ne!(
+ mw.workspaces()[0].entity_id(),
+ workspace_a_id,
+ "project_a should be gone"
+ );
+ });
+
+ // Add project_c as a second workspace, then replace it with project_d.
+ let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(project_c.clone(), window, cx)
+ });
+
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert_eq!(mw.workspaces().len(), 2);
+ assert_eq!(mw.active_workspace_index(), 1);
+ });
+
+ let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| {
+ let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx));
+ mw.replace(workspace.clone(), &*window, cx);
+ workspace
+ });
+
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces");
+ assert_eq!(mw.active_workspace_index(), 1);
+ assert_eq!(
+ mw.workspaces()[1].entity_id(),
+ workspace_d.entity_id(),
+ "active slot should now be project_d"
+ );
+ assert_ne!(
+ mw.workspaces()[1].entity_id(),
+ workspace_c.entity_id(),
+ "project_c should be gone"
+ );
+ });
+
+ // Replace with workspace_b which is already in the list — should just switch.
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.replace(workspace_b.clone(), &*window, cx);
+ });
+
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert_eq!(
+ mw.workspaces().len(),
+ 2,
+ "no workspace should be added or removed"
+ );
+ assert_eq!(
+ mw.active_workspace_index(),
+ 0,
+ "should have switched to workspace_b"
+ );
+ });
+}
diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs
index 33d1befe38e3b48a39377547bd433398b37d6a77..ae05c2c59012b2caf217ac54a80b377aee87f09d 100644
--- a/crates/workspace/src/workspace.rs
+++ b/crates/workspace/src/workspace.rs
@@ -5,6 +5,8 @@ pub mod invalid_item_view;
pub mod item;
mod modal_layer;
mod multi_workspace;
+#[cfg(test)]
+mod multi_workspace_tests;
pub mod notifications;
pub mod pane;
pub mod pane_group;
From 20b140664d3b003ef4b23c03384f40c7c57aea06 Mon Sep 17 00:00:00 2001
From: Mikayla Maki
Date: Tue, 31 Mar 2026 17:10:29 -0700
Subject: [PATCH 03/22] Fix markdown table rendering in the agent panel
(#52864)
This PR reverts part of
https://github.com/zed-industries/zed/pull/50839, as it was causing bad
clipping in the agent panel
Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Closes #ISSUE
Release Notes:
- N/A
---
crates/markdown/src/markdown.rs | 10 ++--------
1 file changed, 2 insertions(+), 8 deletions(-)
diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs
index 7b95688df54610f92b6960d9afc3037bf484b8ed..024e377c2538214c9579c8f025250e2166cf7ace 100644
--- a/crates/markdown/src/markdown.rs
+++ b/crates/markdown/src/markdown.rs
@@ -1609,23 +1609,18 @@ impl Element for MarkdownElement {
builder.table.start(alignments.clone());
let column_count = alignments.len();
- builder.push_div(
- div().flex().flex_col().items_start(),
- range,
- markdown_end,
- );
builder.push_div(
div()
.id(("table", range.start))
- .min_w_0()
.grid()
.grid_cols(column_count as u16)
.when(self.style.table_columns_min_size, |this| {
this.grid_cols_min_content(column_count as u16)
})
.when(!self.style.table_columns_min_size, |this| {
- this.grid_cols_max_content(column_count as u16)
+ this.grid_cols(column_count as u16)
})
+ .w_full()
.mb_2()
.border(px(1.5))
.border_color(cx.theme().colors().border)
@@ -1770,7 +1765,6 @@ impl Element for MarkdownElement {
}
}
MarkdownTagEnd::Table => {
- builder.pop_div();
builder.pop_div();
builder.table.end();
}
From 6837c8aa6bc314cd02e44f71e9377f3eada7120f Mon Sep 17 00:00:00 2001
From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com>
Date: Tue, 31 Mar 2026 20:20:24 -0400
Subject: [PATCH 04/22] git_graph: Fix empty search case (#52845)
This fixes a bug where search would match all commits if there was an empty query instead of setting the query to None
Self-Review Checklist:
- [ ] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable
Closes #ISSUE
Release Notes:
- N/A or Added/Fixed/Improved ...
---------
Co-authored-by: Remco Smits
---
crates/git_graph/src/git_graph.rs | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs
index b971566075181350453b28bf9909371e51436021..d169ba686098dddd4881915ece11c8a97148affa 100644
--- a/crates/git_graph/src/git_graph.rs
+++ b/crates/git_graph/src/git_graph.rs
@@ -1324,6 +1324,12 @@ impl GitGraph {
editor.set_text_style_refinement(Default::default());
});
+ if query.as_str().is_empty() {
+ self.search_state.state = QueryState::Empty;
+ cx.notify();
+ return;
+ }
+
let (request_tx, request_rx) = smol::channel::unbounded::();
repo.update(cx, |repo, cx| {
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 05/22] 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