diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 67ec9ac4a..a971df9ba 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -404,20 +404,7 @@ class AppDelegate: NSObject, // If our app says we don't need to confirm, we can exit now. if !ghostty.needsConfirmQuit { return .terminateNow } - // 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 - } + return terminate() } func applicationWillTerminate(_ notification: Notification) { @@ -1305,6 +1292,79 @@ extension AppDelegate: NSMenuItemValidation { } } +// MARK: - Termination Flow + +extension AppDelegate { + func terminate() -> NSApplication.TerminateReply { + let controllersNeedConfirmation = NSApplication.shared.windows + .compactMap { $0.windowController as? BaseTerminalController } + .filter { !$0.windowCanBeClosedWithoutConfirmation() } + + guard !controllersNeedConfirmation.isEmpty else { + return .terminateNow + } + + if controllersNeedConfirmation.count == 1 { + Task { + let response = await controllersNeedConfirmation[0].confirmCloseAsync( + messageText: "Quit Ghostty?", + informativeText: "The terminal still has a running process. If you quit, the process will be killed.", + confirmButtonTitle: "Terminate", + ) + + if [.OK, .alertFirstButtonReturn].contains(response) { + await NSApp.reply(toApplicationShouldTerminate: true) + } else { + await NSApp.reply(toApplicationShouldTerminate: false) + } + } + + return .terminateLater + } else { + let alert = NSAlert() + alert.messageText = "You have \(controllersNeedConfirmation.count) windows with running processes. Do you want to review these windows before quitting?" + alert.informativeText = "If you don't review your windows, any running processes will be terminated" + alert.addButton(withTitle: "Review Windows...") + alert.addButton(withTitle: "Terminate Processes") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + + switch alert.runModal() { + case .alertFirstButtonReturn: + reviewWindows(controllersNeedConfirmation) + return .terminateLater + case .alertSecondButtonReturn: + return .terminateNow + default: + return .terminateCancel + } + } + } + + private func reviewWindows(_ controllers: [BaseTerminalController]) { + Task { + for controller in controllers { + let response = await controller.confirmCloseAsync( + messageText: "Quit Ghostty?", + informativeText: "The terminal still has a running process. If you quit, the process will be killed.", + confirmButtonTitle: "Terminate", + ) + + if [.OK, .alertFirstButtonReturn].contains(response) { + // Close this window and until next review is cancelled + await controller.window?.close() + continue + } else { + await NSApp.reply(toApplicationShouldTerminate: false) + // Cancel the review + return + } + } + await NSApp.reply(toApplicationShouldTerminate: true) + } + } +} + /// Represents the state of the quick terminal controller. private enum QuickTerminalState { /// Controller has not been initialized and has no pending restoration state.