diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 5c1059ce..11658e0c 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindPresentation/Strings/Workspace.swift b/Frameworks/TermindPresentation/Strings/Workspace.swift index 735f70fc..7be22432 100644 --- a/Frameworks/TermindPresentation/Strings/Workspace.swift +++ b/Frameworks/TermindPresentation/Strings/Workspace.swift @@ -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 { diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 2998019e..e0c5eda4 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -44,6 +44,14 @@ nonisolated enum HostOpenTarget: Hashable, Sendable { final class AppModel { private var terminationTask: Task? 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 } diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index 3e254d10..08de2fea 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -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) diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 2ae768a9..7111ef1e 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -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() diff --git a/TFM/Platform/MainWindowCoordinator.swift b/TFM/Platform/MainWindowCoordinator.swift index 8e14a17f..c2556210 100644 --- a/TFM/Platform/MainWindowCoordinator.swift +++ b/TFM/Platform/MainWindowCoordinator.swift @@ -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) } diff --git a/TFM/Platform/WorkspaceCloseConfirmation.swift b/TFM/Platform/WorkspaceCloseConfirmation.swift new file mode 100644 index 00000000..3c984e83 --- /dev/null +++ b/TFM/Platform/WorkspaceCloseConfirmation.swift @@ -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() + } +} diff --git a/TFMTests/WorkspaceCloseConfirmationTests.swift b/TFMTests/WorkspaceCloseConfirmationTests.swift new file mode 100644 index 00000000..3d71eac4 --- /dev/null +++ b/TFMTests/WorkspaceCloseConfirmationTests.swift @@ -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) {} + } +}