From 81562a30c774165361f88af55e91e135f39d2186 Mon Sep 17 00:00:00 2001 From: Zachiah Sawyer Date: Thu, 5 Feb 2026 01:21:47 -0800 Subject: [PATCH] Make middle click not mousedown close tabs (#44916) Closes #44856 This PR also adds an `on_aux_click` interface to the div element that follows MDM standard https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event Release Notes: - fix bug where mouse down middle click would close tab instead of full middle click --------- Co-authored-by: Anthony Eid --- crates/gpui/src/elements/div.rs | 57 ++++++++++++++++++++++++++++++--- crates/gpui/src/interactive.rs | 13 ++++++++ crates/workspace/src/pane.rs | 12 +++---- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 0d079ee9485e327751711473ed7ab2d61ae8721d..4537cfc22f6ed8c4ea3d5443327723207af88620 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -522,6 +522,20 @@ impl Interactivity { })); } + /// Bind the given callback to non-primary click events of this element. + /// The imperative API equivalent to [`StatefulInteractiveElement::on_aux_click`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_aux_click(&mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) + where + Self: Sized, + { + self.aux_click_listeners + .push(Rc::new(move |event, window, cx| { + listener(event, window, cx) + })); + } + /// On drag initiation, this callback will be used to create a new view to render the dragged value for a /// drag and drop operation. This API should also be used as the equivalent of 'on drag start' with /// the [`Self::on_drag_move`] API. @@ -1190,6 +1204,21 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Bind the given callback to non-primary click events of this element. + /// The fluent API equivalent to [`Interactivity::on_aux_click`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_aux_click( + mut self, + listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self + where + Self: Sized, + { + self.interactivity().on_aux_click(listener); + self + } + /// On drag initiation, this callback will be used to create a new view to render the dragged value for a /// drag and drop operation. This API should also be used as the equivalent of 'on drag start' with /// the [`InteractiveElement::on_drag_move`] API. @@ -1622,6 +1651,7 @@ pub struct Interactivity { pub(crate) drop_listeners: Vec<(TypeId, DropListener)>, pub(crate) can_drop_predicate: Option, pub(crate) click_listeners: Vec, + pub(crate) aux_click_listeners: Vec, pub(crate) drag_listener: Option<(Arc, DragListener)>, pub(crate) hover_listener: Option>, pub(crate) tooltip_builder: Option, @@ -1815,6 +1845,7 @@ impl Interactivity { || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() || !self.click_listeners.is_empty() + || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() || self.drag_listener.is_some() || !self.drop_listeners.is_empty() @@ -2237,6 +2268,7 @@ impl Interactivity { let mut drag_listener = mem::take(&mut self.drag_listener); let drop_listeners = mem::take(&mut self.drop_listeners); let click_listeners = mem::take(&mut self.click_listeners); + let aux_click_listeners = mem::take(&mut self.aux_click_listeners); let can_drop_predicate = mem::take(&mut self.can_drop_predicate); if !drop_listeners.is_empty() { @@ -2273,7 +2305,10 @@ impl Interactivity { } if let Some(element_state) = element_state { - if !click_listeners.is_empty() || drag_listener.is_some() { + if !click_listeners.is_empty() + || !aux_click_listeners.is_empty() + || drag_listener.is_some() + { let pending_mouse_down = element_state .pending_mouse_down .get_or_insert_with(Default::default) @@ -2287,9 +2322,10 @@ impl Interactivity { window.on_mouse_event({ let pending_mouse_down = pending_mouse_down.clone(); let hitbox = hitbox.clone(); + let has_aux_click_listeners = !aux_click_listeners.is_empty(); move |event: &MouseDownEvent, phase, window, _cx| { if phase == DispatchPhase::Bubble - && event.button == MouseButton::Left + && (event.button == MouseButton::Left || has_aux_click_listeners) && hitbox.is_hovered(window) { *pending_mouse_down.borrow_mut() = Some(event.clone()); @@ -2311,6 +2347,7 @@ impl Interactivity { && !cx.has_active_drag() && (event.position - mouse_down.position).magnitude() > DRAG_THRESHOLD && let Some((drag_value, drag_listener)) = drag_listener.take() + && mouse_down.button == MouseButton::Left { *clicked_state.borrow_mut() = ElementClickedState::default(); let cursor_offset = event.position - hitbox.origin; @@ -2387,12 +2424,24 @@ impl Interactivity { // Fire click handlers during the bubble phase. DispatchPhase::Bubble => { if let Some(mouse_down) = captured_mouse_down.take() { + let btn = mouse_down.button; + let mouse_click = ClickEvent::Mouse(MouseClickEvent { down: mouse_down, up: event.clone(), }); - for listener in &click_listeners { - listener(&mouse_click, window, cx); + + match btn { + MouseButton::Left => { + for listener in &click_listeners { + listener(&mouse_click, window, cx); + } + } + _ => { + for listener in &aux_click_listeners { + listener(&mouse_click, window, cx); + } + } } } } diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index a500ac46f0bbf96fc2b9d326a3a61da42c40b7ec..bfa3196adb41f1703e911ee546b37e3ae9b168fe 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -284,6 +284,19 @@ impl ClickEvent { } } + /// Returns if this was a middle click + /// + /// `Keyboard`: false + /// `Mouse`: Whether the middle button was pressed and released + pub fn is_middle_click(&self) -> bool { + match self { + ClickEvent::Keyboard(_) => false, + ClickEvent::Mouse(event) => { + event.down.button == MouseButton::Middle && event.up.button == MouseButton::Middle + } + } + } + /// Returns whether the click was a standard click /// /// `Keyboard`: Always true diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 88cf824d1426c2bb7d3529c16bfb636d4573747c..f025131758760d6c4db5250e2bbdb12a50d4ee01 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2790,8 +2790,6 @@ impl Pane { let item_handle = item.boxed_clone(); move |pane: &mut Self, event: &ClickEvent, window, cx| { if event.click_count() > 1 { - // On double-click, dispatch the Rename action (when available) - // instead of just activating the item. pane.unpreview_item_if_preview(item_id); let extra_actions = item_handle.tab_extra_context_menu_actions(window, cx); if let Some((_, action)) = extra_actions @@ -2809,10 +2807,12 @@ impl Pane { pane.activate_item(ix, true, true, window, cx) } })) - // TODO: This should be a click listener with the middle mouse button instead of a mouse down listener. - .on_mouse_down( - MouseButton::Middle, - cx.listener(move |pane, _event, window, cx| { + .on_aux_click( + cx.listener(move |pane: &mut Self, event: &ClickEvent, window, cx| { + if !event.is_middle_click() { + return; + } + pane.close_item_by_id(item_id, SaveIntent::Close, window, cx) .detach_and_log_err(cx); }),