fix(macos): distinguish quit and window close confirmations
This commit is contained in:
1 parent
7524f3c3f6
commit
dcd750c238
8 files changed
+310
-13
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user