Add Cmd+Q quit warning with suppression toggle (#295)

This commit is contained in:
Lawrence Chen 2026-02-21 21:53:39 -08:00 committed by GitHub
parent 8f2a52fbf2
commit 4c733d4e8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 92 additions and 0 deletions

View file

@ -1647,6 +1647,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control)
}
private func handleQuitShortcutWarning() -> Bool {
if !QuitWarningSettings.isEnabled() {
NSApp.terminate(nil)
return true
}
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Quit cmux?"
alert.informativeText = "This will close all windows and workspaces."
alert.addButton(withTitle: "Quit")
alert.addButton(withTitle: "Cancel")
alert.showsSuppressionButton = true
alert.suppressionButton?.title = "Don't warn again for Cmd+Q"
let response = alert.runModal()
if alert.suppressionButton?.state == .on {
QuitWarningSettings.setEnabled(false)
}
if response == .alertFirstButtonReturn {
NSApp.terminate(nil)
}
return true
}
private func handleCustomShortcut(event: NSEvent) -> Bool {
// `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys.
// Most shortcuts below use keyCode fallbacks, so treat nil as "" rather than bailing out.
@ -1698,6 +1724,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return false
}
let normalizedFlags = flags.subtracting([.numericPad, .function])
if normalizedFlags == [.command], chars == "q" {
return handleQuitShortcutWarning()
}
// When the terminal has active IME composition (e.g. Korean, Japanese, Chinese
// input), don't intercept key events let them flow through to the input method.
if let ghosttyView = NSApp.keyWindow?.firstResponder as? GhosttyNSView,

View file

@ -2416,6 +2416,22 @@ enum AppearanceSettings {
}
}
enum QuitWarningSettings {
static let warnBeforeQuitKey = "warnBeforeQuitShortcut"
static let defaultWarnBeforeQuit = true
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: warnBeforeQuitKey) == nil {
return defaultWarnBeforeQuit
}
return defaults.bool(forKey: warnBeforeQuitKey)
}
static func setEnabled(_ isEnabled: Bool, defaults: UserDefaults = .standard) {
defaults.set(isEnabled, forKey: warnBeforeQuitKey)
}
}
enum ClaudeCodeIntegrationSettings {
static let hooksEnabledKey = "claudeCodeHooksEnabled"
static let defaultHooksEnabled = true
@ -2446,6 +2462,7 @@ struct SettingsView: View {
@AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@ -2542,6 +2559,19 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(
"Warn Before Quit",
subtitle: warnBeforeQuitShortcut
? "Show a confirmation before quitting with Cmd+Q."
: "Cmd+Q quits immediately without confirmation."
) {
Toggle("", isOn: $warnBeforeQuitShortcut)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Sidebar Branch Layout",
subtitle: sidebarBranchVerticalLayout
@ -2947,6 +2977,7 @@ struct SettingsView: View {
browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout

View file

@ -886,6 +886,36 @@ final class AppearanceSettingsTests: XCTestCase {
}
}
final class QuitWarningSettingsTests: XCTestCase {
func testDefaultWarnBeforeQuitIsEnabledWhenUnset() {
let suiteName = "QuitWarningSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.removeObject(forKey: QuitWarningSettings.warnBeforeQuitKey)
XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults))
}
func testStoredPreferenceOverridesDefault() {
let suiteName = "QuitWarningSettingsTests.Stored.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(false, forKey: QuitWarningSettings.warnBeforeQuitKey)
XCTAssertFalse(QuitWarningSettings.isEnabled(defaults: defaults))
defaults.set(true, forKey: QuitWarningSettings.warnBeforeQuitKey)
XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults))
}
}
final class UpdateChannelSettingsTests: XCTestCase {
func testResolvedFeedFallsBackWhenInfoFeedMissing() {
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil)