diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index debc0e09..3f71d164 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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, diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 3cb9d82c..0fa8eedb 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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 diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7514d1e6..0b48bb1d 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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)