diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 793ea4b95..53c6c159c 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -10,9 +10,6 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp category: String(describing: AppDelegate.self) ) - // confirmQuit published so other views can check whether quit needs to be confirmed. - @Published var confirmQuit: Bool = false - /// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config. @IBOutlet private var menuReloadConfig: NSMenuItem? @IBOutlet private var menuQuit: NSMenuItem? @@ -113,9 +110,20 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp // If our app says we don't need to confirm, we can exit now. if (!ghostty.needsConfirmQuit) { return .terminateNow } - // We have some visible window, and all our windows will watch the confirmQuit. - confirmQuit = true - return .terminateLater + // We have some visible window. Show an app-wide modal to confirm quitting. + let alert = NSAlert() + alert.messageText = "Quit Ghostty?" + alert.informativeText = "All terminal sessions will be terminated." + alert.addButton(withTitle: "Close Ghostty") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + switch (alert.runModal()) { + case .alertFirstButtonReturn: + return .terminateNow + + default: + return .terminateCancel + } } /// This is called when the application is already open and someone double-clicks the icon diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift index b871585bd..e54276a6f 100644 --- a/macos/Sources/Features/Primary Window/PrimaryView.swift +++ b/macos/Sources/Features/Primary Window/PrimaryView.swift @@ -28,28 +28,6 @@ struct PrimaryView: View { @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle @FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit - // This is true if this view should be the one to show the quit confirmation. - var ownsQuitConfirmation: Bool { - // We need to have a window to show a confirmation. - guard let window = self.window else { return false } - - // If we are the key window then definitely yes. - if (window.isKeyWindow) { return true } - - // If there is some other PrimaryWindow that is key, let it handle it. - let windows = NSApplication.shared.windows - if (windows.contains { - guard let primary = $0 as? PrimaryWindow else { return false } - return primary.isKeyWindow - }) { return false } - - // We aren't the key window but also there is no key PrimaryWindow. - // If we are the FIRST PrimaryWindow in the windows array, then - // we take the job. - guard let firstWindow = (windows.first { $0 is PrimaryWindow }) else { return false } - return window == firstWindow - } - // The title for our window private var title: String { var title = "👻" @@ -73,27 +51,13 @@ struct PrimaryView: View { switch ghostty.readiness { case .loading: Text("Loading") - .onChange(of: appDelegate.confirmQuit) { value in - guard value else { return } - NSApplication.shared.reply(toApplicationShouldTerminate: true) - } case .error: ErrorView() - .onChange(of: appDelegate.confirmQuit) { value in - guard value else { return } - NSApplication.shared.reply(toApplicationShouldTerminate: true) - } case .ready: let center = NotificationCenter.default let gotoTab = center.publisher(for: Ghostty.Notification.ghosttyGotoTab) let toggleFullscreen = center.publisher(for: Ghostty.Notification.ghosttyToggleFullscreen) - let confirmQuitting = Binding(get: { - self.appDelegate.confirmQuit && self.ownsQuitConfirmation - }, set: { - self.appDelegate.confirmQuit = $0 - }) - VStack(spacing: 0) { // If we're running in debug mode we show a warning so that users // know that performance will be degraded. @@ -118,21 +82,6 @@ struct PrimaryView: View { guard let window = self.window else { return } window.title = newValue } - .confirmationDialog( - "Quit Ghostty?", - isPresented: confirmQuitting) { - Button("Close Ghostty") { - NSApplication.shared.reply(toApplicationShouldTerminate: true) - } - .keyboardShortcut(.defaultAction) - - Button("Cancel", role: .cancel) { - NSApplication.shared.reply(toApplicationShouldTerminate: false) - } - .keyboardShortcut(.cancelAction) - } message: { - Text("All terminal sessions will be terminated.") - } } } } diff --git a/macos/Sources/Features/Settings/SettingsView.swift b/macos/Sources/Features/Settings/SettingsView.swift index 379233d55..cfc1256e4 100644 --- a/macos/Sources/Features/Settings/SettingsView.swift +++ b/macos/Sources/Features/Settings/SettingsView.swift @@ -21,10 +21,6 @@ struct SettingsView: View { } .padding() .frame(minWidth: 500, maxWidth: 500, minHeight: 156, maxHeight: 156) - .onChange(of: appDelegate.confirmQuit) { value in - guard value else { return } - NSApplication.shared.reply(toApplicationShouldTerminate: true) - } } } diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 0c90d0b4b..5991b88a7 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -337,9 +337,6 @@ extension Ghostty { /// This will be set to true when the split requests that is become closed. @Binding var requestClose: Bool - /// This controls whether we're actively confirming if we want to close or not. - @State private var confirmClose: Bool = false - var body: some View { let center = NotificationCenter.default let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface) @@ -350,18 +347,6 @@ extension Ghostty { .onReceive(pub) { onNewSplit(notification: $0) } .onReceive(pubClose) { onClose(notification: $0) } .onReceive(pubFocus) { onMoveFocus(notification: $0) } - .confirmationDialog( - "Close Terminal?", - isPresented: $confirmClose) { - Button("Close the Terminal") { - confirmClose = false - requestClose = true - } - .keyboardShortcut(.defaultAction) - } message: { - Text("The terminal still has a running process. If you close the terminal " + - "the process will be killed.") - } } private func onClose(notification: SwiftUI.Notification) { @@ -377,9 +362,35 @@ extension Ghostty { requestClose = true return } - - // Child process is alive, so we want to show a confirmation. - confirmClose = true + + // If we don't have a window to attach our modal to, we also exit immediately. + // This should NOT happen. + guard let window = leaf.surface.window else { + requestClose = true + return + } + + // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog + // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that + // confirmationDialog allows the user to Cmd-W close the alert, but when doing + // so SwiftUI does not update any of the bindings to note that window is no longer + // being shown, and provides no callback to detect this. + let alert = NSAlert() + alert.messageText = "Close Terminal?" + alert.informativeText = "The terminal still has a running process. If you close the " + + "terminal the process will be killed." + alert.addButton(withTitle: "Close the Terminal") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: window, completionHandler: { response in + switch (response) { + case .alertFirstButtonReturn: + requestClose = true + + default: + break + } + }) } private func onNewSplit(notification: SwiftUI.Notification) {