macOS: review windows when quitting (#12742)

Inspired by `Terminal.app` which I think is a nice feature.

First two commits contains some changes in `BaseTerminalController` so
that I can use swift concurrency to review those windows in chain more
easily.



https://github.com/user-attachments/assets/41d92432-4ae0-499e-961a-fc247602f3d7


Works with tabs as well, i forgot to record that.
pull/12779/head
Mitchell Hashimoto 2026-05-22 08:55:14 -07:00 committed by GitHub
commit 52f23fb419
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 116 additions and 36 deletions

View File

@ -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.

View File

@ -316,19 +316,18 @@ class BaseTerminalController: NSWindowController,
savedFrame = .init(window: window.frame, screen: screen.visibleFrame)
}
func confirmClose(
func confirmCloseAsync(
messageText: String,
informativeText: String,
completion: @escaping () -> Void
) {
confirmButtonTitle: String = "Close",
) async -> NSApplication.ModalResponse? {
// If we already have an alert, we need to wait for that one.
guard alert == nil else { return }
guard alert == nil else { return nil }
// If there is no window to attach the modal then we assume success
// since we'll never be able to show the modal.
guard let window else {
completion()
return
return .OK
}
// If we need confirmation by any, show one confirmation for all windows
@ -336,22 +335,35 @@ class BaseTerminalController: NSWindowController,
let alert = NSAlert()
alert.messageText = messageText
alert.informativeText = informativeText
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: confirmButtonTitle)
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window) { response in
let alertWindow = alert.window
// Store our alert so we only ever show one.
self.alert = alert
defer {
// This is important so that we avoid losing focus when Stage
// Manager is used (#8336)
alert.window.orderOut(nil)
self.alert = nil
if response == .alertFirstButtonReturn {
// This is important so that we avoid losing focus when Stage
// Manager is used (#8336)
alertWindow.orderOut(nil)
}
return await alert.beginSheetModal(for: window)
}
func confirmClose(
messageText: String,
informativeText: String,
confirmButtonTitle: String = "Close",
completion: @escaping () -> Void
) {
Task {
guard let response = await confirmCloseAsync(messageText: messageText, informativeText: informativeText, confirmButtonTitle: confirmButtonTitle) else {
completion()
return
}
if [.alertFirstButtonReturn, .OK].contains(response) {
completion()
}
}
// Store our alert so we only ever show one.
self.alert = alert
}
/// Prompt the user to change the tab/window title.
@ -1183,10 +1195,8 @@ class BaseTerminalController: NSWindowController,
// MARK: NSWindowDelegate
// This is called when performClose is called on a window (NOT when close()
// is called directly). performClose is called primarily when UI elements such
// as the "red X" are pressed.
func windowShouldClose(_ sender: NSWindow) -> Bool {
/// Check whether window should be closed without showing an alert
func windowCanBeClosedWithoutConfirmation() -> Bool {
// We must have a window. Is it even possible not to?
guard let window = self.window else { return true }
@ -1199,12 +1209,22 @@ class BaseTerminalController: NSWindowController,
// If our surfaces don't require confirmation, close.
if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true }
return false
}
// This is called when performClose is called on a window (NOT when close()
// is called directly). performClose is called primarily when UI elements such
// as the "red X" are pressed.
func windowShouldClose(_ sender: NSWindow) -> Bool {
guard !windowCanBeClosedWithoutConfirmation() else {
return true
}
// We require confirmation, so show an alert as long as we aren't already.
confirmClose(
messageText: "Close Terminal?",
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
) {
window.close()
) { [weak self] in
self?.window?.close()
}
return false