fix(macos): distinguish quit and window close confirmations

This commit is contained in:
Kaede Akino committed 2026-10-10 20:51:57 +08:00
1 parent 7524f3c3f6
commit dcd750c238
8 files changed
+310 -13

No files matched your search

+70 -1
View File
@@ -7897,6 +7897,29 @@
}
}
},
"Close Termind" : {
"extractionState" : "manual",
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termindを閉じる"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "关闭Termind"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "關閉Termind"
}
}
}
},
"Close Transfers" : {
"extractionState" : "manual",
"localizations" : {
@@ -8058,7 +8081,7 @@
}
}
},
"Close this window?" : {
"Close this Window?" : {
"extractionState" : "manual",
"localizations" : {
"ja" : {
@@ -28695,6 +28718,29 @@
}
}
},
"Quit Termind?" : {
"extractionState" : "manual",
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termindを終了しますか?"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出Termind?"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出Termind?"
}
}
}
},
"Quit with unuploaded changes?" : {
"extractionState" : "manual",
"localizations" : {
@@ -28718,6 +28764,29 @@
}
}
},
"Quitting Termind will close all open Workspaces and disconnect their sessions." : {
"extractionState" : "manual",
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termindを終了すると、開いているすべてのワークスペースが閉じられ、セッションが切断されます。"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出Termind将关闭所有打开的工作区并断开其会话连接。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出Termind將關閉所有開啟的工作區並中斷其工作階段連線。"
}
}
}
},
"RTS/CTS" : {
"extractionState" : "manual",
"localizations" : {
@@ -267,7 +267,19 @@ extension AppStrings {
}
public static var closeThisWindowConfirmation: LocalizedStringResource {
"Close this window?"
"Close this Window?"
}
public static var quitTermindConfirmation: LocalizedStringResource {
"Quit Termind?"
}
public static var closeTermind: LocalizedStringResource {
"Close Termind"
}
public static var quitTermindWarning: LocalizedStringResource {
"Quitting Termind will close all open Workspaces and disconnect their sessions."
}
public static var connectionLinkFailed: LocalizedStringResource {
+8
View File
@@ -44,6 +44,14 @@ nonisolated enum HostOpenTarget: Hashable, Sendable {
final class AppModel {
private var terminationTask: Task<Void, Never>?
private var isTerminating = false
var hasOpenWorkspaces: Bool {
windows.values.contains { entry in
guard let window = entry.value, !window.isClosed else { return false }
return !window.workspaces.isEmpty
}
}
private final class WeakWindow {
weak var value: WindowModel?
init(_ value: WindowModel) { self.value = value }
-9
View File
@@ -223,15 +223,6 @@ struct RootView: View {
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
app.connectionHistory.refreshMenuDate()
}
.dismissalConfirmationDialog(
AppStrings.Workspace.closeThisWindowConfirmation, shouldPresent: !window.workspaces.isEmpty
) {
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
.keyboardShortcut(.defaultAction)
Button(AppStrings.Workspace.closeWindow.swiftUIKey, role: .destructive) {}
} message: {
Text(AppStrings.Workspace.closeWindowWarning)
}
// No ancestor drop target: pane and Navigator targets must own internal
// layout drops without competing for the same text payload.
.environment(window)
+5
View File
@@ -469,6 +469,11 @@ struct TermindApp: App {
if awaitingTermination { return .terminateLater }
awaitingTermination = true
Task { @MainActor [weak self] in
if app.hasOpenWorkspaces, !WorkspaceCloseConfirmation.application.confirm() {
self?.awaitingTermination = false
NSApp.reply(toApplicationShouldTerminate: false)
return
}
await app.sftpEdits.recheck()
if app.sftpEdits.model.hasPendingChanges {
let alert = NSAlert()
+6 -2
View File
@@ -590,12 +590,14 @@ final class MainWindowRegistrationView: NSView {
private let coordinator: MainWindowCoordinator
private let model: WindowModel
private let requestID: UUID?
private let closeDelegate: WorkspaceWindowCloseDelegate
private var documentEditedObservation: NSKeyValueObservation?
init(coordinator: MainWindowCoordinator, model: WindowModel, requestID: UUID?) {
self.coordinator = coordinator
self.model = model
self.requestID = requestID
closeDelegate = WorkspaceWindowCloseDelegate(model: model)
super.init(frame: .zero)
}
@@ -604,9 +606,10 @@ final class MainWindowRegistrationView: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
closeDelegate.attach(to: window)
documentEditedObservation = nil
// SwiftUI marks a window with dismissal confirmation as edited. Workspaces
// are not documents; suppress that decoration without changing dismissal.
// Workspaces are not documents; suppress edited decoration without
// changing the native window-close confirmation.
documentEditedObservation = window?.observe(\.isDocumentEdited, options: [.initial, .new]) { window, change in
guard change.newValue == true else { return }
MainActor.assumeIsolated { window.isDocumentEdited = false }
@@ -616,6 +619,7 @@ final class MainWindowRegistrationView: NSView {
func register() {
guard let window else { return }
closeDelegate.attach(to: window)
// Establish the content coordinate system before reading or applying a
// size. A separate bridge could otherwise register before this style.
if !window.styleMask.contains(.fullSizeContentView) { window.styleMask.insert(.fullSizeContentView) }
@@ -0,0 +1,78 @@
import AppKit
import TermindPresentation
enum WorkspaceCloseConfirmation {
case window
case application
func makeAlert() -> NSAlert {
let alert = NSAlert()
switch self {
case .window:
alert.messageText = String(localized: AppStrings.Workspace.closeThisWindowConfirmation)
alert.informativeText = String(localized: AppStrings.Workspace.closeWindowWarning)
case .application:
alert.messageText = String(localized: AppStrings.Workspace.quitTermindConfirmation)
alert.informativeText = String(localized: AppStrings.Workspace.quitTermindWarning)
}
alert.addButton(withTitle: String(localized: AppStrings.Common.cancel))
alert.addButton(
withTitle: String(
localized: self == .window ? AppStrings.Workspace.closeWindow : AppStrings.Workspace.closeTermind))
alert.buttons[0].keyEquivalent = "\r"
alert.buttons[1].keyEquivalent = ""
alert.buttons[1].hasDestructiveAction = true
return alert
}
func confirm(present: (NSAlert) -> NSApplication.ModalResponse = { $0.runModal() }) -> Bool {
present(makeAlert()) == .alertSecondButtonReturn
}
}
/// Window dismissal and application termination have different scopes. Keep the
/// window veto out of SwiftUI's dismissal dialog, which also participates in Quit.
nonisolated final class WorkspaceWindowCloseDelegate: NSObject, NSWindowDelegate {
weak var forwardedDelegate: (any NSWindowDelegate)?
@MainActor private weak var window: NSWindow?
@MainActor private let model: WindowModel
@MainActor private let confirmClose: () -> Bool
@MainActor private var isConfirming = false
@MainActor
init(model: WindowModel, confirmClose: @escaping () -> Bool = { WorkspaceCloseConfirmation.window.confirm() }) {
self.model = model
self.confirmClose = confirmClose
super.init()
}
@MainActor
func attach(to window: NSWindow?) {
if self.window !== window {
if self.window?.delegate === self { self.window?.delegate = forwardedDelegate }
self.window = window
}
guard let window, window.delegate !== self else { return }
forwardedDelegate = window.delegate
window.delegate = self
}
override func responds(to selector: Selector!) -> Bool {
super.responds(to: selector) || forwardedDelegate?.responds(to: selector) == true
}
override func forwardingTarget(for selector: Selector!) -> Any? {
if forwardedDelegate?.responds(to: selector) == true { return forwardedDelegate }
return super.forwardingTarget(for: selector)
}
@MainActor
func windowShouldClose(_ sender: NSWindow) -> Bool {
guard !isConfirming else { return false }
guard forwardedDelegate?.windowShouldClose?(sender) != false else { return false }
guard !model.isClosed, !model.workspaces.isEmpty else { return true }
isConfirming = true
defer { isConfirming = false }
return confirmClose()
}
}
@@ -0,0 +1,130 @@
import AppKit
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@testable import Termind
@Suite(.serialized) @MainActor
struct WorkspaceCloseConfirmationTests {
@Test
func quitAndWindowCloseUseDifferentTitlesActionsAndScope() {
let quit = WorkspaceCloseConfirmation.application.makeAlert()
let close = WorkspaceCloseConfirmation.window.makeAlert()
#expect(quit.messageText == String(localized: AppStrings.Workspace.quitTermindConfirmation))
#expect(quit.informativeText == String(localized: AppStrings.Workspace.quitTermindWarning))
#expect(quit.buttons[1].title == String(localized: AppStrings.Workspace.closeTermind))
#expect(close.messageText == String(localized: AppStrings.Workspace.closeThisWindowConfirmation))
#expect(close.informativeText == String(localized: AppStrings.Workspace.closeWindowWarning))
#expect(close.buttons[1].title == String(localized: AppStrings.Workspace.closeWindow))
for alert in [quit, close] {
#expect(alert.buttons[0].title == String(localized: AppStrings.Common.cancel))
#expect(alert.buttons[0].keyEquivalent == "\r")
#expect(alert.buttons[1].keyEquivalent.isEmpty)
#expect(alert.buttons[1].hasDestructiveAction)
}
}
@Test(arguments: [WorkspaceCloseConfirmation.window, .application])
func onlyExplicitDestructiveConfirmationAllowsClosing(_ scope: WorkspaceCloseConfirmation) {
#expect(!scope.confirm { _ in .alertFirstButtonReturn })
#expect(!scope.confirm { _ in .abort })
#expect(scope.confirm { _ in .alertSecondButtonReturn })
}
@Test
func quitChecksWorkspacesAcrossAllRegisteredWindows() {
let app = AppModel(hosts: HostLibraryModel())
let empty = WindowModel()
let populated = WindowModel(workspaces: [.sshSession(host: Host(name: "Server", host: "server.example"))])
app.registerWindow(empty)
#expect(!app.hasOpenWorkspaces)
app.registerWindow(populated)
#expect(app.hasOpenWorkspaces)
populated.closeForWindow()
#expect(!app.hasOpenWorkspaces)
let other = WindowModel(workspaces: [.sshSession(host: Host(name: "Other", host: "other.example"))])
app.registerWindow(other)
#expect(app.hasOpenWorkspaces)
app.unregisterWindow(other)
#expect(!app.hasOpenWorkspaces)
other.closeForWindow()
}
@Test
func windowVetoPreservesWorkspacesAndCanBeRetried() {
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
let model = WindowModel(workspaces: [workspace])
let native = makeWindow()
let original = WindowDelegate()
native.delegate = original
var approved = false
var confirmations = 0
let proxy = WorkspaceWindowCloseDelegate(model: model) {
confirmations += 1
return approved
}
proxy.attach(to: native)
defer {
proxy.attach(to: nil)
native.close()
model.closeForWindow()
}
#expect(native.delegate?.windowShouldClose?(native) == false)
#expect(confirmations == 1)
#expect(!model.isClosed)
#expect(model.workspaces.first === workspace)
approved = true
#expect(native.delegate?.windowShouldClose?(native) == true)
#expect(confirmations == 2)
original.allowsClose = false
#expect(native.delegate?.windowShouldClose?(native) == false)
#expect(confirmations == 2)
#expect(original.requests == 3)
let selector = #selector(WindowDelegate.windowDidResize(_:))
#expect(proxy.responds(to: selector))
#expect(proxy.forwardingTarget(for: selector) as? WindowDelegate === original)
proxy.attach(to: nil)
#expect(native.delegate === original)
}
@Test
func emptyWindowClosesWithoutConfirmation() {
let model = WindowModel()
let native = makeWindow()
let proxy = WorkspaceWindowCloseDelegate(model: model) {
Issue.record("An empty window should not require confirmation.")
return false
}
proxy.attach(to: native)
defer {
proxy.attach(to: nil)
native.close()
}
#expect(native.delegate?.windowShouldClose?(native) == true)
}
private func makeWindow() -> NSWindow {
let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
return window
}
private final class WindowDelegate: NSObject, NSWindowDelegate {
var allowsClose = true
var requests = 0
func windowShouldClose(_ sender: NSWindow) -> Bool {
requests += 1
return allowsClose
}
func windowDidResize(_ notification: Notification) {}
}
}