diff --git a/src/core/config.rs b/src/core/config.rs index 02a2ee30..01efaed6 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -184,6 +184,17 @@ pub struct Config { /// `true` by that hint; there is no UI to reset it (nor a reason to). #[serde(default)] pub workspace_detach_hint_seen: bool, + /// Ask before closing the *last* window (the close that also quits the app). + /// On by default, which is the behavior every build so far has had. + /// + /// The prompt was only ever a teaching device, not a safety net: ⌘Q, the + /// tray's Quit and the palette's Quit all leave without asking, and nothing + /// is lost either way — the panes keep running in the daemon. So once the + /// user has learned that (Settings states it permanently under "How sessions + /// work"), being asked on every quit is pure friction. Off makes the last + /// window close exactly like any other: detach the workspace, quit. + #[serde(default = "default_true")] + pub confirm_window_close: bool, /// How the terminal bell (BEL / `^G`) is signalled. Defaults to a brief /// visual flash (the current behavior). #[serde(default, deserialize_with = "de_lenient")] @@ -619,6 +630,7 @@ impl Default for Config { restore_session: true, show_tray_icon: true, workspace_detach_hint_seen: false, + confirm_window_close: true, // Visual flash preserves the pre-config behavior (the bell always // flashed); opting into None/Audible is a deliberate change. bell: BellMode::Visual, @@ -1037,6 +1049,31 @@ mod tests { assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4); } + /// Opt-*out*, unlike most flags here: a config written before this setting + /// existed must keep the prompt, or an update would silently take away the + /// one thing telling people their sessions survive a quit. + #[test] + fn confirm_window_close_defaults_on_and_round_trips() { + assert!(Config::default().confirm_window_close); + + let old: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert!(old.confirm_window_close); + + let off: Config = serde_json::from_str(r#"{"confirm_window_close": false}"#).unwrap(); + assert!(!off.confirm_window_close); + let json = serde_json::to_string(&off).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(!back.confirm_window_close); + + // ...and a key this build has never heard of — a config last written by + // a newer tty7, or hand-edited — must be ignored rather than failing the + // whole parse, which `Config::load` would swallow into *defaults*: the + // opt-out would come back on with nothing said. + let newer: Config = + serde_json::from_str(r#"{"confirm_window_close": false, "not_a_setting": 7}"#).unwrap(); + assert!(!newer.confirm_window_close); + } + #[test] fn theme_follow_system_defaults_and_round_trips() { // Old configs (no follow-system keys) must land on off + the built-in diff --git a/src/ui/app.rs b/src/ui/app.rs index e5fc1eed..d5b06207 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1089,10 +1089,14 @@ impl Tty7App { // // The last window is different: closing it also quits the app (a // windowless process left in the Dock no longer responds to being - // clicked — #147), so that one keeps the reassuring prompt. We veto the - // immediate close (return `false`), show it, and quit only if the user - // picks "Close"; a one-shot flag lets that post-confirm close through - // instead of looping the prompt. + // clicked — #147), so that one keeps the reassuring prompt by default. + // We veto the immediate close (return `false`), show it, and quit only + // if the user picks "Close"; a one-shot flag lets that post-confirm + // close through instead of looping the prompt. + // + // `confirm_window_close` turns the prompt off for users who have learned + // the model — it is teaching, not protection (⌘Q never asked), so it has + // to be escapable. let close_confirmed = std::rc::Rc::new(std::cell::Cell::new(false)); let weak_app = cx.weak_entity(); window.on_window_should_close(cx, move |window, cx| { @@ -1104,9 +1108,11 @@ impl Tty7App { .upgrade() .is_some_and(|app| app.read(cx).tabs.is_empty()); - // Any window but the last, or an empty one with nothing to - // reassure about: detach and go. Prompting here would be friction. - if !last_window || empty { + // Any window but the last, an empty one with nothing to reassure + // about, or a user who has turned the prompt off: detach and go. + // Prompting here would be friction. + let confirm = cx.global::().confirm_window_close; + if !last_window || empty || !confirm { if let Some(app) = weak_app.upgrade() { app.update(cx, |app, cx| app.detach_workspace(cx)); } @@ -2623,6 +2629,13 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.show_tray_icon = on); } + /// Toggle the "Close Window?" prompt on the last window. The close handler + /// reads the flag when it fires, so this applies to the very next ⌘W with no + /// restart and nothing to push to open windows. + pub(crate) fn set_confirm_window_close(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.confirm_window_close = on); + } + // ── Input / Mouse setters ─────────────────────────────────────────────── /// Takes effect on the next keystroke — the terminal reads the flag per diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 26aa1c96..5aa52d0d 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -344,6 +344,14 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Restore last layout", keywords: "restore session previous tabs splits reopen launch startup layout", }, + SearchEntry { + section: WindowTabs, + title: "Confirm before closing the last window", + // Both spellings of the chord: the prompt this turns off is reached + // by ⌘W on macOS and Ctrl-W everywhere else, and the user types + // whichever one their own keyboard just used. + keywords: "close quit confirm prompt dialog ask again warn last window cmd-w ctrl-w", + }, SearchEntry { section: WindowTabs, title: "Show tray icon", @@ -3441,6 +3449,7 @@ impl Tty7App { let restore_session = cfg.restore_session; let remember_window_size = cfg.remember_window_size; let show_tray_icon = cfg.show_tray_icon; + let confirm_window_close = cfg.confirm_window_close; let tab_bar_idx = match cfg.tab_bar_position { TabBarPosition::Top => 0, TabBarPosition::Left => 1, @@ -3505,6 +3514,10 @@ impl Tty7App { .checked(remember_window_size) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_remember_window_size(*on, cx))) .into_any_element(); + let confirm_close_switch = crate::ui::theme::switch("wt-confirm-window-close", cx) + .checked(confirm_window_close) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_confirm_window_close(*on, cx))) + .into_any_element(); let tray_switch = crate::ui::theme::switch("wt-tray-icon", cx) .checked(show_tray_icon) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_show_tray_icon(*on, cx))) @@ -3590,6 +3603,16 @@ impl Tty7App { restore_switch, cx, )) + // Phrased around what stays true either way: the prompt is there to + // teach that closing isn't ending, so the row that turns it off is + // the last chance to say so. + .child(self.settings_row( + "Confirm before closing the last window", + "Ask first, since that close also quits tty7. Off closes straight away — \ + either way your shells keep running in the background.", + confirm_close_switch, + cx, + )) .child(self.settings_row( "Show tray icon", "Keep a status item in the system tray / menu bar: it signals when a \ @@ -4551,6 +4574,28 @@ mod tests { } } + /// The close-confirmation toggle is the one people go looking for *after* + /// the dialog has annoyed them, so it has to be reachable by what they'd + /// type in that moment — not just by its own title. + #[test] + fn close_confirmation_toggle_is_findable() { + // Not a bare "confirm": SSH's own close warning owns that word just as + // legitimately, and the nav's per-section counts are what disambiguate. + for query in [ + "ask again", + "closing the last window", + "dialog", + "cmd-w", + "ctrl-w", + ] { + assert_eq!( + best_matching_section(query).map(|s| s.profile_label()), + Some(SettingsSection::WindowTabs.profile_label()), + "query {query:?} should land on Window & Tabs" + ); + } + } + /// The index names rows, so a title that no longer matches the rendered row /// sends the user to the right page and then leaves them hunting. This /// pins the ones that had drifted (the index said "Working directory"; the @@ -4560,6 +4605,7 @@ mod tests { for title in [ "Start in", "Restore last layout", + "Confirm before closing the last window", "Terminal bell", "Report mouse to apps", "Open files with",