From 0499890f76bb994871a9ce442e3d08ff19a37efc Mon Sep 17 00:00:00 2001 From: Silas Su Date: Mon, 14 Sep 2026 00:18:07 +0800 Subject: [PATCH] fix(macos): answer the Dock's reopen so a retired tty7 can come back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the last window with the tray icon on retires tty7 to the tray: process alive, Dock icon up, nothing on screen. That state had no way back through the icon. macOS relaunching an already-running app arrives as `applicationShouldHandleReopen:hasVisibleWindows:`, gpui's delegate forwards it to a callback registered with `Application::on_reopen`, and tty7 registered none — so the click was a no-op, and the only ways back in were `⌘N`, the tray's "Show tty7", or quitting and relaunching. `windows::reopen` takes that callback, in the two shapes the state has: a window still registered is activated rather than doubled, and no window at all goes through the pathless-launch restore (`restore_target` + `open_at` + `announce_detached_at_launch`) — the same path the tray's windowless branch takes, so the workspace that retired is the one that returns and not a blank one beside it. `reopen_with` is the seam the tests drive, so a reopen that opens a second window beside the one on screen cannot pass. `Application::on_reopen` is registered beside `on_open_urls` in `main`, because it has to exist before `run` — `keymap::init` runs inside the loop — and the callback defers to the loop with `cx.spawn` rather than opening windows on AppKit's delegate stack, the shape `on_open_urls` already uses. `activate_window` is `makeKeyAndOrderFront:` on macOS. Reported from a macOS machine where a lid close and wake left the process frontmost with no window: `launchservicesd SETFRONT` at 23:51:40 with the process still reported `running-active-NotVisible`, and the layout only back after a quit and relaunch. The window itself being lost across display sleep → wake is not explained by this change and carries no guess-fix here: nothing in tty7 or in the pinned gpui hangs off display sleep or wake. cargo fmt --check; cargo check --locked -p tty7 --tests; cargo test --locked -p tty7 --bin tty7-app -- 1888 passed, 0 failed. Co-authored-by: CommandCodeBot --- src/main.rs | 18 ++++++++++ src/ui/windows.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/main.rs b/src/main.rs index 67d36b02..a0412c52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -638,6 +638,24 @@ fn main() { application.on_open_urls(move |urls| { let _ = external_open_tx.try_send(urls); }); + // macOS relaunching an app that is already running — the Dock icon, a + // double-click on the bundle, `open -a tty7` — only reaches this process + // as `applicationShouldHandleReopen:`, and gpui's delegate does nothing + // with it unless a handler is registered. That is the one entrance a + // tray-resident tty7 has: with `show_tray_icon` on, closing the last + // window keeps the process and its Dock icon alive with nothing on + // screen, and without this the icon's click was a no-op (the only ways + // back in were ⌘N, the tray's "Show tty7", or quitting and relaunching). + // Like `on_open_urls`, the hook lives on `Application`, so it cannot go + // beside the other app-level handlers in `keymap::init`; and like that + // path it defers to the loop rather than opening windows on AppKit's + // delegate stack, which is also how Zed's own reopen handler runs. + application.on_reopen(|cx| { + cx.spawn(async move |cx| { + let _ = cx.update(crate::ui::windows::reopen); + }) + .detach(); + }); application.run(move |cx| { // gpui invokes this callback without an `App` context. Bridge it // back onto the application loop instead of touching UI state on diff --git a/src/ui/windows.rs b/src/ui/windows.rs index e249ba3b..a08bc947 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -480,6 +480,30 @@ fn open_missing_cli_window_with( announce_detached_at_launch(cx, restore); } +/// Brings the UI back when macOS relaunches a tty7 that is already running. +/// +/// AppKit only asks when it found no visible window, and that is a state +/// tty7 lives in on purpose: closing the last window with the tray icon on +/// retires to the tray, process alive and Dock icon up. A window that is +/// still there is activated rather than doubled — `activate_window` is +/// `makeKeyAndOrderFront:`, which is what orders a window AppKit left behind +/// back to the front. None at all gets the last layout back the way a +/// pathless launch and the tray's windowless path do, so the workspace that +/// retired is the one that returns and not a blank one beside it. +pub fn reopen(cx: &mut App) { + reopen_with(cx, open_at); +} + +fn reopen_with( + cx: &mut App, + open: impl FnOnce(&mut App, Option, Option), +) { + match WindowRegistry::most_recent(cx) { + Some(workspace) => activate(cx, workspace), + None => open_missing_cli_window_with(cx, None, open), + } +} + pub fn refresh_menu(cx: &mut App) { crate::ui::theme::set_menus(cx); } @@ -988,6 +1012,65 @@ mod tests { assert_eq!(opened, Some((None, None))); } + #[gpui::test] + fn a_reopen_with_no_window_up_restores_the_workspace_that_retired( + cx: &mut gpui::TestAppContext, + ) { + // The Dock icon after the last window retired to the tray: the + // process is alive with nothing on screen, and the click has to bring + // back the layout that was there, not mint a blank workspace beside it. + let view = WindowView::default(); + let restored = view.id; + let mut opened = None; + + cx.update(|cx| { + WindowRegistry::init(cx); + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![view], + active: Some(restored), + }, + ); + reopen_with(cx, |_, workspace, path| { + opened = Some((workspace, path)); + }); + }); + + assert_eq!(opened, Some((Some(restored), None))); + } + + #[gpui::test] + fn a_reopen_with_a_window_up_activates_it_instead_of_opening_another( + cx: &mut gpui::TestAppContext, + ) { + // AppKit also asks while a window is still there but off screen. That + // window is the thing to activate; a second one would take the + // restore away from it. + use gpui::VisualContext as _; + + let (app, mut vcx) = crate::ui::app::test_window::harness(cx); + let handle = vcx.window_handle(); + app.update_in(&mut vcx, |_, _, cx| { + WindowRegistry::init(cx); + let view = WindowView::default(); + let open = view.id; + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![view], + active: Some(open), + }, + ); + WindowRegistry::register(cx, open, handle, app.downgrade()); + + reopen_with(cx, |_, workspace, _| { + panic!("a reopen with a window up opened another for {workspace:?}"); + }); + assert_eq!(WindowRegistry::count(cx), 1); + }); + } + #[gpui::test] fn a_request_carrying_a_path_restores_the_layout_and_brings_the_path_along( cx: &mut gpui::TestAppContext,