From 4064af34584a4574ae4a270cd4c6b73a730f042d Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 16 Jan 2026 18:57:06 -0800 Subject: [PATCH] Preserve and restore focus across window activation cycles (#47044) Closes https://github.com/zed-industries/zed/issues/46953 This turned out to be a pretty deep rabbit hole, ultimately landing in how GPUI didn't restore focus nicely when swapping window activation states. Release Notes: - N/A Co-authored-by: Claude Opus 4.5 --- crates/gpui/src/key_dispatch.rs | 44 ++++++++++ crates/gpui/src/window.rs | 10 +++ crates/workspace/src/workspace.rs | 137 ++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 1b92b9fe3ffabdbeec4bc7450adc1439e8e223eb..2f0848660eca6e8b17e5e1402f8334566a82856e 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -1102,4 +1102,48 @@ mod tests { cx.simulate_keystrokes("ctrl-b ["); test.update(cx, |test, _| assert_eq!(test.text.borrow().as_str(), "[")) } + + #[crate::test] + fn test_focus_preserved_across_window_activation(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let focus_handle = cx.update(|window, cx| { + let handle = cx.focus_handle(); + window.focus(&handle, cx); + window.activate_window(); + handle + }); + cx.run_until_parked(); + + cx.update(|window, _| { + assert!(window.is_window_active(), "Window should be active"); + assert!( + focus_handle.is_focused(window), + "Element should be focused after window.focus() call" + ); + }); + + cx.deactivate_window(); + + cx.update(|window, _| { + assert!(!window.is_window_active(), "Window should not be active"); + assert!( + !focus_handle.is_focused(window), + "Element should not appear focused when window is inactive" + ); + }); + + cx.update(|window, _| { + window.activate_window(); + }); + cx.run_until_parked(); + + cx.update(|window, _| { + assert!(window.is_window_active(), "Window should be active again"); + assert!( + focus_handle.is_focused(window), + "Element should be focused after window reactivation" + ); + }); + } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 53e10ab227c510a538f3181d4a34a757a13c197c..d4465cdaee6c01d20e63b241844e76ccdd29f36d 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -942,6 +942,7 @@ pub struct Window { pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, + focus_before_deactivation: Option, focus_enabled: bool, pending_input: Option, pending_modifier: ModifierState, @@ -1253,6 +1254,14 @@ impl Window { move |active| { handle .update(&mut cx, |_, window, cx| { + if active { + if let Some(focus_id) = window.focus_before_deactivation.take() { + window.focus = Some(focus_id); + } + } else { + window.focus_before_deactivation = window.focus.take(); + } + window.active.set(active); window.modifiers = window.platform_window.modifiers(); window.capslock = window.platform_window.capslock(); @@ -1410,6 +1419,7 @@ impl Window { refreshing: false, activation_observers: SubscriberSet::new(), focus: None, + focus_before_deactivation: None, focus_enabled: true, pending_input: None, pending_modifier: ModifierState::default(), diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index f6f61e25b47984c01533eb8b660f8d8e9546b0b2..b4192ece5e6b15b9be58e3ab868f621f02581702 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -10115,6 +10115,143 @@ mod tests { }); } + #[gpui::test] + async fn test_zoomed_dock_persists_across_window_activation(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| TestPanel::new(DockPosition::Bottom, 100, cx)); + workspace.add_panel(panel.clone(), window, cx); + workspace.toggle_dock(DockPosition::Bottom, window, cx); + panel + }); + + // Activate and zoom the panel + panel.update(cx, |_, cx| cx.emit(PanelEvent::Activate)); + panel.update(cx, |_, cx| cx.emit(PanelEvent::ZoomIn)); + + // Verify the dock is open and zoomed with focus in the panel + workspace.update_in(cx, |workspace, window, cx| { + assert!( + workspace.bottom_dock().read(cx).is_open(), + "Bottom dock should be open" + ); + assert!(panel.is_zoomed(window, cx), "Panel should be zoomed"); + assert!( + workspace.zoomed.is_some(), + "Workspace should track the zoomed panel" + ); + assert!( + workspace.zoomed_position.is_some(), + "Workspace should track the zoomed dock position" + ); + assert!( + panel.read(cx).focus_handle(cx).contains_focused(window, cx), + "Panel should be focused" + ); + }); + + // Deactivate the window (simulates cmd-tab away from Zed) + cx.deactivate_window(); + + // Verify the dock is still open while window is deactivated + // (the bug manifests on REactivation, not deactivation) + workspace.update_in(cx, |workspace, window, cx| { + assert!( + workspace.bottom_dock().read(cx).is_open(), + "Bottom dock should still be open while window is deactivated" + ); + assert!( + panel.is_zoomed(window, cx), + "Panel should still be zoomed while window is deactivated" + ); + assert!( + workspace.zoomed_position.is_some(), + "zoomed_position should still be set while window is deactivated" + ); + }); + + // Reactivate the window (simulates cmd-tab back to Zed) + // During reactivation, focus is restored to the dock panel + cx.update(|window, _cx| { + window.activate_window(); + }); + cx.run_until_parked(); + + // Verify zoomed dock remains open after reactivation + workspace.update_in(cx, |workspace, window, cx| { + assert!( + workspace.bottom_dock().read(cx).is_open(), + "Bottom dock should remain open after window reactivation" + ); + assert!( + panel.is_zoomed(window, cx), + "Panel should remain zoomed after window reactivation" + ); + assert!( + workspace.zoomed.is_some(), + "Workspace should still track the zoomed panel after window reactivation" + ); + }); + } + + #[gpui::test] + async fn test_zoomed_dock_dismissed_when_focus_moves_to_center_pane( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| TestPanel::new(DockPosition::Bottom, 100, cx)); + workspace.add_panel(panel.clone(), window, cx); + workspace.toggle_dock(DockPosition::Bottom, window, cx); + panel + }); + + // Activate and zoom the panel + panel.update(cx, |_, cx| cx.emit(PanelEvent::Activate)); + panel.update(cx, |_, cx| cx.emit(PanelEvent::ZoomIn)); + + // Verify setup + workspace.update_in(cx, |workspace, window, cx| { + assert!(workspace.bottom_dock().read(cx).is_open()); + assert!(panel.is_zoomed(window, cx)); + assert!(workspace.zoomed_position.is_some()); + }); + + // Explicitly focus the center pane (simulates user clicking in the editor) + workspace.update_in(cx, |workspace, window, cx| { + window.focus(&workspace.active_pane().focus_handle(cx), cx); + }); + cx.run_until_parked(); + + // When user explicitly focuses the center pane, the zoomed dock SHOULD be dismissed + workspace.update_in(cx, |workspace, _window, cx| { + assert!( + !workspace.bottom_dock().read(cx).is_open(), + "Bottom dock should be closed when focus explicitly moves to center pane" + ); + assert!( + workspace.zoomed.is_none(), + "Workspace should not track zoomed panel when focus explicitly moves to center pane" + ); + assert!( + workspace.zoomed_position.is_none(), + "Workspace zoomed_position should be None when focus explicitly moves to center pane" + ); + }); + } + #[gpui::test] async fn test_toggle_all_docks(cx: &mut gpui::TestAppContext) { init_test(cx);