Preserve and restore focus across window activation cycles (#47044)

Mikayla Maki and Claude Opus 4.5 created

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 <noreply@anthropic.com>

Change summary

crates/gpui/src/key_dispatch.rs   |  44 ++++++++++
crates/gpui/src/window.rs         |  10 ++
crates/workspace/src/workspace.rs | 137 +++++++++++++++++++++++++++++++++
3 files changed, 191 insertions(+)

Detailed changes

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"
+            );
+        });
+    }
 }

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<FocusId>,
+    focus_before_deactivation: Option<FocusId>,
     focus_enabled: bool,
     pending_input: Option<PendingInput>,
     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(),

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);