Channel joining (#3428)

Conrad Irwin created

- Remove debugging
- Basic channel joining!

[[PR Description]]

Release Notes:

- N/A

Change summary

crates/call2/src/call2.rs                     |  51 +++++
crates/collab2/src/tests/channel_tests.rs     |  37 ++-
crates/collab2/src/tests/integration_tests.rs |  11 
crates/collab_ui2/src/collab_panel.rs         | 171 ++++++++++----------
crates/ui2/src/components/disclosure.rs       |  25 ++
crates/ui2/src/components/icon_button.rs      |  15 +
crates/ui2/src/components/list.rs             |  16 +
7 files changed, 211 insertions(+), 115 deletions(-)

Detailed changes

crates/call2/src/call2.rs 🔗

@@ -14,8 +14,8 @@ use client::{
 use collections::HashSet;
 use futures::{channel::oneshot, future::Shared, Future, FutureExt};
 use gpui::{
-    AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Subscription, Task,
-    View, ViewContext, VisualContext, WeakModel, WeakView,
+    AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, PromptLevel,
+    Subscription, Task, View, ViewContext, VisualContext, WeakModel, WeakView, WindowHandle,
 };
 pub use participant::ParticipantLocation;
 use postage::watch;
@@ -334,12 +334,55 @@ impl ActiveCall {
     pub fn join_channel(
         &mut self,
         channel_id: u64,
+        requesting_window: Option<WindowHandle<Workspace>>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<Option<Model<Room>>>> {
         if let Some(room) = self.room().cloned() {
             if room.read(cx).channel_id() == Some(channel_id) {
-                return Task::ready(Ok(Some(room)));
-            } else {
+                return cx.spawn(|_, _| async move {
+                    todo!();
+                    // let future = room.update(&mut cx, |room, cx| {
+                    //     room.most_active_project(cx).map(|(host, project)| {
+                    //         room.join_project(project, host, app_state.clone(), cx)
+                    //     })
+                    // })
+
+                    // if let Some(future) = future {
+                    //     future.await?;
+                    // }
+
+                    // Ok(Some(room))
+                });
+            }
+
+            let should_prompt = room.update(cx, |room, _| {
+                room.channel_id().is_some()
+                    && room.is_sharing_project()
+                    && room.remote_participants().len() > 0
+            });
+            if should_prompt && requesting_window.is_some() {
+                return cx.spawn(|this, mut cx| async move {
+                    let answer = requesting_window.unwrap().update(&mut cx, |_, cx| {
+                        cx.prompt(
+                            PromptLevel::Warning,
+                            "Leaving this call will unshare your current project.\nDo you want to switch channels?",
+                            &["Yes, Join Channel", "Cancel"],
+                        )
+                    })?;
+                    if answer.await? == 1 {
+                        return Ok(None);
+                    }
+
+                    room.update(&mut cx, |room, cx| room.clear_state(cx))?;
+
+                    this.update(&mut cx, |this, cx| {
+                        this.join_channel(channel_id, requesting_window, cx)
+                    })?
+                    .await
+                });
+            }
+
+            if room.read(cx).channel_id().is_some() {
                 room.update(cx, |room, cx| room.clear_state(cx));
             }
         }

crates/collab2/src/tests/channel_tests.rs 🔗

@@ -364,7 +364,8 @@ async fn test_joining_channel_ancestor_member(
     let active_call_b = cx_b.read(ActiveCall::global);
 
     assert!(active_call_b
-        .update(cx_b, |active_call, cx| active_call.join_channel(sub_id, cx))
+        .update(cx_b, |active_call, cx| active_call
+            .join_channel(sub_id, None, cx))
         .await
         .is_ok());
 }
@@ -394,7 +395,9 @@ async fn test_channel_room(
     let active_call_b = cx_b.read(ActiveCall::global);
 
     active_call_a
-        .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
+        .update(cx_a, |active_call, cx| {
+            active_call.join_channel(zed_id, None, cx)
+        })
         .await
         .unwrap();
 
@@ -442,7 +445,9 @@ async fn test_channel_room(
     });
 
     active_call_b
-        .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
+        .update(cx_b, |active_call, cx| {
+            active_call.join_channel(zed_id, None, cx)
+        })
         .await
         .unwrap();
 
@@ -559,12 +564,16 @@ async fn test_channel_room(
     });
 
     active_call_a
-        .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
+        .update(cx_a, |active_call, cx| {
+            active_call.join_channel(zed_id, None, cx)
+        })
         .await
         .unwrap();
 
     active_call_b
-        .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
+        .update(cx_b, |active_call, cx| {
+            active_call.join_channel(zed_id, None, cx)
+        })
         .await
         .unwrap();
 
@@ -608,7 +617,9 @@ async fn test_channel_jumping(executor: BackgroundExecutor, cx_a: &mut TestAppCo
     let active_call_a = cx_a.read(ActiveCall::global);
 
     active_call_a
-        .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
+        .update(cx_a, |active_call, cx| {
+            active_call.join_channel(zed_id, None, cx)
+        })
         .await
         .unwrap();
 
@@ -627,7 +638,7 @@ async fn test_channel_jumping(executor: BackgroundExecutor, cx_a: &mut TestAppCo
 
     active_call_a
         .update(cx_a, |active_call, cx| {
-            active_call.join_channel(rust_id, cx)
+            active_call.join_channel(rust_id, None, cx)
         })
         .await
         .unwrap();
@@ -793,7 +804,7 @@ async fn test_call_from_channel(
     let active_call_b = cx_b.read(ActiveCall::global);
 
     active_call_a
-        .update(cx_a, |call, cx| call.join_channel(channel_id, cx))
+        .update(cx_a, |call, cx| call.join_channel(channel_id, None, cx))
         .await
         .unwrap();
 
@@ -1286,7 +1297,7 @@ async fn test_guest_access(
 
     // Non-members should not be allowed to join
     assert!(active_call_b
-        .update(cx_b, |call, cx| call.join_channel(channel_a, cx))
+        .update(cx_b, |call, cx| call.join_channel(channel_a, None, cx))
         .await
         .is_err());
 
@@ -1308,7 +1319,7 @@ async fn test_guest_access(
 
     // Client B joins channel A as a guest
     active_call_b
-        .update(cx_b, |call, cx| call.join_channel(channel_a, cx))
+        .update(cx_b, |call, cx| call.join_channel(channel_a, None, cx))
         .await
         .unwrap();
 
@@ -1341,7 +1352,7 @@ async fn test_guest_access(
     assert_channels_list_shape(client_b.channel_store(), cx_b, &[]);
 
     active_call_b
-        .update(cx_b, |call, cx| call.join_channel(channel_b, cx))
+        .update(cx_b, |call, cx| call.join_channel(channel_b, None, cx))
         .await
         .unwrap();
 
@@ -1372,7 +1383,7 @@ async fn test_invite_access(
 
     // should not be allowed to join
     assert!(active_call_b
-        .update(cx_b, |call, cx| call.join_channel(channel_b_id, cx))
+        .update(cx_b, |call, cx| call.join_channel(channel_b_id, None, cx))
         .await
         .is_err());
 
@@ -1390,7 +1401,7 @@ async fn test_invite_access(
         .unwrap();
 
     active_call_b
-        .update(cx_b, |call, cx| call.join_channel(channel_b_id, cx))
+        .update(cx_b, |call, cx| call.join_channel(channel_b_id, None, cx))
         .await
         .unwrap();
 

crates/collab2/src/tests/integration_tests.rs 🔗

@@ -510,9 +510,10 @@ async fn test_joining_channels_and_calling_multiple_users_simultaneously(
 
     // Simultaneously join channel 1 and then channel 2
     active_call_a
-        .update(cx_a, |call, cx| call.join_channel(channel_1, cx))
+        .update(cx_a, |call, cx| call.join_channel(channel_1, None, cx))
         .detach();
-    let join_channel_2 = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_2, cx));
+    let join_channel_2 =
+        active_call_a.update(cx_a, |call, cx| call.join_channel(channel_2, None, cx));
 
     join_channel_2.await.unwrap();
 
@@ -538,7 +539,8 @@ async fn test_joining_channels_and_calling_multiple_users_simultaneously(
         call.invite(client_c.user_id().unwrap(), None, cx)
     });
 
-    let join_channel = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, cx));
+    let join_channel =
+        active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, None, cx));
 
     b_invite.await.unwrap();
     c_invite.await.unwrap();
@@ -567,7 +569,8 @@ async fn test_joining_channels_and_calling_multiple_users_simultaneously(
         .unwrap();
 
     // Simultaneously join channel 1 and call user B and user C from client A.
-    let join_channel = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, cx));
+    let join_channel =
+        active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, None, cx));
 
     let b_invite = active_call_a.update(cx_a, |call, cx| {
         call.invite(client_b.user_id().unwrap(), None, cx)

crates/collab_ui2/src/collab_panel.rs 🔗

@@ -90,10 +90,10 @@ use rpc::proto;
 //     channel_id: ChannelId,
 // }
 
-// #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-// pub struct OpenChannelNotes {
-//     pub channel_id: ChannelId,
-// }
+#[derive(Action, PartialEq, Debug, Clone, Serialize, Deserialize)]
+pub struct OpenChannelNotes {
+    pub channel_id: ChannelId,
+}
 
 // #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
 // pub struct JoinChannelCall {
@@ -167,10 +167,10 @@ use editor::Editor;
 use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
 use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
-    actions, div, img, prelude::*, serde_json, AppContext, AsyncWindowContext, Div, EventEmitter,
-    FocusHandle, Focusable, FocusableView, InteractiveElement, IntoElement, Model, ParentElement,
-    Render, RenderOnce, SharedString, Styled, Subscription, View, ViewContext, VisualContext,
-    WeakView,
+    actions, div, img, prelude::*, serde_json, Action, AppContext, AsyncWindowContext, Div,
+    EventEmitter, FocusHandle, Focusable, FocusableView, InteractiveElement, IntoElement, Model,
+    ParentElement, Render, RenderOnce, SharedString, Styled, Subscription, View, ViewContext,
+    VisualContext, WeakView,
 };
 use project::Fs;
 use serde_derive::{Deserialize, Serialize};
@@ -322,17 +322,17 @@ pub struct CollabPanel {
     subscriptions: Vec<Subscription>,
     collapsed_sections: Vec<Section>,
     collapsed_channels: Vec<ChannelId>,
-    // drag_target_channel: ChannelDragTarget,
+    drag_target_channel: ChannelDragTarget,
     workspace: WeakView<Workspace>,
     // context_menu_on_selected: bool,
 }
 
-// #[derive(PartialEq, Eq)]
-// enum ChannelDragTarget {
-//     None,
-//     Root,
-//     Channel(ChannelId),
-// }
+#[derive(PartialEq, Eq)]
+enum ChannelDragTarget {
+    None,
+    Root,
+    Channel(ChannelId),
+}
 
 #[derive(Serialize, Deserialize)]
 struct SerializedCollabPanel {
@@ -614,7 +614,7 @@ impl CollabPanel {
                 workspace: workspace.weak_handle(),
                 client: workspace.app_state().client.clone(),
                 //                 context_menu_on_selected: true,
-                //                 drag_target_channel: ChannelDragTarget::None,
+                drag_target_channel: ChannelDragTarget::None,
                 //                 list_state,
             };
 
@@ -2233,20 +2233,20 @@ impl CollabPanel {
     //         self.toggle_channel_collapsed(action.location, cx);
     //     }
 
-    //     fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
-    //         match self.collapsed_channels.binary_search(&channel_id) {
-    //             Ok(ix) => {
-    //                 self.collapsed_channels.remove(ix);
-    //             }
-    //             Err(ix) => {
-    //                 self.collapsed_channels.insert(ix, channel_id);
-    //             }
-    //         };
-    //         self.serialize(cx);
-    //         self.update_entries(true, cx);
-    //         cx.notify();
-    //         cx.focus_self();
-    //     }
+    fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
+        match self.collapsed_channels.binary_search(&channel_id) {
+            Ok(ix) => {
+                self.collapsed_channels.remove(ix);
+            }
+            Err(ix) => {
+                self.collapsed_channels.insert(ix, channel_id);
+            }
+        };
+        // self.serialize(cx); todo!()
+        self.update_entries(true, cx);
+        cx.notify();
+        cx.focus_self();
+    }
 
     fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
         self.collapsed_channels.binary_search(&channel_id).is_ok()
@@ -2346,11 +2346,12 @@ impl CollabPanel {
     //         }
     //     }
 
-    //     fn open_channel_notes(&mut self, action: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
-    //         if let Some(workspace) = self.workspace.upgrade(cx) {
-    //             ChannelView::open(action.channel_id, workspace, cx).detach();
-    //         }
-    //     }
+    fn open_channel_notes(&mut self, action: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
+        if let Some(workspace) = self.workspace.upgrade() {
+            todo!();
+            // ChannelView::open(action.channel_id, workspace, cx).detach();
+        }
+    }
 
     //     fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
     //         let Some(channel) = self.selected_channel() else {
@@ -2504,21 +2505,22 @@ impl CollabPanel {
     //             .detach_and_log_err(cx);
     //     }
 
-    //     fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
-    //         let Some(workspace) = self.workspace.upgrade(cx) else {
-    //             return;
-    //         };
-    //         let Some(handle) = cx.window().downcast::<Workspace>() else {
-    //             return;
-    //         };
-    //         workspace::join_channel(
-    //             channel_id,
-    //             workspace.read(cx).app_state().clone(),
-    //             Some(handle),
-    //             cx,
-    //         )
-    //         .detach_and_log_err(cx)
-    //     }
+    fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
+        let Some(handle) = cx.window_handle().downcast::<Workspace>() else {
+            return;
+        };
+        let active_call = ActiveCall::global(cx);
+        cx.spawn(|_, mut cx| async move {
+            active_call
+                .update(&mut cx, |active_call, cx| {
+                    active_call.join_channel(channel_id, Some(handle), cx)
+                })
+                .log_err()?
+                .await
+                .notify_async_err(&mut cx)
+        })
+        .detach()
+    }
 
     //     fn join_channel_chat(&mut self, action: &JoinChannelChat, cx: &mut ViewContext<Self>) {
     //         let channel_id = action.channel_id;
@@ -2982,9 +2984,7 @@ impl CollabPanel {
         is_selected: bool,
         cx: &mut ViewContext<Self>,
     ) -> impl IntoElement {
-        ListItem::new("contact-placeholder")
-            .child(Label::new("Add a Contact"))
-            .on_click(cx.listener(|this, _, cx| todo!()))
+        ListItem::new("contact-placeholder").child(Label::new("Add a Contact"))
         // enum AddContacts {}
         // MouseEventHandler::new::<AddContacts, _>(0, cx, |state, _| {
         //     let style = theme.list_empty_state.style_for(is_selected, state);
@@ -3023,6 +3023,15 @@ impl CollabPanel {
     ) -> impl IntoElement {
         let channel_id = channel.id;
 
+        let is_active = maybe!({
+            let call_channel = ActiveCall::global(cx)
+                .read(cx)
+                .room()?
+                .read(cx)
+                .channel_id()?;
+            Some(call_channel == channel_id)
+        })
+        .unwrap_or(false);
         let is_public = self
             .channel_store
             .read(cx)
@@ -3034,17 +3043,7 @@ impl CollabPanel {
             .then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok())
             .unwrap_or(false);
 
-        let is_active = maybe!({
-            let call_channel = ActiveCall::global(cx)
-                .read(cx)
-                .room()?
-                .read(cx)
-                .channel_id()?;
-            Some(call_channel == channel_id)
-        })
-        .unwrap_or(false);
-
-        let has_messages_notification = channel.unseen_message_id.is_some() || true;
+        let has_messages_notification = channel.unseen_message_id.is_some();
         let has_notes_notification = channel.unseen_note_version.is_some();
 
         const FACEPILE_LIMIT: usize = 3;
@@ -3052,6 +3051,7 @@ impl CollabPanel {
 
         let face_pile = if !participants.is_empty() {
             let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
+            let user = &participants[0];
 
             let result = FacePile {
                 faces: participants
@@ -3059,6 +3059,7 @@ impl CollabPanel {
                     .filter_map(|user| Some(Avatar::data(user.avatar.clone()?).into_any_element()))
                     .take(FACEPILE_LIMIT)
                     .chain(if extra_count > 0 {
+                        // todo!() @nate - this label looks wrong.
                         Some(Label::new(format!("+{}", extra_count)).into_any_element())
                     } else {
                         None
@@ -3081,7 +3082,7 @@ impl CollabPanel {
                         .w_full()
                         .justify_between()
                         .child(
-                            div()
+                            h_stack()
                                 .id(channel_id as usize)
                                 .child(Label::new(channel.name.clone()))
                                 .children(face_pile.map(|face_pile| face_pile.render(cx)))
@@ -3092,11 +3093,10 @@ impl CollabPanel {
                                 .child(
                                     div()
                                         .id("channel_chat")
-                                        .bg(gpui::blue())
                                         .when(!has_messages_notification, |el| el.invisible())
                                         .group_hover("", |style| style.visible())
                                         .child(
-                                            IconButton::new("test_chat", Icon::MessageBubbles)
+                                            IconButton::new("channel_chat", Icon::MessageBubbles)
                                                 .color(if has_messages_notification {
                                                     Color::Default
                                                 } else {
@@ -3111,20 +3111,16 @@ impl CollabPanel {
                                         .when(!has_notes_notification, |el| el.invisible())
                                         .group_hover("", |style| style.visible())
                                         .child(
-                                            div().child("Notes").id("test_notes").tooltip(|cx| {
-                                                Tooltip::text("Open channel notes", cx)
-                                            }),
-                                        ), // .child(
-                                           //     IconButton::new("channel_notes", Icon::File)
-                                           //         .color(if has_notes_notification {
-                                           //             Color::Default
-                                           //         } else {
-                                           //             Color::Muted
-                                           //         })
-                                           //         .tooltip(|cx| {
-                                           //             Tooltip::text("Open channel notes", cx)
-                                           //         }),
-                                           // ),
+                                            IconButton::new("channel_notes", Icon::File)
+                                                .color(if has_notes_notification {
+                                                    Color::Default
+                                                } else {
+                                                    Color::Muted
+                                                })
+                                                .tooltip(|cx| {
+                                                    Tooltip::text("Open channel notes", cx)
+                                                }),
+                                        ),
                                 ),
                         ),
                 )
@@ -3133,7 +3129,18 @@ impl CollabPanel {
                 } else {
                     Toggle::NotToggleable
                 })
-                .on_click(cx.listener(|this, _, cx| todo!()))
+                .on_toggle(
+                    cx.listener(move |this, _, cx| this.toggle_channel_collapsed(channel_id, cx)),
+                )
+                .on_click(cx.listener(move |this, _, cx| {
+                    if this.drag_target_channel == ChannelDragTarget::None {
+                        if is_active {
+                            this.open_channel_notes(&OpenChannelNotes { channel_id }, cx)
+                        } else {
+                            this.join_channel(channel_id, cx)
+                        }
+                    }
+                }))
                 .on_secondary_mouse_down(cx.listener(|this, _, cx| {
                     todo!() // open context menu
                 })),

crates/ui2/src/components/disclosure.rs 🔗

@@ -1,19 +1,30 @@
-use gpui::{div, Element, ParentElement};
+use std::rc::Rc;
 
-use crate::{Color, Icon, IconElement, IconSize, Toggle};
+use gpui::{div, Element, IntoElement, MouseDownEvent, ParentElement, WindowContext};
 
-pub fn disclosure_control(toggle: Toggle) -> impl Element {
+use crate::{Color, Icon, IconButton, IconSize, Toggle};
+
+pub fn disclosure_control(
+    toggle: Toggle,
+    on_toggle: Option<Rc<dyn Fn(&MouseDownEvent, &mut WindowContext) + 'static>>,
+) -> impl Element {
     match (toggle.is_toggleable(), toggle.is_toggled()) {
         (false, _) => div(),
         (_, true) => div().child(
-            IconElement::new(Icon::ChevronDown)
+            IconButton::new("toggle", Icon::ChevronDown)
                 .color(Color::Muted)
-                .size(IconSize::Small),
+                .size(IconSize::Small)
+                .when_some(on_toggle, move |el, on_toggle| {
+                    el.on_click(move |e, cx| on_toggle(e, cx))
+                }),
         ),
         (_, false) => div().child(
-            IconElement::new(Icon::ChevronRight)
+            IconButton::new("toggle", Icon::ChevronRight)
                 .color(Color::Muted)
-                .size(IconSize::Small),
+                .size(IconSize::Small)
+                .when_some(on_toggle, move |el, on_toggle| {
+                    el.on_click(move |e, cx| on_toggle(e, cx))
+                }),
         ),
     }
 }

crates/ui2/src/components/icon_button.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{h_stack, prelude::*, Icon, IconElement};
+use crate::{h_stack, prelude::*, Icon, IconElement, IconSize};
 use gpui::{prelude::*, Action, AnyView, Div, MouseButton, MouseDownEvent, Stateful};
 
 #[derive(IntoElement)]
@@ -6,6 +6,7 @@ pub struct IconButton {
     id: ElementId,
     icon: Icon,
     color: Color,
+    size: IconSize,
     variant: ButtonVariant,
     state: InteractionState,
     selected: bool,
@@ -50,7 +51,11 @@ impl RenderOnce for IconButton {
             // place we use an icon button.
             // .hover(|style| style.bg(bg_hover_color))
             .active(|style| style.bg(bg_active_color))
-            .child(IconElement::new(self.icon).color(icon_color));
+            .child(
+                IconElement::new(self.icon)
+                    .size(self.size)
+                    .color(icon_color),
+            );
 
         if let Some(click_handler) = self.on_mouse_down {
             button = button.on_mouse_down(MouseButton::Left, move |event, cx| {
@@ -76,6 +81,7 @@ impl IconButton {
             id: id.into(),
             icon,
             color: Color::default(),
+            size: Default::default(),
             variant: ButtonVariant::default(),
             state: InteractionState::default(),
             selected: false,
@@ -94,6 +100,11 @@ impl IconButton {
         self
     }
 
+    pub fn size(mut self, size: IconSize) -> Self {
+        self.size = size;
+        self
+    }
+
     pub fn variant(mut self, variant: ButtonVariant) -> Self {
         self.variant = variant;
         self

crates/ui2/src/components/list.rs 🔗

@@ -63,7 +63,7 @@ impl RenderOnce for ListHeader {
     type Rendered = Div;
 
     fn render(self, cx: &mut WindowContext) -> Self::Rendered {
-        let disclosure_control = disclosure_control(self.toggle);
+        let disclosure_control = disclosure_control(self.toggle, None);
 
         let meta = match self.meta {
             Some(ListHeaderMeta::Tools(icons)) => div().child(
@@ -177,6 +177,7 @@ pub struct ListItem {
     toggle: Toggle,
     inset: bool,
     on_click: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    on_toggle: Option<Rc<dyn Fn(&MouseDownEvent, &mut WindowContext) + 'static>>,
     on_secondary_mouse_down: Option<Rc<dyn Fn(&MouseDownEvent, &mut WindowContext) + 'static>>,
     children: SmallVec<[AnyElement; 2]>,
 }
@@ -193,6 +194,7 @@ impl ListItem {
             inset: false,
             on_click: None,
             on_secondary_mouse_down: None,
+            on_toggle: None,
             children: SmallVec::new(),
         }
     }
@@ -230,6 +232,14 @@ impl ListItem {
         self
     }
 
+    pub fn on_toggle(
+        mut self,
+        on_toggle: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static,
+    ) -> Self {
+        self.on_toggle = Some(Rc::new(on_toggle));
+        self
+    }
+
     pub fn selected(mut self, selected: bool) -> Self {
         self.selected = selected;
         self
@@ -283,7 +293,7 @@ impl RenderOnce for ListItem {
                 this.bg(cx.theme().colors().ghost_element_selected)
             })
             .when_some(self.on_click.clone(), |this, on_click| {
-                this.on_click(move |event, cx| {
+                this.cursor_pointer().on_click(move |event, cx| {
                     // HACK: GPUI currently fires `on_click` with any mouse button,
                     // but we only care about the left button.
                     if event.down.button == MouseButton::Left {
@@ -304,7 +314,7 @@ impl RenderOnce for ListItem {
                     .gap_1()
                     .items_center()
                     .relative()
-                    .child(disclosure_control(self.toggle))
+                    .child(disclosure_control(self.toggle, self.on_toggle))
                     .children(left_content)
                     .children(self.children)
                     // HACK: We need to attach the `on_click` handler to the child element in order to have the click