Fix tab switcher random jumps (#52671)

#### Fixes #52579

When quickly pressing and releasing Ctrl+Tab, the tab switcher popover
would briefly appear, and any mouse movement over it would select a
random tab instead of the intended recently-focused file.

### Fix : 
This adds a 300ms delay before showing the tab switcher UI when opened
via modifier keys. If the modifier is released before the delay elapses,
the selected item is confirmed directly without ever showing the
popover. This matches the behavior of system window switchers (e.g.
macOS Cmd+Tab), where quick presses never flash the UI.

- `visible` and `_show_task` fields track whether the popover should be
rendered
- When not yet visible, the picker is mounted in a zero-size
overflow-hidden container to preserve focus and keyboard state
- When modifiers are released before the popover is visible, `confirm()`
is called directly instead of dispatching `menu::Confirm`, avoiding any
mouse-hover-based selection

### Self-Review Checklist

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments — *N/A, no unsafe
blocks*
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
— *Matches system window switcher behavior (e.g. macOS Cmd+Tab); no
visual change for slow/held switches*
- [x] Tests cover the new/changed behavior — *Added
`test_quick_switch_before_popover_visible`*
- [x] Performance impact has been considered and is acceptable — *Only
adds a lightweight 300ms timer task; no extra allocations or rendering
cost*

### Video 

[Screencast from 2026-03-29
11-52-07.webm](https://github.com/user-attachments/assets/8e148a74-75ae-4c36-8d44-594c3c9097e6)



### Note : Reopens previous work from closed PR #52648 (fork was deleted
by mistake)


Release Notes:

- Fixed Ctrl+Tab jumping to random documents when the mouse is moved
during a quick tab switch

---------

Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
This commit is contained in:
Om ChillureandPiotr Osiewicz authored and GitHub committed 2026-09-09 09:09:25 +00:00
1 parent 5c1845ddb8
commit 24b222a981
2 files changed
+82 -2

No files matched your search

+27 -2
View File
@@ -16,6 +16,7 @@ use project::Project;
use schemars::JsonSchema;
use serde::Deserialize;
use settings::Settings;
use std::time::Duration;
use std::{cmp::Reverse, sync::Arc};
use ui::{
DecoratedIcon, IconDecoration, IconDecorationKind, ListItem, ListItemSpacing, Tooltip,
@@ -29,6 +30,7 @@ use workspace::{
};
const PANEL_WIDTH_REMS: f32 = 28.;
const POPOVER_DELAY: Duration = Duration::from_millis(300);
/// Toggles the tab switcher interface.
#[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)]
@@ -54,6 +56,8 @@ actions!(
pub struct TabSwitcher {
picker: Entity<Picker<TabSwitcherDelegate>>,
init_modifiers: Option<Modifiers>,
visible: bool,
_show_task: Option<Task<()>>,
}
impl ModalView for TabSwitcher {}
@@ -169,6 +173,17 @@ impl TabSwitcher {
} else {
window.modifiers().modified().then_some(window.modifiers())
};
let has_modifiers = init_modifiers.is_some();
let _show_task = has_modifiers.then(|| {
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(POPOVER_DELAY).await;
this.update_in(cx, |this, _window, cx| {
this.visible = true;
cx.notify();
})
.ok();
})
});
Self {
picker: cx.new(|cx| {
if is_global {
@@ -179,6 +194,8 @@ impl TabSwitcher {
.initial_width(rems(PANEL_WIDTH_REMS))
}),
init_modifiers,
visible: !has_modifiers,
_show_task,
}
}
@@ -195,8 +212,12 @@ impl TabSwitcher {
self.init_modifiers = None;
if self.picker.read(cx).delegate.matches.is_empty() {
cx.emit(DismissEvent)
} else {
} else if self.visible {
window.dispatch_action(menu::Confirm.boxed_clone(), cx);
} else {
self.picker.update(cx, |picker, cx| {
picker.delegate.confirm(false, window, cx);
});
}
}
}
@@ -225,12 +246,16 @@ impl Focusable for TabSwitcher {
impl Render for TabSwitcher {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let picker = self.picker.clone();
v_flex()
.key_context("TabSwitcher")
.w(rems(PANEL_WIDTH_REMS))
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
.on_action(cx.listener(Self::handle_close_selected_item))
.child(self.picker.clone())
.when(self.visible, |el| el.child(picker.clone()))
.when(!self.visible, |el| {
el.child(div().size_0().overflow_hidden().child(picker.clone()))
})
}
}
@@ -255,6 +255,61 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) {
assert_tab_switcher_is_closed(workspace, cx);
}
#[gpui::test]
async fn test_quick_switch_before_popover_visible(cx: &mut gpui::TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({
"1.txt": "First file",
"2.txt": "Second file",
"3.txt": "Third file",
}),
)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
open_buffer("1.txt", &workspace, cx).await;
open_buffer("2.txt", &workspace, cx).await;
let _tab_3 = open_buffer("3.txt", &workspace, cx).await;
// Simulate quick Ctrl+Tab: press modifier, open switcher, release modifier
// all before the POPOVER_DELAY (300ms) elapses.
cx.simulate_modifiers_change(Modifiers::control());
let tab_switcher = open_tab_switcher(false, &workspace, cx);
// Verify the switcher is not visible yet (before delay)
tab_switcher.read_with(cx, |picker, cx| {
let tab_switcher = picker
.delegate
.tab_switcher
.upgrade()
.expect("tab switcher should exist");
assert!(!tab_switcher.read(cx).visible);
});
// Release modifiers before delay — should confirm the pre-selected item (2.txt)
cx.simulate_modifiers_change(Modifiers::none());
cx.read(|cx| {
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
assert_eq!(
active_editor.read(cx).title(cx),
"2.txt",
"quick switch should select previous tab, not a random one"
);
});
assert_tab_switcher_is_closed(workspace, cx);
}
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let state = AppState::test(cx);