Merge pull request #206 from l0ng-ai/feat/confirm-window-close

feat(settings): let the last-window close confirmation be turned off
This commit is contained in:
l0ng-ai
2026-07-27 10:54:25 +08:00
committed by GitHub
3 changed files with 103 additions and 7 deletions
+37
View File
@@ -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
+20 -7
View File
@@ -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::<Config>().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>) {
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
+46
View File
@@ -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",