From f2cdaafd3abf8adb806d6afffa966d4cc3fd18a4 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 4 Oct 2026 21:09:22 +0800 Subject: [PATCH] feat: refine confirmation UX --- Frameworks/Resources/Localizable.xcstrings | 255 ++++++++++++++++++ .../MemorySettingsSection.swift | 29 +- .../OperationConfirmation.swift | 112 ++++++++ .../TermindPresentation/Strings/Common.swift | 56 ++++ .../Strings/Settings.swift | 3 + .../TermindPresentation/Strings/Vault.swift | 4 + TFI/App/MobileStartupView.swift | 1 + .../Assistant/MobileAssistantView.swift | 47 ++-- .../HostEditor/MobileHostEditor.swift | 4 + .../Keychain/MobileCredentialsView.swift | 62 +++-- TFI/Features/Keychain/MobileKeyEditor.swift | 2 + .../KnownHosts/MobileKnownHostsView.swift | 23 +- .../Ports/MobileForwardingEditor.swift | 2 + TFI/Features/Ports/MobileForwardingView.swift | 25 +- TFI/Features/SFTP/MobileSFTPBrowser.swift | 29 +- .../Settings/MobileAISettingsView.swift | 2 + TFI/Features/Settings/MobileLoggingView.swift | 14 +- .../Settings/MobileSkillSettings.swift | 2 + .../MobileTerminalHighlightRuleEditor.swift | 2 + .../Snippets/MobileSnippetsView.swift | 70 +++-- .../Transfers/MobileTransfersView.swift | 15 +- TFI/Features/Vault/MobileVaultView.swift | 75 +++--- .../Workspaces/MobileWorkspaceListView.swift | 35 ++- .../Workspaces/MobileWorkspaceView.swift | 28 +- .../MobileConfirmationBinding.swift | 13 + TFI/UI/Components/MobileEditorSheet.swift | 48 ++-- ...MobileOperationConfirmationPresenter.swift | 176 ++++++++++++ TFITests/MobileCardTests.swift | 20 ++ TFITests/MobileEditorToolbarTests.swift | 29 +- .../MobileOperationConfirmationTests.swift | 171 ++++++++++++ TFM/Features/HostEditor/HostEditorView.swift | 4 +- TFM/Features/Hosts/HostsPageView.swift | 8 +- TFM/Features/Hosts/NewGroupSheet.swift | 53 +++- TFM/Features/Keychain/KeychainPageView.swift | 93 +++++-- TFM/Features/Keychain/NewIdentitySheet.swift | 2 +- .../KnownHosts/KnownHostsPageView.swift | 2 +- TFM/Features/Ports/PortForwardingView.swift | 2 +- TFM/Features/Snippets/SnippetsPageView.swift | 52 ++-- TFM/Features/Vault/VaultView.swift | 4 +- TFM/Features/Vault/VaultWorkspaceView.swift | 68 ++++- TFMTests/CardMarqueeSelectionTests.swift | 94 +++++++ Termind.xcodeproj/project.pbxproj | 4 +- 42 files changed, 1434 insertions(+), 306 deletions(-) create mode 100644 Frameworks/TermindPresentation/OperationConfirmation.swift create mode 100644 TFI/UI/Components/MobileConfirmationBinding.swift create mode 100644 TFI/UI/Components/MobileOperationConfirmationPresenter.swift create mode 100644 TFITests/MobileOperationConfirmationTests.swift diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 094d34a4..519c302f 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -3648,6 +3648,227 @@ } } }, + "Are you sure you want to discard this new environment variable?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新环境变量吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新環境變數嗎?" + } + } + } + }, + "Are you sure you want to discard this new group?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新组吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新組嗎?" + } + } + } + }, + "Are you sure you want to discard this new highlight rule?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新高亮规则吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新醒目提示規則嗎?" + } + } + } + }, + "Are you sure you want to discard this new host?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新主机吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新主機嗎?" + } + } + } + }, + "Are you sure you want to discard this new identity?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新身份吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新身分嗎?" + } + } + } + }, + "Are you sure you want to discard this new key?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新密钥吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新金鑰嗎?" + } + } + } + }, + "Are you sure you want to discard this new known host?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新已知主机记录吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新已知主機記錄嗎?" + } + } + } + }, + "Are you sure you want to discard this new model provider?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新模型提供商吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新模型供應商嗎?" + } + } + } + }, + "Are you sure you want to discard this new package?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新包吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新包嗎?" + } + } + } + }, + "Are you sure you want to discard this new port forwarding rule?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新端口转发规则吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新連接埠轉送規則嗎?" + } + } + } + }, + "Are you sure you want to discard this new skill?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新技能吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新技能嗎?" + } + } + } + }, + "Are you sure you want to discard this new snippet?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃这个新片段吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄這個新片段嗎?" + } + } + } + }, + "Are you sure you want to discard your changes?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要放弃更改吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要放棄更改嗎?" + } + } + } + }, "Arguments" : { "extractionState" : "manual", "localizations" : { @@ -14779,6 +15000,23 @@ } } }, + "Keep Editing" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续编辑" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "繼續編輯" + } + } + } + }, "Keep Existing Key" : { "extractionState" : "manual", "localizations" : { @@ -34804,6 +35042,23 @@ } } }, + "You have unsaved changes. Save them before leaving, or discard them." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你有未保存的更改。可以保存后离开,也可以放弃更改。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你有未儲存的更改。可以儲存後離開,也可以放棄更改。" + } + } + } + }, "You stopped after %@" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindPresentation/MemorySettingsSection.swift b/Frameworks/TermindPresentation/MemorySettingsSection.swift index 326bc633..b02c7582 100644 --- a/Frameworks/TermindPresentation/MemorySettingsSection.swift +++ b/Frameworks/TermindPresentation/MemorySettingsSection.swift @@ -20,6 +20,11 @@ public struct MemorySettingsSection: View { self.ai = ai self.layout = layout } + @ViewBuilder private var destroyActions: some View { + Button(AppStrings.Settings.destroy.swiftUIKey, role: .destructive) { Task { await memory.destroy() } } + Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} + } + public var body: some View { Group { if layout == .page { @@ -43,11 +48,25 @@ public struct MemorySettingsSection: View { } } .task { await memory.refresh() } - .confirmationDialog("Destroy Memory?", isPresented: $confirmDestroy, titleVisibility: .visible) { - Button("Destroy", role: .destructive) { Task { await memory.destroy() } } - } message: { - Text(AppStrings.Settings.hermesDestroyDescription) - } + #if os(iOS) + .operationConfirmation( + .init(AppStrings.Settings.destroyMemoryConfirmation), isPresented: $confirmDestroy, + placement: .centered, + actions: [ + .init(AppStrings.Settings.destroy, role: .destructive) { Task { await memory.destroy() } }, + .init(AppStrings.Common.cancel, role: .cancel) {}, + ], + message: .init(AppStrings.Settings.hermesDestroyDescription) + ) + #else + .confirmationDialog( + AppStrings.Settings.destroyMemoryConfirmation, isPresented: $confirmDestroy, titleVisibility: .visible + ) { + destroyActions + } message: { + Text(AppStrings.Settings.hermesDestroyDescription) + } + #endif .sheet(isPresented: Binding(get: { editing != nil }, set: { if !$0 { editing = nil } })) { NavigationStack { Form { diff --git a/Frameworks/TermindPresentation/OperationConfirmation.swift b/Frameworks/TermindPresentation/OperationConfirmation.swift new file mode 100644 index 00000000..f9c6cf56 --- /dev/null +++ b/Frameworks/TermindPresentation/OperationConfirmation.swift @@ -0,0 +1,112 @@ +#if os(iOS) + import SwiftUI + import TermindApplication + + public enum OperationConfirmationPlacement { + case anchored + case centered + } + + public enum OperationConfirmationText { + case localized(LocalizedStringResource) + case verbatim(String) + + public init(_ resource: LocalizedStringResource) { self = .localized(resource) } + public init(_ value: String) { self = .verbatim(value) } + + fileprivate func resolve(locale: Locale) -> String { + switch self { + case .localized(let resource): AppLocalization.resolve(resource, locale: locale) + case .verbatim(let value): value + } + } + } + + public struct OperationConfirmationAction { + let title: LocalizedStringResource + let role: ButtonRole? + let perform: () -> Void + + public init(_ title: LocalizedStringResource, role: ButtonRole? = nil, action: @escaping () -> Void) { + self.title = title + self.role = role + perform = action + } + } + + /// The app provides centered confirmations; anchored confirmations use SwiftUI's native source transition. + public struct OperationConfirmationRequest { + public struct Action { + public let title: String + public let role: ButtonRole? + public let perform: () -> Void + } + + public let title: String + public let message: String? + public let isPresented: Binding + public let placement: OperationConfirmationPlacement + public let actions: [Action] + } + + public typealias OperationConfirmationRenderer = @MainActor @Sendable (OperationConfirmationRequest) -> AnyView + + extension EnvironmentValues { + @Entry public var operationConfirmationRenderer: OperationConfirmationRenderer? = nil + } + + extension View { + public func operationConfirmation( + _ title: OperationConfirmationText, isPresented: Binding, + placement: OperationConfirmationPlacement = .anchored, + actions: [OperationConfirmationAction], message: OperationConfirmationText? = nil + ) -> some View { + modifier( + OperationConfirmation( + title: title, isPresented: isPresented, placement: placement, actions: actions, message: message)) + } + } + + private struct OperationConfirmation: ViewModifier { + let title: OperationConfirmationText + @Binding var isPresented: Bool + let placement: OperationConfirmationPlacement + let actions: [OperationConfirmationAction] + let message: OperationConfirmationText? + @Environment(\.locale) private var locale + @Environment(\.operationConfirmationRenderer) private var renderer + + func body(content: Content) -> some View { + if let renderer, placement == .centered { + content.background { + renderer( + OperationConfirmationRequest( + title: title.resolve(locale: locale), message: message?.resolve(locale: locale), + isPresented: $isPresented, placement: placement, + actions: actions.map { + .init( + title: AppLocalization.resolve($0.title, locale: locale), role: $0.role, + perform: $0.perform) + })) + } + } else { + content.confirmationDialog( + Text(verbatim: title.resolve(locale: locale)), isPresented: $isPresented, + titleVisibility: .visible + ) { + ForEach(actions.indices, id: \.self) { index in + // Keep the explicit Cancel visible in the floating menu; SwiftUI owns outside dismissal. + Button( + role: actions[index].role == .cancel ? nil : actions[index].role, + action: actions[index].perform + ) { + Text(actions[index].title) + } + } + } message: { + if let message { Text(verbatim: message.resolve(locale: locale)) } + } + } + } + } +#endif diff --git a/Frameworks/TermindPresentation/Strings/Common.swift b/Frameworks/TermindPresentation/Strings/Common.swift index a090fd9f..439af512 100644 --- a/Frameworks/TermindPresentation/Strings/Common.swift +++ b/Frameworks/TermindPresentation/Strings/Common.swift @@ -102,6 +102,62 @@ extension AppStrings { "Discard Changes?" } + public static var discardChangesQuestion: LocalizedStringResource { + "Are you sure you want to discard your changes?" + } + + public static var discardNewHostQuestion: LocalizedStringResource { + "Are you sure you want to discard this new host?" + } + + public static var discardNewGroupQuestion: LocalizedStringResource { + "Are you sure you want to discard this new group?" + } + + public static var discardNewPackageQuestion: LocalizedStringResource { + "Are you sure you want to discard this new package?" + } + + public static var discardNewSnippetQuestion: LocalizedStringResource { + "Are you sure you want to discard this new snippet?" + } + + public static var discardNewIdentityQuestion: LocalizedStringResource { + "Are you sure you want to discard this new identity?" + } + + public static var discardNewKeyQuestion: LocalizedStringResource { + "Are you sure you want to discard this new key?" + } + + public static var discardNewKnownHostQuestion: LocalizedStringResource { + "Are you sure you want to discard this new known host?" + } + + public static var discardNewForwardingQuestion: LocalizedStringResource { + "Are you sure you want to discard this new port forwarding rule?" + } + + public static var discardNewProviderQuestion: LocalizedStringResource { + "Are you sure you want to discard this new model provider?" + } + + public static var discardNewSkillQuestion: LocalizedStringResource { + "Are you sure you want to discard this new skill?" + } + + public static var discardNewHighlightRuleQuestion: LocalizedStringResource { + "Are you sure you want to discard this new highlight rule?" + } + + public static var discardNewEnvironmentVariableQuestion: LocalizedStringResource { + "Are you sure you want to discard this new environment variable?" + } + + public static var keepEditing: LocalizedStringResource { + "Keep Editing" + } + public static var done: LocalizedStringResource { "Done" } diff --git a/Frameworks/TermindPresentation/Strings/Settings.swift b/Frameworks/TermindPresentation/Strings/Settings.swift index 2d78697c..e5a0b303 100644 --- a/Frameworks/TermindPresentation/Strings/Settings.swift +++ b/Frameworks/TermindPresentation/Strings/Settings.swift @@ -362,6 +362,9 @@ extension AppStrings { "Automatic learning reviews completed requests after 10 turns or 15 tool calls. Corrections are reviewed sooner. Your configured model may incur API charges." } + public static var destroy: LocalizedStringResource { "Destroy" } + public static var destroyMemoryConfirmation: LocalizedStringResource { "Destroy Memory?" } + public static var hermesDestroyDescription: LocalizedStringResource { "Delete saved memory, learning progress, and search indexes, then turn off Memory. Skills and chat history are kept. Re-enabling Memory can rebuild memories from old chats." } diff --git a/Frameworks/TermindPresentation/Strings/Vault.swift b/Frameworks/TermindPresentation/Strings/Vault.swift index f3e17051..b7cf4fc5 100644 --- a/Frameworks/TermindPresentation/Strings/Vault.swift +++ b/Frameworks/TermindPresentation/Strings/Vault.swift @@ -298,6 +298,10 @@ extension AppStrings { "Save Changes?" } + public static var saveChangesMessage: LocalizedStringResource { + "You have unsaved changes. Save them before leaving, or discard them." + } + public static var serial: LocalizedStringResource { "Serial" } diff --git a/TFI/App/MobileStartupView.swift b/TFI/App/MobileStartupView.swift index ca1208b9..d7ac90c4 100644 --- a/TFI/App/MobileStartupView.swift +++ b/TFI/App/MobileStartupView.swift @@ -29,6 +29,7 @@ struct MobileStartupView: View { MobileQuickActionSceneRoot(app: app) } } + .mobileOperationConfirmations() .modelContainer(app.container) .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) case .failure: diff --git a/TFI/Features/Assistant/MobileAssistantView.swift b/TFI/Features/Assistant/MobileAssistantView.swift index 645f81fb..05df6782 100644 --- a/TFI/Features/Assistant/MobileAssistantView.swift +++ b/TFI/Features/Assistant/MobileAssistantView.swift @@ -142,18 +142,19 @@ struct MobileAssistantView: View { renaming = nil } } - .confirmationDialog( - AppStrings.Assistant.deleteConversation, + .operationConfirmation( + .init(AppStrings.Assistant.deleteConversation), isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), - titleVisibility: .visible - ) { - Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { - if let deleting { Task { await model.delete(deleting) } } - deleting = nil - } - } message: { - Text(AppStrings.Assistant.deleteStagedConversationWarning) - } + placement: .centered, + actions: [ + .init(AppStrings.Common.delete, role: .destructive) { + if let deleting { Task { await model.delete(deleting) } } + deleting = nil + }, + .init(AppStrings.Common.cancel, role: .cancel) { deleting = nil }, + ], + message: .init(AppStrings.Assistant.deleteStagedConversationWarning) + ) .task { await model.load() } .alert( AppStrings.Common.operationFailed, @@ -359,18 +360,6 @@ struct MobileAssistantLibrary: View { renaming = nil } } - .confirmationDialog( - AppStrings.Assistant.deleteConversation, - isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), - titleVisibility: .visible - ) { - Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { - if let deleting { Task { await model.delete(deleting) } } - deleting = nil - } - } message: { - Text(AppStrings.Assistant.deleteStagedConversationWarning) - } } private var values: [ConversationModel] { model.library.search(query, scope: archived ? .archived : .active) } @@ -422,6 +411,18 @@ struct MobileAssistantLibrary: View { ? String(localized: AppStrings.Notifications.unread(conversation.unreadNotificationCount)) : "" ) .accessibilityAddTraits(model.conversation.id == conversation.id ? .isSelected : []) + .operationConfirmation( + .init(AppStrings.Assistant.deleteConversation), + isPresented: $deleting.isPresenting { $0.id == conversation.id }, + actions: [ + .init(AppStrings.Common.delete, role: .destructive) { + if let deleting { Task { await model.delete(deleting) } } + deleting = nil + }, + .init(AppStrings.Common.cancel, role: .cancel) { deleting = nil }, + ], + message: .init(AppStrings.Assistant.deleteStagedConversationWarning) + ) } private func select(_ conversation: ConversationModel) { diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 2e926eaf..19ba9fd4 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -69,6 +69,8 @@ struct MobileHostEditor: View { && (!draft.sshEnabled || draft.environmentVariablesAreValid), topBar: AnyView( HostProtocolProfileControls(protocol: $selectedProtocol).disabled(draft.isTailscaleManaged)), + discardQuestion: app.library.host(original.id) == nil + ? AppStrings.Common.discardNewHostQuestion : AppStrings.Common.discardChangesQuestion, save: { if draft.isTailscaleManaged { guard library.saveTailscaleHost(draft, username: tailscaleUsername) else { @@ -579,6 +581,8 @@ private struct MobileHostEnvironmentVariableEditor: View { isDirty: draft != original, canSave: draft.isValid && !usedVariables.contains(draft.variable), compact: true, + discardQuestion: isNew + ? AppStrings.Common.discardNewEnvironmentVariableQuestion : AppStrings.Common.discardChangesQuestion, save: { didSave(draft) }, content: { LabeledContent(AppStrings.Vault.variable.swiftUIKey) { diff --git a/TFI/Features/Keychain/MobileCredentialsView.swift b/TFI/Features/Keychain/MobileCredentialsView.swift index e748c2a8..015cfd2d 100644 --- a/TFI/Features/Keychain/MobileCredentialsView.swift +++ b/TFI/Features/Keychain/MobileCredentialsView.swift @@ -67,6 +67,22 @@ struct MobileCredentialsView: View { deletingKey = key } } + .operationConfirmation( + .init(AppStrings.Keychain.deleteKeyConfirmation), + isPresented: $deletingKey.isPresenting { $0.id == key.id }, + actions: [ + .init(AppStrings.Common.delete, role: .destructive) { + if let key = deletingKey { + do { try app.credentials.delete(keyID: key.id) } catch { + self.error = error.localizedDescription + } + } + deletingKey = nil + }, + .init(AppStrings.Common.cancel, role: .cancel) { deletingKey = nil }, + ], + message: .init(AppStrings.Keychain.deleteKeyWarning) + ) } } } @@ -90,6 +106,22 @@ struct MobileCredentialsView: View { deletingIdentity = identity } } + .operationConfirmation( + .init(AppStrings.Keychain.deleteIdentityConfirmation), + isPresented: $deletingIdentity.isPresenting { $0.id == identity.id }, + actions: [ + .init(AppStrings.Common.delete, role: .destructive) { + if let identity = deletingIdentity, + !app.credentials.delete(identityID: identity.id) + { + error = app.credentials.storageError + } + deletingIdentity = nil + }, + .init(AppStrings.Common.cancel, role: .cancel) { deletingIdentity = nil }, + ], + message: .init(AppStrings.Keychain.deleteIdentityWarning) + ) } } } @@ -127,34 +159,6 @@ struct MobileCredentialsView: View { library: app.library, model: KeyExportModel(key: key, library: app.library, services: app.keyExportServices)) } - .confirmationDialog( - AppStrings.Keychain.deleteIdentityConfirmation, - isPresented: Binding(get: { deletingIdentity != nil }, set: { if !$0 { deletingIdentity = nil } }), - titleVisibility: .visible - ) { - Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { - if let identity = deletingIdentity, !app.credentials.delete(identityID: identity.id) { - error = app.credentials.storageError - } - deletingIdentity = nil - } - } message: { - Text(AppStrings.Keychain.deleteIdentityWarning) - } - .confirmationDialog( - AppStrings.Keychain.deleteKeyConfirmation, - isPresented: Binding(get: { deletingKey != nil }, set: { if !$0 { deletingKey = nil } }), - titleVisibility: .visible - ) { - Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { - if let key = deletingKey { - do { try app.credentials.delete(keyID: key.id) } catch { self.error = error.localizedDescription } - } - deletingKey = nil - } - } message: { - Text(AppStrings.Keychain.deleteKeyWarning) - } .alert( AppStrings.Common.unableToDelete, isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } }) @@ -407,6 +411,8 @@ struct MobileIdentityEditor: View { identity: draft, password: password, hasConfiguredPassword: original.usesPassword, removesPassword: canEditPassword && password.isEmpty && !originalPassword.isEmpty), + discardQuestion: isNew + ? AppStrings.Common.discardNewIdentityQuestion : AppStrings.Common.discardChangesQuestion, save: { draft.username = draft.username.trimmingCharacters(in: .whitespacesAndNewlines) draft.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/TFI/Features/Keychain/MobileKeyEditor.swift b/TFI/Features/Keychain/MobileKeyEditor.swift index 2eebb155..0eda7336 100644 --- a/TFI/Features/Keychain/MobileKeyEditor.swift +++ b/TFI/Features/Keychain/MobileKeyEditor.swift @@ -16,6 +16,8 @@ struct MobileKeyEditor: View { var body: some View { MobileEditorSheet( title: title, isDirty: model.isDirty, canSave: model.canSave, + discardQuestion: model.mode != .edit + ? AppStrings.Common.discardNewKeyQuestion : AppStrings.Common.discardChangesQuestion, save: { try await model.save { name, verification in try await presenter.register(name: name, userVerification: verification) diff --git a/TFI/Features/KnownHosts/MobileKnownHostsView.swift b/TFI/Features/KnownHosts/MobileKnownHostsView.swift index e7418df3..b1ebae78 100644 --- a/TFI/Features/KnownHosts/MobileKnownHostsView.swift +++ b/TFI/Features/KnownHosts/MobileKnownHostsView.swift @@ -61,6 +61,17 @@ struct MobileKnownHostsView: View { removing = entry } } + .operationConfirmation( + .init(AppStrings.KnownHosts.deleteKnownHostConfirmation), + isPresented: $removing.isPresenting { $0.id == entry.id }, + actions: [ + .init(AppStrings.Common.delete, role: .destructive) { + if let entry = removing { _ = app.knownHosts.delete([entry.id]) } + removing = nil + }, + .init(AppStrings.Common.cancel, role: .cancel) { removing = nil }, + ] + ) } } } @@ -76,16 +87,6 @@ struct MobileKnownHostsView: View { .sheet(item: $editing) { entry in MobileKnownHostEditor(app: app, entry: entry, isNew: app.knownHosts.entry(entry.id) == nil) } - .confirmationDialog( - AppStrings.KnownHosts.deleteKnownHostConfirmation, - isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), - titleVisibility: .visible - ) { - Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { - if let entry = removing { _ = app.knownHosts.delete([entry.id]) } - removing = nil - } - } } } @@ -112,6 +113,8 @@ struct MobileKnownHostEditor: View { MobileEditorSheet( title: editorTitle, isDirty: draft != original, canSave: draft.isValid, + discardQuestion: isNew + ? AppStrings.Common.discardNewKnownHostQuestion : AppStrings.Common.discardChangesQuestion, save: { guard app.knownHosts.save(draft) else { throw MobileEditorError.message( diff --git a/TFI/Features/Ports/MobileForwardingEditor.swift b/TFI/Features/Ports/MobileForwardingEditor.swift index 0cf2be7e..b7dad680 100644 --- a/TFI/Features/Ports/MobileForwardingEditor.swift +++ b/TFI/Features/Ports/MobileForwardingEditor.swift @@ -28,6 +28,8 @@ struct MobileForwardingEditor: View { ? String(localized: AppStrings.Forwarding.newForwarding) : String(localized: AppStrings.Forwarding.editForwarding), isDirty: draft != original, canSave: canSave, + discardQuestion: isNew + ? AppStrings.Common.discardNewForwardingQuestion : AppStrings.Common.discardChangesQuestion, save: { guard canSave, let rule = PortForwardingEditorPolicy.normalized(draft) else { return } draft = try app.forwardingLibrary.save(rule) diff --git a/TFI/Features/Ports/MobileForwardingView.swift b/TFI/Features/Ports/MobileForwardingView.swift index 8be6e71a..1f93395f 100644 --- a/TFI/Features/Ports/MobileForwardingView.swift +++ b/TFI/Features/Ports/MobileForwardingView.swift @@ -53,17 +53,6 @@ struct MobileForwardingView: View { .sheet(item: $editing) { rule in MobileForwardingEditor(app: app, rule: rule, isNew: app.forwardingLibrary.portForwarding(rule.id) == nil) } - .confirmationDialog( - AppStrings.Forwarding.deleteForwardingRulesConfirmation, - isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), - titleVisibility: .visible - ) { - Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { - guard let removing else { return } - do { try app.forwardingLibrary.delete(removing.id) } catch { self.error = error.localizedDescription } - self.removing = nil - } - } .alert( AppStrings.Common.operationFailed, isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } }) @@ -108,6 +97,20 @@ struct MobileForwardingView: View { Divider() Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { removing = rule } } + .operationConfirmation( + .init(AppStrings.Forwarding.deleteForwardingRulesConfirmation), + isPresented: $removing.isPresenting { $0.id == rule.id }, + actions: [ + .init(AppStrings.Common.delete, role: .destructive) { + guard let removing else { return } + do { try app.forwardingLibrary.delete(removing.id) } catch { + self.error = error.localizedDescription + } + self.removing = nil + }, + .init(AppStrings.Common.cancel, role: .cancel) { removing = nil }, + ] + ) } private func canStart(_ rule: PortForwardingRule) -> Bool { PortForwardingEditorPolicy.canStart( diff --git a/TFI/Features/SFTP/MobileSFTPBrowser.swift b/TFI/Features/SFTP/MobileSFTPBrowser.swift index bf8a4f99..cd9c1579 100644 --- a/TFI/Features/SFTP/MobileSFTPBrowser.swift +++ b/TFI/Features/SFTP/MobileSFTPBrowser.swift @@ -65,7 +65,9 @@ struct MobileSFTPBrowser: View { case .parent: parentRow case .file(let file): - fileRow(file) + MobileSFTPFileDeletion(file: file, delete: delete) { requestDelete in + fileRow(file, requestDelete: requestDelete) + } } } if items.isEmpty { @@ -99,7 +101,7 @@ struct MobileSFTPBrowser: View { .accessibilityLabel(AppStrings.SFTP.parentDirectory.swiftUIKey) } - private func fileRow(_ file: RemoteFile) -> some View { + private func fileRow(_ file: RemoteFile, requestDelete: @escaping () -> Void) -> some View { HStack(spacing: 14) { Image(systemName: file.kind.symbol) .font(.system(size: file.isDirectory ? 28 : 25)) @@ -159,12 +161,33 @@ struct MobileSFTPBrowser: View { Button(AppStrings.Common.rename.swiftUIKey, systemImage: "pencil") { rename(file) } Button(AppStrings.SFTP.editPermissions.swiftUIKey, systemImage: "lock.shield") { editPermissions(file) } Divider() - Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { delete(file) } + Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive, action: requestDelete) } .accessibilityHint(file.isDirectory ? Text(AppStrings.Common.open) : Text(AppStrings.SFTP.download)) } } +/// Lives in the hosted file row so its presentation and anchor survive list refreshes together. +private struct MobileSFTPFileDeletion: View { + let file: RemoteFile + let delete: (RemoteFile) -> Void + @ViewBuilder let content: (@escaping () -> Void) -> Content + @Environment(\.locale) private var locale + @State private var confirmsDeletion = false + + var body: some View { + content { confirmsDeletion = true } + .operationConfirmation( + .init(AppStrings.SFTP.deleteConfirmation(file.name)), isPresented: $confirmsDeletion, + actions: [ + .init(AppStrings.Common.delete, role: .destructive) { delete(file) }, + .init(AppStrings.Common.cancel, role: .cancel) {}, + ], + message: .init(AppStrings.SFTP.deletionMessage(paths: file.path, remainingCount: 0, locale: locale)) + ) + } +} + private struct MobileSFTPPathBar: View { @Bindable var channel: MobileSFTPChannelModel let connected: Bool diff --git a/TFI/Features/Settings/MobileAISettingsView.swift b/TFI/Features/Settings/MobileAISettingsView.swift index eb329a48..ca5e40d8 100644 --- a/TFI/Features/Settings/MobileAISettingsView.swift +++ b/TFI/Features/Settings/MobileAISettingsView.swift @@ -214,6 +214,8 @@ struct MobileProviderEditor: View { title: String(localized: AppStrings.Settings.modelProvider), isDirty: model.isDirty, canSave: model.canSave, onCancel: { model.discardPlaintext() }, + discardQuestion: ai.modelProvider(model.original.id) == nil + ? AppStrings.Common.discardNewProviderQuestion : AppStrings.Common.discardChangesQuestion, save: { try model.save() }, content: { ModelProviderConnectionFields( diff --git a/TFI/Features/Settings/MobileLoggingView.swift b/TFI/Features/Settings/MobileLoggingView.swift index ecd592aa..e5a3087a 100644 --- a/TFI/Features/Settings/MobileLoggingView.swift +++ b/TFI/Features/Settings/MobileLoggingView.swift @@ -56,12 +56,14 @@ struct MobileLoggingView: View { exportDocument = nil if case .failure(let error) = result { errorMessage = error.localizedDescription } } - .confirmationDialog( - AppStrings.Settings.deleteAllLoggingFilesConfirmation, isPresented: $showsDeleteAll, - titleVisibility: .visible - ) { - Button(AppStrings.Common.deleteAll.swiftUIKey, role: .destructive) { perform { try logging.deleteAll() } } - } + .operationConfirmation( + .init(AppStrings.Settings.deleteAllLoggingFilesConfirmation), isPresented: $showsDeleteAll, + placement: .centered, + actions: [ + .init(AppStrings.Common.deleteAll, role: .destructive) { perform { try logging.deleteAll() } }, + .init(AppStrings.Common.cancel, role: .cancel) {}, + ] + ) .alert(AppStrings.Settings.logging, isPresented: errorPresented) { Button(AppStrings.Common.ok.swiftUIKey, role: .cancel) { errorMessage = nil } } message: { diff --git a/TFI/Features/Settings/MobileSkillSettings.swift b/TFI/Features/Settings/MobileSkillSettings.swift index 89f483a1..a1ac5915 100644 --- a/TFI/Features/Settings/MobileSkillSettings.swift +++ b/TFI/Features/Settings/MobileSkillSettings.swift @@ -110,6 +110,8 @@ struct MobileSkillEditor: View { MobileEditorSheet( title: String(localized: AppStrings.Settings.skill), isDirty: model.isDirty, canSave: model.canSave, actions: toolbarActions, + discardQuestion: library.skill(model.original.id) == nil + ? AppStrings.Common.discardNewSkillQuestion : AppStrings.Common.discardChangesQuestion, save: { try model.save() }, content: { Section(AppStrings.Settings.skill.swiftUIKey) { diff --git a/TFI/Features/Settings/MobileTerminalHighlightRuleEditor.swift b/TFI/Features/Settings/MobileTerminalHighlightRuleEditor.swift index 2993ab6c..1cbf40ca 100644 --- a/TFI/Features/Settings/MobileTerminalHighlightRuleEditor.swift +++ b/TFI/Features/Settings/MobileTerminalHighlightRuleEditor.swift @@ -26,6 +26,8 @@ struct MobileTerminalHighlightRuleEditor: View { localized: isNew ? AppStrings.TerminalHighlighting.addRule : AppStrings.TerminalHighlighting.editRule), isDirty: rule != original, canSave: canSave, actions: toolbarActions, + discardQuestion: isNew + ? AppStrings.Common.discardNewHighlightRuleQuestion : AppStrings.Common.discardChangesQuestion, save: { guard model.upsert(rule) else { throw MobileEditorError.message( diff --git a/TFI/Features/Snippets/MobileSnippetsView.swift b/TFI/Features/Snippets/MobileSnippetsView.swift index 98efa5dd..193fb75b 100644 --- a/TFI/Features/Snippets/MobileSnippetsView.swift +++ b/TFI/Features/Snippets/MobileSnippetsView.swift @@ -101,7 +101,10 @@ struct MobileSnippetsView: View { editorRequest = SnippetEditorRequest(snippetID: id, packageID: nil) }, duplicate: duplicate, - delete: { deletingSnippetID = id }) + delete: { + app.snippets.delete(snippetIDs: [id]) + reportStorageError() + }) } .sheet(item: $editorRequest) { request in MobileSnippetEditor( @@ -128,33 +131,6 @@ struct MobileSnippetsView: View { pendingTargetExecution = ExecutionRequest(snippetID: request.snippetID, hostIDs: ids) } } - .confirmationDialog( - AppStrings.Snippets.deleteSnippetConfirmation, - isPresented: Binding( - get: { deletingSnippetID != nil }, - set: { if !$0 { deletingSnippetID = nil } }), - titleVisibility: .visible - ) { - Button(AppStrings.Snippets.deleteSnippet.swiftUIKey, role: .destructive, action: deleteSnippet) - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { deletingSnippetID = nil } - } message: { - Text(AppStrings.Snippets.thisActionCannotBeUndone) - } - .confirmationDialog( - packageDeletionTitle, - isPresented: Binding( - get: { deletingPackage != nil }, - set: { if !$0 { deletingPackage = nil } }), - titleVisibility: .visible - ) { - Button(AppStrings.Snippets.deletePackageAndSnippets.swiftUIKey, role: .destructive) { - deletePackage(keepingSnippets: false) - } - Button(AppStrings.Snippets.keepSnippets.swiftUIKey) { deletePackage(keepingSnippets: true) } - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { deletingPackage = nil } - } message: { - Text(packageDeletionMessage) - } .confirmationDialog( AppStrings.Snippets.runADestructiveScriptConfirmation, isPresented: Binding( @@ -241,6 +217,18 @@ struct MobileSnippetsView: View { .frame(width: 44, height: 44) } .accessibilityLabel(AppStrings.Common.more.swiftUIKey) + .operationConfirmation( + .init(packageDeletionTitle), + isPresented: $deletingPackage.isPresenting { $0.id == package.id }, + actions: [ + .init(AppStrings.Snippets.deletePackageAndSnippets, role: .destructive) { + deletePackage(keepingSnippets: false) + }, + .init(AppStrings.Snippets.keepSnippets) { deletePackage(keepingSnippets: true) }, + .init(AppStrings.Common.cancel, role: .cancel) { deletingPackage = nil }, + ], + message: .init(packageDeletionMessage) + ) } else if case .ungrouped = section.id { Menu { Button(AppStrings.Snippets.newSnippet.swiftUIKey, systemImage: "curlybraces") { @@ -285,6 +273,15 @@ struct MobileSnippetsView: View { deletingSnippetID = snippet.id } } + .operationConfirmation( + .init(AppStrings.Snippets.deleteSnippetConfirmation), + isPresented: $deletingSnippetID.isPresenting { $0 == snippet.id }, + actions: [ + .init(AppStrings.Snippets.deleteSnippet, role: .destructive, action: deleteSnippet), + .init(AppStrings.Common.cancel, role: .cancel) { deletingSnippetID = nil }, + ], + message: .init(AppStrings.Snippets.thisActionCannotBeUndone) + ) } private func toggle(_ section: SnippetLibrarySection, expanded: Bool) { @@ -407,6 +404,7 @@ struct MobileSnippetDetailView: View { let duplicate: (Snippet.ID) -> Void let delete: () -> Void @Environment(\.dismiss) private var dismiss + @State private var confirmsDeletion = false private var snippet: Snippet? { app.snippets.snippets.first { $0.id == snippetID } } @@ -450,13 +448,23 @@ struct MobileSnippetDetailView: View { Divider() Button( AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive, - action: delete) + action: { confirmsDeletion = true }) } } } else { ContentUnavailableView(AppStrings.Snippets.noSnippets.swiftUIKey, systemImage: "curlybraces") } } + .operationConfirmation( + .init(AppStrings.Snippets.deleteSnippetConfirmation), + isPresented: $confirmsDeletion, + placement: .centered, + actions: [ + .init(AppStrings.Snippets.deleteSnippet, role: .destructive, action: delete), + .init(AppStrings.Common.cancel, role: .cancel) {}, + ], + message: .init(AppStrings.Snippets.thisActionCannotBeUndone) + ) .onChange(of: app.snippets.snippets.map(\.id)) { if snippet == nil { dismiss() } } @@ -561,6 +569,8 @@ struct MobileSnippetEditor: View { : String(localized: AppStrings.Snippets.newSnippet), isDirty: draft != original, canSave: !trimmedTitle.isEmpty && !trimmedBody.isEmpty, + discardQuestion: !existing + ? AppStrings.Common.discardNewSnippetQuestion : AppStrings.Common.discardChangesQuestion, save: save ) { SnippetOrganizationSection(name: $draft.title, color: $draft.colorTag) { @@ -699,6 +709,8 @@ struct MobileSnippetPackageEditor: View { ? String(localized: AppStrings.Snippets.editPackage) : String(localized: AppStrings.Snippets.newPackage), isDirty: draft != original, canSave: !trimmedName.isEmpty, compact: true, + discardQuestion: !existing + ? AppStrings.Common.discardNewPackageQuestion : AppStrings.Common.discardChangesQuestion, save: save ) { TextField(AppStrings.Common.name.swiftUIKey, text: $draft.name.singleLineText, axis: .horizontal) diff --git a/TFI/Features/Transfers/MobileTransfersView.swift b/TFI/Features/Transfers/MobileTransfersView.swift index 5c713cc4..12693da8 100644 --- a/TFI/Features/Transfers/MobileTransfersView.swift +++ b/TFI/Features/Transfers/MobileTransfersView.swift @@ -38,13 +38,14 @@ struct MobileTransfersView: View { .labelStyle(.iconOnly) } } - .confirmationDialog( - AppStrings.SFTP.clearFinishedTransfersConfirmation, isPresented: $confirmsClearFinished, - titleVisibility: .visible - ) { - Button(AppStrings.SFTP.clearFinished.swiftUIKey, role: .destructive) { transfers.clearFinished() } - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} - } + .operationConfirmation( + .init(AppStrings.SFTP.clearFinishedTransfersConfirmation), isPresented: $confirmsClearFinished, + placement: .centered, + actions: [ + .init(AppStrings.SFTP.clearFinished, role: .destructive) { transfers.clearFinished() }, + .init(AppStrings.Common.cancel, role: .cancel) {}, + ] + ) } @ViewBuilder diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 80e7588a..626e29ee 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -119,17 +119,17 @@ struct MobileConnectionHistoryView: View { .disabled(app.connectionHistory.entries().isEmpty) } } - .confirmationDialog( - AppStrings.Connections.clearConnectionHistoryConfirmation, isPresented: $clearingHistory, - titleVisibility: .visible - ) { - Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive) { - app.connectionHistory.clearAll() - } - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} - } message: { - Text(AppStrings.Workspace.clearHistoryWarning) - } + .operationConfirmation( + .init(AppStrings.Connections.clearConnectionHistoryConfirmation), isPresented: $clearingHistory, + placement: .centered, + actions: [ + .init(AppStrings.Common.clearHistory, role: .destructive) { + app.connectionHistory.clearAll() + }, + .init(AppStrings.Common.cancel, role: .cancel) {}, + ], + message: .init(AppStrings.Workspace.clearHistoryWarning) + ) } @ViewBuilder @@ -505,33 +505,6 @@ struct MobileVaultView: View { group: request.groupID.flatMap(app.library.group), parentID: request.parentID) } - .confirmationDialog( - AppStrings.Vault.deleteHostConfirmation, - isPresented: Binding(get: { deletingHost != nil }, set: { if !$0 { deletingHost = nil } }), - titleVisibility: .visible - ) { - Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { - if let host = deletingHost { app.deleteHost(host.id) } - deletingHost = nil - } - } message: { - Text(AppStrings.Vault.deleteHostWarning) - } - .confirmationDialog( - groupDeletionTitle, - isPresented: Binding( - get: { deletingGroup != nil }, - set: { if !$0 { deletingGroup = nil } }), - titleVisibility: .visible - ) { - Button(AppStrings.Vault.deleteGroupAndHosts.swiftUIKey, role: .destructive) { - deleteGroup(keepingHosts: false) - } - Button(AppStrings.Vault.keepHosts.swiftUIKey) { deleteGroup(keepingHosts: true) } - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { deletingGroup = nil } - } message: { - Text(groupDeletionMessage) - } .mobileReviewPage("vault.hosts", scene: reviewScene) } @@ -719,6 +692,18 @@ struct MobileVaultView: View { .frame(width: 44, height: 44) } .accessibilityLabel(AppStrings.Common.more.swiftUIKey) + .operationConfirmation( + .init(groupDeletionTitle), + isPresented: $deletingGroup.isPresenting { $0.id == group.id }, + actions: [ + .init(AppStrings.Vault.deleteGroupAndHosts, role: .destructive) { + deleteGroup(keepingHosts: false) + }, + .init(AppStrings.Vault.keepHosts) { deleteGroup(keepingHosts: true) }, + .init(AppStrings.Common.cancel, role: .cancel) { deletingGroup = nil }, + ], + message: .init(groupDeletionMessage) + ) } else if case .ungrouped = section.id { Menu { Button(AppStrings.Vault.newHost.swiftUIKey, systemImage: "server.rack") { @@ -831,6 +816,18 @@ struct MobileVaultView: View { } } } + .operationConfirmation( + .init(AppStrings.Vault.deleteHostConfirmation), + isPresented: $deletingHost.isPresenting { $0.id == host.id }, + actions: [ + .init(AppStrings.Common.delete, role: .destructive) { + if let host = deletingHost { app.deleteHost(host.id) } + deletingHost = nil + }, + .init(AppStrings.Common.cancel, role: .cancel) { deletingHost = nil }, + ], + message: .init(AppStrings.Vault.deleteHostWarning) + ) } @ViewBuilder @@ -889,6 +886,8 @@ struct MobileHostGroupEditor: View { title: existing ? String(localized: AppStrings.Vault.editGroup) : String(localized: AppStrings.Vault.newGroup), isDirty: editor.isDirty, canSave: editor.canSave, compact: true, + discardQuestion: !existing + ? AppStrings.Common.discardNewGroupQuestion : AppStrings.Common.discardChangesQuestion, save: save ) { Section { diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index 2e4c288d..1841e301 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -100,24 +100,6 @@ struct MobileWorkspaceListView: View { await Task.yield() scene.activatePendingWorkspace() } - .confirmationDialog( - AppStrings.Workspace.clearRecentConnectionsConfirmation, - isPresented: Binding( - get: { clearingRecentHost != nil }, - set: { if !$0 { clearingRecentHost = nil } } - ), - titleVisibility: .visible - ) { - Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive) { - if let host = clearingRecentHost { - app.connectionHistory.clear(for: host.id) - } - clearingRecentHost = nil - } - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { clearingRecentHost = nil } - } message: { - Text(AppStrings.Workspace.clearHostHistoryWarning) - } } @ViewBuilder @@ -226,9 +208,10 @@ struct MobileWorkspaceListView: View { } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { + Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash") { requestHistoryClear(for: row.host) } + .tint(.red) ForEach( MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: row.host), id: \.self ) { kind in @@ -236,6 +219,20 @@ struct MobileWorkspaceListView: View { .tint(swipeTint(for: kind)) } } + .operationConfirmation( + .init(AppStrings.Workspace.clearRecentConnectionsConfirmation), + isPresented: $clearingRecentHost.isPresenting { $0.id == row.host.id }, + actions: [ + .init(AppStrings.Common.clearHistory, role: .destructive) { + if let host = clearingRecentHost { + app.connectionHistory.clear(for: host.id) + } + clearingRecentHost = nil + }, + .init(AppStrings.Common.cancel, role: .cancel) { clearingRecentHost = nil }, + ], + message: .init(AppStrings.Workspace.clearHostHistoryWarning) + ) } } diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 4b755845..c7d9d63d 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -41,7 +41,6 @@ struct MobileWorkspaceView: View { } @State private var directoryName = "" @State private var creatingDirectory = false - @State private var deleting: RemoteFile? @State private var renaming: RemoteFile? @State private var editingPermissions: RemoteFile? @State private var showingTransfers = false @@ -437,19 +436,6 @@ struct MobileWorkspaceView: View { } Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} } - .confirmationDialog( - deletionTitle, isPresented: deletingPresented, - titleVisibility: .visible - ) { - Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { - if let file = deleting { Task { await workspace.sftpChannel.delete(file) } } - deleting = nil - } - } message: { - if let deleting { - Text(AppStrings.SFTP.deletionMessage(paths: deleting.path, remainingCount: 0, locale: locale)) - } - } } private var workspaceAlerts: some View { @@ -521,7 +507,7 @@ struct MobileWorkspaceView: View { paste: pasteRemoteItems, rename: { renaming = $0 }, editPermissions: { editingPermissions = $0 }, - delete: { deleting = $0 } + delete: { file in Task { await workspace.sftpChannel.delete(file) } } ) .overlay(alignment: .bottom) { SFTPEditUploadOverlay( @@ -774,18 +760,6 @@ struct MobileWorkspaceView: View { ) } - private var deletingPresented: Binding { - Binding( - get: { deleting != nil }, - set: { if !$0 { deleting = nil } } - ) - } - - private var deletionTitle: LocalizedStringResource { - deleting.map { AppStrings.SFTP.deleteConfirmation($0.name) } - ?? AppStrings.Workspace.deleteFileConfirmation - } - private func workspaceTitleMenuLabel(for candidate: MobileWorkspaceDescriptor) -> some View { MobileNotificationMenuLabel( title: candidate.title, diff --git a/TFI/UI/Components/MobileConfirmationBinding.swift b/TFI/UI/Components/MobileConfirmationBinding.swift new file mode 100644 index 00000000..15adc693 --- /dev/null +++ b/TFI/UI/Components/MobileConfirmationBinding.swift @@ -0,0 +1,13 @@ +import SwiftUI + +extension Binding { + /// Only the selected row presents. A dismissed or recycled row cannot clear another row's request. + func isPresenting(matching matches: @escaping (Selection) -> Bool) -> Binding + where Value == Selection? { + Binding( + get: { wrappedValue.map(matches) == true }, + set: { presented in + if !presented, wrappedValue.map(matches) == true { wrappedValue = nil } + }) + } +} diff --git a/TFI/UI/Components/MobileEditorSheet.swift b/TFI/UI/Components/MobileEditorSheet.swift index 1614ff40..3bda67b8 100644 --- a/TFI/UI/Components/MobileEditorSheet.swift +++ b/TFI/UI/Components/MobileEditorSheet.swift @@ -20,10 +20,11 @@ struct MobileEditorSheet: View { var actions: [MobileEditorToolbarAction] = [] var onCancel: () -> Void = {} var topBar: AnyView? = nil + var discardQuestion: LocalizedStringResource = AppStrings.Common.discardChangesQuestion let save: () async throws -> Void @ViewBuilder let content: () -> Content @Environment(\.dismiss) private var dismiss - @State private var discard = false + @State private var showsDiscard = false @State private var error: String? @State private var isSaving = false @State private var saveTask: Task? @@ -51,12 +52,17 @@ struct MobileEditorSheet: View { ToolbarItem(placement: .cancellationAction) { Button(AppStrings.Common.cancel.swiftUIKey, systemImage: "xmark") { if isDirty { - discard = true + showsDiscard = true } else { cancel() } } .labelStyle(.iconOnly).disabled(isSaving) + .confirmationDialog(Text(verbatim: ""), isPresented: $showsDiscard, titleVisibility: .hidden) { + Button(AppStrings.Common.discardChanges.swiftUIKey, role: .destructive, action: cancel) + } message: { + Text(discardQuestion) + } } if !actions.isEmpty { MobileToolbarOverflow { @@ -70,31 +76,13 @@ struct MobileEditorSheet: View { } } ToolbarItem(placement: .confirmationAction) { - Button { - guard !isSaving else { return } - isSaving = true - saveTask = Task { - defer { isSaving = false } - do { - try await save() - try Task.checkCancellation() - dismiss() - } catch is CancellationError { - } catch { self.error = error.localizedDescription } - } - } label: { + Button(action: saveAndDismiss) { Label(AppStrings.Common.save.swiftUIKey, systemImage: "checkmark") }.labelStyle(.iconOnly).accessibilityLabel(Text(AppStrings.Common.save)).disabled( !canSave || isSaving) } .mobileProminentAction() } - .confirmationDialog( - AppStrings.Common.discardChangesConfirmation, isPresented: $discard, - titleVisibility: .visible - ) { - Button(AppStrings.Common.discardChanges.swiftUIKey, role: .destructive, action: cancel) - } .alert( AppStrings.Common.unableToSave, isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } }) @@ -106,10 +94,28 @@ struct MobileEditorSheet: View { } .mobileSheetPresentation(compact: compact) .interactiveDismissDisabled(isDirty || isSaving) + .onChange(of: isDirty) { _, dirty in + if !dirty { showsDiscard = false } + } .onDisappear { saveTask?.cancel() } } + private func saveAndDismiss() { + guard canSave, !isSaving else { return } + isSaving = true + saveTask = Task { + defer { isSaving = false } + do { + try await save() + try Task.checkCancellation() + dismiss() + } catch is CancellationError { + } catch { self.error = error.localizedDescription } + } + } + private func cancel() { + guard !isSaving else { return } onCancel() dismiss() } diff --git a/TFI/UI/Components/MobileOperationConfirmationPresenter.swift b/TFI/UI/Components/MobileOperationConfirmationPresenter.swift new file mode 100644 index 00000000..acd2185b --- /dev/null +++ b/TFI/UI/Components/MobileOperationConfirmationPresenter.swift @@ -0,0 +1,176 @@ +import SwiftUI +import TermindPresentation +import UIKit + +extension View { + func mobileOperationConfirmations() -> some View { + environment(\.operationConfirmationRenderer) { request in + AnyView(MobileOperationConfirmationPresenter(request: request)) + } + } +} + +private struct MobileOperationConfirmationPresenter: UIViewControllerRepresentable { + let request: OperationConfirmationRequest + + func makeUIViewController(context: Context) -> ConfirmationAnchorController { + ConfirmationAnchorController() + } + + func updateUIViewController(_ controller: ConfirmationAnchorController, context: Context) { + controller.update( + title: request.title, message: request.message, isPresented: request.isPresented, + placement: request.placement, actions: request.actions) + } + + static func dismantleUIViewController(_ controller: ConfirmationAnchorController, coordinator: ()) { + controller.removeAnchor() + } +} + +final class ConfirmationAnchorController: UIViewController, UIPopoverPresentationControllerDelegate { + typealias Action = OperationConfirmationRequest.Action + + private var promptTitle = "" + private var message: String? + private var isPresented: Binding? + private var placement = OperationConfirmationPlacement.anchored + private var actions: [Action] = [] + private weak var alert: UIAlertController? + private var presentationTask: Task? + private var isDismissing = false + + override func loadView() { + view = UIView() + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + schedulePresentation() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + if let popover = alert?.popoverPresentationController { position(popover) } + } + + func update( + title: String, message: String?, isPresented: Binding, + placement: OperationConfirmationPlacement, actions: [Action] + ) { + promptTitle = title + self.message = message + self.isPresented = isPresented + self.placement = placement + self.actions = actions + if !isPresented.wrappedValue { + stop() + } else { + schedulePresentation() + } + } + + func stop() { + presentationTask?.cancel() + presentationTask = nil + if let alert { dismiss(alert) {} } + } + + func removeAnchor() { + let presentation = isPresented + stop() + Task { @MainActor in presentation?.wrappedValue = false } + } + + private func schedulePresentation() { + guard isPresented?.wrappedValue == true, alert == nil, !isDismissing, presentationTask == nil else { return } + presentationTask = Task { @MainActor [weak self] in + await Task.yield() + guard let self, !Task.isCancelled else { return } + presentationTask = nil + presentConfirmation() + } + } + + private func presentConfirmation() { + guard isPresented?.wrappedValue == true, alert == nil, !isDismissing, view.window != nil else { return } + var presenter = view.window?.rootViewController + while let presented = presenter?.presentedViewController { presenter = presented } + // Context menus and swipe actions must finish closing before presenting another controller. + if let transition = presenter?.transitionCoordinator { + transition.animate(alongsideTransition: nil) { [weak self] _ in self?.schedulePresentation() } + return + } + let alert = UIAlertController(title: promptTitle, message: message, preferredStyle: .actionSheet) + alert.modalPresentationStyle = .popover + for action in actions { + // Popovers omit UIKit's cancel row. Keep Cancel as a visible, vertically stacked action. + let style: UIAlertAction.Style = action.role == .destructive ? .destructive : .default + alert.addAction( + UIAlertAction(title: action.title, style: style) { [weak self, weak alert] _ in + guard let self, let alert else { return } + performAfterDismissal(action.perform, from: alert) + }) + } + guard let popover = alert.popoverPresentationController else { return } + popover.delegate = self + position(popover) + self.alert = alert + present(alert, animated: true) + } + + func performAfterDismissal(_ action: @escaping () -> Void, from alert: UIAlertController) { + dismiss(alert) { [weak self] in + action() + self?.isPresented?.wrappedValue = false + } + } + + private func dismiss(_ alert: UIAlertController, completion: @escaping () -> Void) { + guard !isDismissing else { return } + isDismissing = true + let finish = { [weak self] in + self?.alert = nil + // Keep the source row alive until the native exit transition has finished. + completion() + self?.isDismissing = false + self?.schedulePresentation() + } + if alert.isBeingDismissed { + if let transition = alert.transitionCoordinator { + transition.animate(alongsideTransition: nil) { _ in finish() } + } else { + finish() + } + } else if alert.presentingViewController != nil { + alert.dismiss(animated: true, completion: finish) + } else { + finish() + } + } + + private func position(_ popover: UIPopoverPresentationController) { + switch placement { + case .anchored: + popover.sourceView = view + popover.sourceRect = view.bounds + case .centered: + guard let window = view.window else { return } + popover.sourceView = window + popover.sourceRect = CGRect(x: window.bounds.midX, y: window.bounds.midY, width: 0, height: 0) + } + popover.permittedArrowDirections = [] + } + + func adaptivePresentationStyle( + for controller: UIPresentationController, traitCollection: UITraitCollection + ) -> UIModalPresentationStyle { .none } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard presentationController.presentedViewController === alert, !isDismissing else { return } + alert = nil + isPresented?.wrappedValue = false + } +} diff --git a/TFITests/MobileCardTests.swift b/TFITests/MobileCardTests.swift index a8ef727f..af372a1a 100644 --- a/TFITests/MobileCardTests.swift +++ b/TFITests/MobileCardTests.swift @@ -12,6 +12,26 @@ import UIKit @Suite @MainActor struct MobileCardTests { + @Test func dismissingAnOldRowDoesNotCancelAnotherRowsConfirmation() { + var selectedID: UUID? + let firstID = UUID() + let secondID = UUID() + let selection = Binding(get: { selectedID }, set: { selectedID = $0 }) + let first = selection.isPresenting { $0 == firstID } + let second = selection.isPresenting { $0 == secondID } + + selectedID = firstID + #expect(first.wrappedValue && !second.wrappedValue) + selectedID = secondID + first.wrappedValue = false + #expect(!first.wrappedValue && second.wrappedValue) + #expect(selectedID == secondID) + + second.wrappedValue = false + #expect(selectedID == nil) + #expect(!first.wrappedValue && !second.wrappedValue) + } + @Test func workspaceConnectionIndicatorHidesDisconnectedWorkspaces() { #expect( MobileWorkspaceConnectionIndicator.resolve( diff --git a/TFITests/MobileEditorToolbarTests.swift b/TFITests/MobileEditorToolbarTests.swift index a277831b..3acbc79a 100644 --- a/TFITests/MobileEditorToolbarTests.swift +++ b/TFITests/MobileEditorToolbarTests.swift @@ -1,5 +1,7 @@ import Foundation import SwiftUI +import TermindApplication +import TermindPresentation import Testing import UIKit @@ -75,18 +77,35 @@ struct MobileEditorToolbarTests { } } - @Test(arguments: [false, true]) - func cancelOnlyDismissesUnmodifiedDrafts(isDirty: Bool) async throws { + @Test(arguments: [false, true], ["en", "zh-Hans", "zh-Hant"]) + func cancelOnlyDismissesUnmodifiedDrafts(isDirty: Bool, language: String) async throws { let state = EditorState() state.isDirty = isDirty - try await withEditor(state) { controller, navigation in + try await withEditor(state, language: language) { controller, navigation in try activate(#require(cancelButton(in: navigation))) try await settle() #expect(state.isPresented == isDirty) #expect(state.saveCount == 0) if isDirty { let alert = try #require(descendant(of: UIAlertController.self, in: controller)) - #expect(alert.actions.contains { $0.style == .destructive }) + #expect(alert.preferredStyle == .actionSheet) + #expect(alert.title?.isEmpty != false) + let locale = Locale(identifier: language) + #expect( + alert.message == AppLocalization.resolve(AppStrings.Common.discardChangesQuestion, locale: locale)) + let actions = alert.actions.filter { $0.style != .cancel } + #expect(actions.count == 1) + #expect(actions.first?.style == .destructive) + #expect( + actions.first?.title == AppLocalization.resolve(AppStrings.Common.discardChanges, locale: locale)) + #expect(saveButton(in: navigation) != nil) + #expect(state.cancelCount == 0) + if let window = controller.view.window { + let data = UIGraphicsImageRenderer(bounds: window.bounds).pngData { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + Attachment.record(data, named: "discard-confirmation-\(language).png") + } } } } @@ -207,6 +226,7 @@ struct MobileEditorToolbarTests { var showsActions = false var title = "Example" var saveCount = 0 + var cancelCount = 0 var pendingSave: CheckedContinuation? func finishSave() { @@ -231,6 +251,7 @@ private struct EditorHost: View { id: "delete", title: "Delete", systemImage: "trash", role: .destructive, startsGroup: true, perform: {}), ] : [], + onCancel: { state.cancelCount += 1 }, save: { state.saveCount += 1 await withCheckedContinuation { state.pendingSave = $0 } diff --git a/TFITests/MobileOperationConfirmationTests.swift b/TFITests/MobileOperationConfirmationTests.swift new file mode 100644 index 00000000..40bc9d04 --- /dev/null +++ b/TFITests/MobileOperationConfirmationTests.swift @@ -0,0 +1,171 @@ +import SwiftUI +import TermindApplication +import TermindPresentation +import Testing +import UIKit + +@testable import TermindIOS + +@Suite( + .enabled( + if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1", + "Set TERMIND_MOBILE_UI_TESTS=1 and disable parallel testing for key-window integration tests"), + .serialized) +@MainActor +struct MobileOperationConfirmationTests { + @Test(arguments: [false, true], ["en", "zh-Hans", "zh-Hant"]) + func nativeConfirmationUsesItsSourceAndStacksActions(centered: Bool, language: String) async throws { + let state = ConfirmationFixtureState() + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.keyWindow + let window = UIWindow(windowScene: scene) + let host = UIHostingController( + rootView: ConfirmationFixture(state: state, centered: centered) + .environment(\.locale, Locale(identifier: language)) + .mobileOperationConfirmations()) + window.rootViewController = host + window.windowLevel = .alert + 1 + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + } + try await Task.sleep(for: .milliseconds(400)) + state.isPresented = true + for _ in 0..<100 where findAlert(host)?.transitionCoordinator == nil { + try await Task.sleep(for: .milliseconds(5)) + } + try await Task.sleep(for: .milliseconds(60)) + let entering = try #require(findAlert(host)) + if UIView.areAnimationsEnabled && !UIAccessibility.isReduceMotionEnabled { + #expect(entering.isBeingPresented) + } + try await Task.sleep(for: .milliseconds(700)) + let alert = try #require(findAlert(host)) + #expect(alert.preferredStyle == .actionSheet) + #expect(alert.actions.filter { $0.style != .cancel }.count == 2) + #expect(alert.actions.first?.style == .destructive) + #expect(state.performedCount == 0) + let popover = try #require(alert.popoverPresentationController) + let source = try #require(popover.sourceView) + let anchor = source.convert(popover.sourceRect, to: window) + if centered { + #expect(popover.permittedArrowDirections.isEmpty) + #expect(abs(anchor.midX - window.bounds.midX) < 1) + #expect(abs(anchor.midY - window.bounds.midY) < 1) + } else { + #expect(findAnchor(host) == nil) + #expect(source !== window) + #expect(abs(anchor.midX - state.sourceFrame.midX) < 1) + #expect(abs(anchor.midY - state.sourceFrame.midY) < 1) + #expect(abs(anchor.width - state.sourceFrame.width) < 1) + } + let locale = Locale(identifier: language) + let confirmTitle = AppLocalization.resolve( + centered ? AppStrings.Common.clearHistory : AppStrings.Common.delete, locale: locale) + let cancelTitle = AppLocalization.resolve(AppStrings.Common.cancel, locale: locale) + let confirmLabel = try #require(labels(alert.view).first { $0.text == confirmTitle }) + let cancelLabel = try #require(labels(alert.view).first { $0.text == cancelTitle }) + let confirmFrame = confirmLabel.convert(confirmLabel.bounds, to: window) + let cancelFrame = cancelLabel.convert(cancelLabel.bounds, to: window) + #expect(confirmFrame.maxY < cancelFrame.minY) + #expect(abs(confirmFrame.midX - cancelFrame.midX) < 2) + let data = UIGraphicsImageRenderer(bounds: window.bounds).pngData { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + Attachment.record(data, named: "confirmation-\(centered ? "centered" : "anchored")-\(language).png") + state.isPresented = false + try await Task.sleep(for: .milliseconds(60)) + if UIView.areAnimationsEnabled && !UIAccessibility.isReduceMotionEnabled { + #expect(alert.isBeingDismissed) + #expect((alert.transitionCoordinator?.transitionDuration ?? 0) > 0) + } + try await Task.sleep(for: .milliseconds(700)) + #expect(findAlert(host) == nil) + #expect(state.performedCount == 0) + + state.isPresented = true + try await Task.sleep(for: .milliseconds(700)) + let reopened = try #require(findAlert(host)) + if centered { + let presenter = try #require(findAnchor(host)) + presenter.performAfterDismissal({ state.performedCount += 1 }, from: reopened) + } else { + state.isPresented = false + } + if UIView.areAnimationsEnabled && !UIAccessibility.isReduceMotionEnabled { + #expect(state.performedCount == 0) + } + try await Task.sleep(for: .milliseconds(900)) + #expect(!state.isPresented) + #expect(findAlert(host) == nil) + #expect(state.performedCount == (centered ? 1 : 0)) + } + + private func findAlert(_ controller: UIViewController) -> UIAlertController? { + if let alert = controller as? UIAlertController { return alert } + if let presented = controller.presentedViewController, let alert = findAlert(presented) { return alert } + for child in controller.children { + if let alert = findAlert(child) { return alert } + } + return nil + } + + private func labels(_ view: UIView) -> [UILabel] { + (view as? UILabel).map { [$0] } ?? view.subviews.flatMap(labels) + } + + private func findAnchor(_ controller: UIViewController) -> ConfirmationAnchorController? { + if let anchor = controller as? ConfirmationAnchorController { return anchor } + for child in controller.children { + if let anchor = findAnchor(child) { return anchor } + } + return nil + } +} + +@Observable @MainActor private final class ConfirmationFixtureState { + var isPresented = false + var sourceFrame: CGRect = .zero + var performedCount = 0 +} + +private struct ConfirmationFixture: View { + @Bindable var state: ConfirmationFixtureState + let centered: Bool + + var body: some View { + VStack(spacing: 16) { + Text("Vault").font(.largeTitle.bold()) + Spacer() + Button("Example host") { state.isPresented = true } + .frame(width: 260, height: 92) + .background(.blue.opacity(0.08), in: .rect(cornerRadius: 20)) + .onGeometryChange(for: CGRect.self) { + $0.frame(in: .global) + } action: { + state.sourceFrame = $0 + } + .operationConfirmation( + .init( + centered + ? AppStrings.Connections.clearConnectionHistoryConfirmation + : AppStrings.Vault.deleteHostConfirmation), + isPresented: $state.isPresented, placement: centered ? .centered : .anchored, + actions: [ + .init(centered ? AppStrings.Common.clearHistory : AppStrings.Common.delete, role: .destructive) + { + state.performedCount += 1 + }, + .init(AppStrings.Common.cancel, role: .cancel) {}, + ], + message: .init( + centered ? AppStrings.Workspace.clearHistoryWarning : AppStrings.Vault.deleteHostWarning) + ) + Spacer().frame(height: 140) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemGroupedBackground)) + } +} diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 415dd034..08aeb909 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -883,7 +883,7 @@ struct HostEditorView: View { } private func cancel() { - editingSession.requestTransition( + editingSession.requestCancellation( route: .hostEditor(hostID, groupID: initialGroupID), action: closeTab) } @@ -944,7 +944,7 @@ private struct HostEnvironmentVariableEditor: View { HStack { Spacer() Button(AppStrings.Common.cancel.swiftUIKey, action: back).keyboardShortcut(.cancelAction) - Button(original == nil ? AppStrings.Common.add.swiftUIKey : AppStrings.Common.save.swiftUIKey) { + Button(AppStrings.Common.save.swiftUIKey) { save(draft) back() } diff --git a/TFM/Features/Hosts/HostsPageView.swift b/TFM/Features/Hosts/HostsPageView.swift index ad971a12..11c193a5 100644 --- a/TFM/Features/Hosts/HostsPageView.swift +++ b/TFM/Features/Hosts/HostsPageView.swift @@ -223,8 +223,12 @@ struct HostsPageView: ManagedCardPage { } func managedBlankAreaPrimaryAction() { - guard let route = window.vaultWorkRoute, case .hostEditor = route else { return } - editingSession.requestTransition(route: route, action: window.closeVaultWork) + guard let route = window.vaultWorkRoute else { return } + switch route { + case .hostEditor, .hostGroupEditor: + editingSession.requestTransition(route: route, action: window.closeVaultWork) + default: break + } } /// Every tag in use, each one a toggle, plus a way out. diff --git a/TFM/Features/Hosts/NewGroupSheet.swift b/TFM/Features/Hosts/NewGroupSheet.swift index a79117b6..baa08faf 100644 --- a/TFM/Features/Hosts/NewGroupSheet.swift +++ b/TFM/Features/Hosts/NewGroupSheet.swift @@ -19,6 +19,7 @@ struct NewGroupSheet: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window + @Environment(VaultWorkEditingSession.self) private var editingSession @Environment(\.locale) private var locale @Environment(\.dismiss) private var dismiss @@ -26,6 +27,7 @@ struct NewGroupSheet: View { @State private var saveError: String? @State private var saveTask: Task? @State private var confirmsDiscard = false + @State private var editingToken: UUID? @State private var confirmsDeletion = false init( @@ -63,8 +65,16 @@ struct NewGroupSheet: View { } footer: { footer } - .onAppear(perform: load) + .onAppear { + load() + guard requestedClose != nil else { return } + editingToken = editingSession.register( + route: editingRoute, isDirty: { editor?.isDirty == true }, + isBusy: { editor?.isSaving == true } + ) { await commitChanges() } + } .onDisappear { + if let editingToken { editingSession.unregister(editingToken) } saveTask?.cancel() editor?.cancel() } @@ -72,8 +82,12 @@ struct NewGroupSheet: View { AppStrings.Common.discardChangesConfirmation, isPresented: $confirmsDiscard, titleVisibility: .visible ) { + Button(AppStrings.Common.keepEditing.swiftUIKey, role: .cancel) {} + .keyboardShortcut(.defaultAction) Button(AppStrings.Common.discardChanges.swiftUIKey, role: .destructive, action: close) - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} + } message: { + Text( + isEditing ? AppStrings.Common.discardChangesQuestion : AppStrings.Common.discardNewGroupQuestion) } .confirmationDialog( AppStrings.Common.deleteNamedConfirmation(group?.name ?? ""), isPresented: $confirmsDeletion, @@ -134,7 +148,7 @@ struct NewGroupSheet: View { .keyboardShortcut(.cancelAction) .disabled(editor?.isSaving == true) - Button(isEditing ? AppStrings.Common.save.swiftUIKey : AppStrings.Common.add.swiftUIKey, action: add) + Button(AppStrings.Common.save.swiftUIKey, action: saveAndClose) .keyboardShortcut(.defaultAction) .disabled(editor?.canSave != true) } @@ -149,13 +163,19 @@ struct NewGroupSheet: View { private func cancelEditing() { guard editor?.isSaving != true else { return } - if editor?.isDirty == true { + if requestedClose != nil { + editingSession.requestCancellation(route: editingRoute, action: close) + } else if editor?.isDirty == true { confirmsDiscard = true } else { close() } } + private var editingRoute: VaultWorkRoute { + .hostGroupEditor(groupID: group?.id, parentID: initialParentID) + } + private func delete(keepHosts: Bool) { guard let group, editor?.isSaving != true else { return } app.deleteHostGroup(group.id, keepHosts: keepHosts) @@ -172,14 +192,26 @@ struct NewGroupSheet: View { editor = TailscaleGroupEditor(model: app.tailscale, group: group, parentID: initialParentID) } - private func add() { + private func saveAndClose() { guard let editor, editor.canSave else { return } saveTask = Task { - do { - let saved = try await editor.save() - create(saved) - close() - } catch { saveError = error.localizedDescription } + if await commitChanges() { close() } + } + } + + private func commitChanges() async -> Bool { + guard let editor, !editor.isSaving else { return false } + guard editor.canSave else { + saveError = String(localized: AppStrings.Common.completeAllRequiredFieldsBeforeSaving) + return false + } + do { + create(try await editor.save()) + saveError = nil + return true + } catch { + saveError = error.localizedDescription + return false } } @@ -189,4 +221,5 @@ struct NewGroupSheet: View { NewGroupSheet(create: { _ in }) .environment(AppModel(hosts: .demo())) .environment(WindowModel()) + .environment(VaultWorkEditingSession()) } diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index 3985dc21..e1a4cbb9 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -269,7 +269,7 @@ struct KeychainPageView: ManagedCardPage { private func isEditableRoute(_ route: VaultWorkRoute) -> Bool { switch route { - case .editKey, .identityEditor: true + case .editKey, .identityEditor, .importKey, .generateKey: true default: false } } @@ -476,6 +476,7 @@ private struct SoftwareKeyEditor: View { @State private var reference = "" @State private var referenceDropTask: Task? let existing: SSHKeyRef? + let initialStorage: KeyStorage let title: LocalizedStringResource let symbol: String let close: () -> Void @@ -531,6 +532,7 @@ private struct SoftwareKeyEditor: View { ) { self.saveSecurityKey = saveSecurityKey self.existing = existing + initialStorage = existing?.storage ?? defaultStorage self.title = title self.symbol = symbol self.close = close @@ -549,9 +551,7 @@ private struct SoftwareKeyEditor: View { var body: some View { VaultDetailScaffold( title: title, symbol: symbol, - close: { - if existing == nil { close() } else { cancelEditing() } - }, + close: cancelEditing, closeLabel: existing == nil ? AppStrings.Common.close : AppStrings.Common.cancel ) { if isLoading { @@ -653,9 +653,9 @@ private struct SoftwareKeyEditor: View { } Spacer() if isWorking { ProgressView().controlSize(.small) } - Button(AppStrings.Common.cancel.swiftUIKey) { - if existing == nil { close() } else { cancelEditing() } - } + Button(AppStrings.Common.cancel.swiftUIKey, action: cancelEditing) + .keyboardShortcut(.cancelAction) + .disabled(isWorking) Button(existing == nil ? AppStrings.Common.import.swiftUIKey : AppStrings.Common.save.swiftUIKey) { Task { guard await commitChanges() else { return } @@ -697,9 +697,8 @@ private struct SoftwareKeyEditor: View { ) .task { load() - guard let existing else { return } editingToken = editingSession.register( - route: .editKey(existing.id), isDirty: { isDirty } + route: editingRoute, isDirty: { isDirty }, isBusy: { isWorking } ) { await commitChanges() } @@ -798,6 +797,8 @@ private struct SoftwareKeyEditor: View { private var isDirty: Bool { guard let existing else { return !name.isEmpty || !privateKey.isEmpty || !publicKey.isEmpty || !certificate.isEmpty + || colorTag != .none || storage != initialStorage || !passphrase.isEmpty || !savePassphrase + || usesWebAuthn || !reference.isEmpty || verification != .preferred } return name != existing.label || colorTag != existing.colorTag || storage != existing.storage || privateKey != originalPrivateKey || passphrase != originalPassphrase @@ -806,14 +807,16 @@ private struct SoftwareKeyEditor: View { } private func cancelEditing() { - guard let existing else { - close() - return - } - editingSession.requestTransition(route: .editKey(existing.id), action: close) + guard !isWorking else { return } + editingSession.requestCancellation(route: editingRoute, action: close) + } + + private var editingRoute: VaultWorkRoute { + existing.map { .editKey($0.id) } ?? .importKey } private func commitChanges() async -> Bool { + guard !isWorking, !isLoading else { return false } guard existing == nil || isDirty else { return true } isWorking = true errorMessage = nil @@ -1082,6 +1085,7 @@ private struct GenerateKeySheet: View { } let credentials: CredentialLibraryModel + let initialStorage: KeyStorage let selectKey: (SSHKeyRef) -> Void @State private var usesWebAuthn = false @State private var verification: SecurityKeyCredential.UserVerification = .preferred @@ -1108,6 +1112,8 @@ private struct GenerateKeySheet: View { @State private var requiresUserPresence = false @State private var isWorking = false @State private var errorMessage: String? + @State private var editingToken: UUID? + @Environment(VaultWorkEditingSession.self) private var editingSession init( credentials: CredentialLibraryModel, @@ -1121,6 +1127,7 @@ private struct GenerateKeySheet: View { close: @escaping () -> Void ) { self.credentials = credentials + initialStorage = defaultStorage self.selectKey = selectKey self.generate = generate self.close = close @@ -1140,8 +1147,29 @@ private struct GenerateKeySheet: View { passphrase.isEmpty || passphrase == confirmation } + private var isDirty: Bool { + !label.isEmpty || colorTag != .none || storage != initialStorage + || kind != .ed25519 || curveBits != 256 || rsaBits != 3072 + || !passphrase.isEmpty || !confirmation.isEmpty || !savePassphrase + || cipher != .aes256 || rounds != 100 || requiresUserPresence + || usesWebAuthn || verification != .preferred + } + + private var canGenerate: Bool { + !isWorking + && (usesWebAuthn + || (storage == .secureEnclave + ? SecureEnclaveKey.isAvailable + : passwordsMatch && (passphrase.isEmpty || rounds > 0))) + } + + private func cancelEditing() { + guard !isWorking else { return } + editingSession.requestCancellation(route: .generateKey, action: close) + } + var body: some View { - VaultDetailScaffold(title: AppStrings.Keychain.generateKey, symbol: "key.fill", close: close) { + VaultDetailScaffold(title: AppStrings.Keychain.generateKey, symbol: "key.fill", close: cancelEditing) { Form { Section { VaultNameField(text: $label, prompt: Text(defaultName)) @@ -1213,22 +1241,27 @@ private struct GenerateKeySheet: View { HStack { Spacer() if isWorking { ProgressView().controlSize(.small) } - Button(AppStrings.Common.cancel.swiftUIKey, action: close).keyboardShortcut(.cancelAction) + Button(AppStrings.Common.cancel.swiftUIKey, action: cancelEditing) + .keyboardShortcut(.cancelAction) + .disabled(isWorking) Button(AppStrings.Keychain.generate.swiftUIKey) { - generationTask = Task { await commit() } + generationTask = Task { + if await commitChanges() { close() } + } } .keyboardShortcut(.defaultAction) - .disabled( - isWorking - || (!usesWebAuthn && storage != .secureEnclave - && (!passwordsMatch || (!passphrase.isEmpty && rounds <= 0))) - || (!usesWebAuthn && storage == .secureEnclave && !SecureEnclaveKey.isAvailable) - ) + .disabled(!canGenerate) } .padding() } .background(SecurityKeyAnchor(presenter: presenter).frame(width: 0, height: 0)) + .onAppear { + editingToken = editingSession.register( + route: .generateKey, isDirty: { isDirty }, isBusy: { isWorking } + ) { await commitChanges() } + } .onDisappear { + if let editingToken { editingSession.unregister(editingToken) } generationTask?.cancel() presenter.cancel() passphrase = "" @@ -1237,8 +1270,14 @@ private struct GenerateKeySheet: View { } @MainActor - private func commit() async { + private func commitChanges() async -> Bool { + guard !isWorking else { return false } + guard canGenerate else { + errorMessage = String(localized: AppStrings.Common.completeAllRequiredFieldsBeforeSaving) + return false + } isWorking = true + defer { isWorking = false } errorMessage = nil do { if usesWebAuthn { @@ -1263,10 +1302,10 @@ private struct GenerateKeySheet: View { } passphrase = "" confirmation = "" - close() + return true } catch { errorMessage = error.localizedDescription - isWorking = false + return false } } @@ -1482,7 +1521,7 @@ private struct SecureEnclaveKeyEditor: View { private var isDirty: Bool { name != key.label || colorTag != key.colorTag } private func cancelEditing() { - editingSession.requestTransition(route: .editKey(key.id), action: close) + editingSession.requestCancellation(route: .editKey(key.id), action: close) } private func commitChanges() -> Bool { diff --git a/TFM/Features/Keychain/NewIdentitySheet.swift b/TFM/Features/Keychain/NewIdentitySheet.swift index d09a6ff1..307e9da1 100644 --- a/TFM/Features/Keychain/NewIdentitySheet.swift +++ b/TFM/Features/Keychain/NewIdentitySheet.swift @@ -262,7 +262,7 @@ struct NewIdentitySheet: View { } private func cancelEditing() { - editingSession.requestTransition(route: .identityEditor(existing?.id), action: close) + editingSession.requestCancellation(route: .identityEditor(existing?.id), action: close) } private func saveAndClose() { diff --git a/TFM/Features/KnownHosts/KnownHostsPageView.swift b/TFM/Features/KnownHosts/KnownHostsPageView.swift index 4d976df2..88448197 100644 --- a/TFM/Features/KnownHosts/KnownHostsPageView.swift +++ b/TFM/Features/KnownHosts/KnownHostsPageView.swift @@ -274,7 +274,7 @@ struct KnownHostEditorView: View { } private func cancelEditing() { - editingSession.requestTransition(route: .knownHostEditor(entryID), action: window.closeVaultWork) + editingSession.requestCancellation(route: .knownHostEditor(entryID), action: window.closeVaultWork) } private func commitChanges() -> Bool { diff --git a/TFM/Features/Ports/PortForwardingView.swift b/TFM/Features/Ports/PortForwardingView.swift index 8da2cd3c..23148584 100644 --- a/TFM/Features/Ports/PortForwardingView.swift +++ b/TFM/Features/Ports/PortForwardingView.swift @@ -354,7 +354,7 @@ struct PortForwardingDetailView: View { window.closeVaultWork() } private func cancelEditing() { - editingSession.requestTransition(route: .forwardingEditor(ruleID), action: window.closeVaultWork) + editingSession.requestCancellation(route: .forwardingEditor(ruleID), action: window.closeVaultWork) } private func commitChanges() -> Bool { guard isDirty else { return true } diff --git a/TFM/Features/Snippets/SnippetsPageView.swift b/TFM/Features/Snippets/SnippetsPageView.swift index 20b9fbc6..cc1658ff 100644 --- a/TFM/Features/Snippets/SnippetsPageView.swift +++ b/TFM/Features/Snippets/SnippetsPageView.swift @@ -206,8 +206,12 @@ struct SnippetsPageView: ManagedCardPage { } func managedBlankAreaPrimaryAction() { - guard let route = window.vaultWorkRoute, case .snippetEditor = route else { return } - editingSession.requestTransition(route: route, action: window.closeVaultWork) + guard let route = window.vaultWorkRoute else { return } + switch route { + case .snippetEditor, .snippetPackageEditor: + editingSession.requestTransition(route: route, action: window.closeVaultWork) + default: break + } } func managedEmptyState() -> some View { @@ -671,7 +675,7 @@ struct SnippetEditorView: View { } private func cancelEditing() { - editingSession.requestTransition( + editingSession.requestCancellation( route: .snippetEditor(snippetID: existing?.id, packageID: packageID), action: close) } @@ -874,12 +878,14 @@ struct SnippetPackageSheet: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window + @Environment(VaultWorkEditingSession.self) private var editingSession @Environment(\.dismiss) private var dismiss @State private var name: String @State private var parentID: SnippetPackage.ID? - @State private var confirmsDiscard = false + @State private var editingToken: UUID? @State private var confirmsDeletion = false @State private var deleteError: String? + @State private var saveError: String? init( package: SnippetPackage? = nil, parentID: SnippetPackage.ID? = nil, @@ -910,6 +916,7 @@ struct SnippetPackageSheet: View { } } .formStyle(.grouped) + .overlay(alignment: .bottom) { if let saveError { Text(saveError).foregroundStyle(.red) } } } footer: { HStack { if existing != nil { @@ -917,21 +924,17 @@ struct SnippetPackageSheet: View { } Spacer() Button(AppStrings.Common.cancel.swiftUIKey, action: cancelEditing).keyboardShortcut(.cancelAction) - Button( - existing == nil ? AppStrings.Common.add.swiftUIKey : AppStrings.Common.save.swiftUIKey, - action: commit - ) - .keyboardShortcut(.defaultAction) - .disabled(trimmedName.isEmpty) + Button(AppStrings.Common.save.swiftUIKey, action: commit) + .keyboardShortcut(.defaultAction) + .disabled(trimmedName.isEmpty) } .padding(Metrics.Space.l) } - .confirmationDialog( - AppStrings.Common.discardChangesConfirmation, isPresented: $confirmsDiscard, - titleVisibility: .visible - ) { - Button(AppStrings.Common.discardChanges.swiftUIKey, role: .destructive, action: close) - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} + .onAppear { + editingToken = editingSession.register(route: editingRoute, isDirty: { isDirty }) { commitChanges() } + } + .onDisappear { + if let editingToken { editingSession.unregister(editingToken) } } .confirmationDialog( AppStrings.Common.deleteNamedConfirmation(existing?.name ?? ""), isPresented: $confirmsDeletion, @@ -966,7 +969,11 @@ struct SnippetPackageSheet: View { } private func cancelEditing() { - if isDirty { confirmsDiscard = true } else { close() } + editingSession.requestCancellation(route: editingRoute, action: close) + } + + private var editingRoute: VaultWorkRoute { + .snippetPackageEditor(packageID: existing?.id, parentID: initialParentID) } private func close() { @@ -1001,6 +1008,14 @@ struct SnippetPackageSheet: View { } private func commit() { + if commitChanges() { close() } + } + + private func commitChanges() -> Bool { + guard !trimmedName.isEmpty else { + saveError = String(localized: AppStrings.Common.completeAllRequiredFieldsBeforeSaving) + return false + } save( SnippetPackage( id: existing?.id ?? UUID(), name: trimmedName, @@ -1008,7 +1023,8 @@ struct SnippetPackageSheet: View { isExpanded: existing?.isExpanded ?? true ) ) - close() + saveError = app.hostLibrary.storageError + return saveError == nil } } diff --git a/TFM/Features/Vault/VaultView.swift b/TFM/Features/Vault/VaultView.swift index 5e5f894c..7833eefe 100644 --- a/TFM/Features/Vault/VaultView.swift +++ b/TFM/Features/Vault/VaultView.swift @@ -44,9 +44,7 @@ struct VaultWorkDetailView: View { case .hostGroupEditor(let groupID, let parentID): NewGroupSheet( group: groupID.flatMap(app.hostLibrary.group), parentID: parentID, - create: { _ in - window.closeVaultWork() - }, + create: { _ in }, close: window.closeVaultWork ) case .forwardingEditor(let id): diff --git a/TFM/Features/Vault/VaultWorkspaceView.swift b/TFM/Features/Vault/VaultWorkspaceView.swift index dcf5b297..460fb5a7 100644 --- a/TFM/Features/Vault/VaultWorkspaceView.swift +++ b/TFM/Features/Vault/VaultWorkspaceView.swift @@ -6,15 +6,22 @@ import TermindPresentation /// editor cannot unregister the editor that replaced it in the same update. @Observable final class VaultWorkEditingSession { + enum Confirmation { + case saveChanges + case discardChanges + } + private struct Registration { let token: UUID let route: VaultWorkRoute let isDirty: () -> Bool + let isBusy: () -> Bool let commit: () async -> Bool } private struct PendingTransition { let id = UUID() + let confirmation: Confirmation let action: () -> Void } @@ -22,14 +29,17 @@ final class VaultWorkEditingSession { @ObservationIgnored private var pendingTransition: PendingTransition? @ObservationIgnored private var isCommitting = false var confirmationID: UUID? + private(set) var confirmation: Confirmation = .saveChanges + private(set) var discardQuestion = AppStrings.Common.discardChangesQuestion @discardableResult func register( route: VaultWorkRoute, isDirty: @escaping () -> Bool, + isBusy: @escaping () -> Bool = { false }, commit: @escaping () async -> Bool ) -> UUID { let token = UUID() - registration = Registration(token: token, route: route, isDirty: isDirty, commit: commit) + registration = Registration(token: token, route: route, isDirty: isDirty, isBusy: isBusy, commit: commit) return token } @@ -38,19 +48,30 @@ final class VaultWorkEditingSession { registration = nil } - func requestTransition(route: VaultWorkRoute, action: @escaping () -> Void) { + func requestCancellation(route: VaultWorkRoute, action: @escaping () -> Void) { + requestTransition(route: route, confirmation: .discardChanges, action: action) + } + + func requestTransition( + route: VaultWorkRoute, confirmation: Confirmation = .saveChanges, action: @escaping () -> Void + ) { + guard !isCommitting else { return } + if let registration, registration.route == route, registration.isBusy() { return } guard let registration, registration.route == route, registration.isDirty() else { action() return } - let pending = PendingTransition(action: action) + let pending = PendingTransition(confirmation: confirmation, action: action) pendingTransition = pending + self.confirmation = confirmation + discardQuestion = route.discardQuestion confirmationID = pending.id } func saveAndProceed() { guard let registration, let pendingTransition else { return } - guard !isCommitting else { return } + guard pendingTransition.confirmation == .saveChanges else { return } + guard !isCommitting, !registration.isBusy() else { return } isCommitting = true Task { let saved = await registration.commit() @@ -66,6 +87,7 @@ final class VaultWorkEditingSession { } func discardAndProceed() { + guard !isCommitting, registration?.isBusy() != true else { return } guard let pendingTransition else { return } self.pendingTransition = nil confirmationID = nil @@ -124,16 +146,46 @@ struct VaultWorkspaceContent: View { } .environment(editingSession) .confirmationDialog( - AppStrings.Vault.saveChangesConfirmation, + editingSession.confirmation == .saveChanges + ? AppStrings.Vault.saveChangesConfirmation : AppStrings.Common.discardChangesConfirmation, isPresented: Binding( get: { editingSession.confirmationID != nil }, set: { if !$0 { editingSession.cancelTransition() } } ), titleVisibility: .visible ) { - Button(AppStrings.Common.save.swiftUIKey, action: editingSession.saveAndProceed) - Button(AppStrings.Common.discard.swiftUIKey, role: .destructive, action: editingSession.discardAndProceed) - Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel, action: editingSession.cancelTransition) + if editingSession.confirmation == .saveChanges { + Button(AppStrings.Common.save.swiftUIKey, action: editingSession.saveAndProceed) + Button( + AppStrings.Common.discard.swiftUIKey, role: .destructive, action: editingSession.discardAndProceed) + Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel, action: editingSession.cancelTransition) + } else { + Button(AppStrings.Common.keepEditing.swiftUIKey, role: .cancel, action: editingSession.cancelTransition) + .keyboardShortcut(.defaultAction) + Button( + AppStrings.Common.discardChanges.swiftUIKey, role: .destructive, + action: editingSession.discardAndProceed) + } + } message: { + Text( + editingSession.confirmation == .saveChanges + ? AppStrings.Vault.saveChangesMessage : editingSession.discardQuestion) + } + } +} + +extension VaultWorkRoute { + var discardQuestion: LocalizedStringResource { + switch self { + case .hostEditor(nil, _): AppStrings.Common.discardNewHostQuestion + case .hostGroupEditor(nil, _): AppStrings.Common.discardNewGroupQuestion + case .forwardingEditor(nil): AppStrings.Common.discardNewForwardingQuestion + case .identityEditor(nil): AppStrings.Common.discardNewIdentityQuestion + case .generateKey, .importKey: AppStrings.Common.discardNewKeyQuestion + case .knownHostEditor(nil): AppStrings.Common.discardNewKnownHostQuestion + case .snippetEditor(nil, _): AppStrings.Common.discardNewSnippetQuestion + case .snippetPackageEditor(nil, _): AppStrings.Common.discardNewPackageQuestion + default: AppStrings.Common.discardChangesQuestion } } } diff --git a/TFMTests/CardMarqueeSelectionTests.swift b/TFMTests/CardMarqueeSelectionTests.swift index 93b65f9c..7827eb7f 100644 --- a/TFMTests/CardMarqueeSelectionTests.swift +++ b/TFMTests/CardMarqueeSelectionTests.swift @@ -123,4 +123,98 @@ struct CardMarqueeSelectionTests { #expect(commits == [secondID]) #expect(transitions.last == secondID) } + + @Test @MainActor + func cancellingEditingOffersDiscardWhileNavigationStillOffersSave() async { + let session = VaultWorkEditingSession() + var commits = 0 + var transitions = 0 + var dirty = true + session.register( + route: .importKey, isDirty: { dirty }, + commit: { + commits += 1 + return true + }) + session.requestCancellation(route: .importKey) { transitions += 1 } + #expect(session.confirmation == .discardChanges) + #expect(session.confirmationID != nil) + session.saveAndProceed() + await Task.yield() + #expect(commits == 0) + #expect(transitions == 0) + session.cancelTransition() + #expect(transitions == 0) + + session.requestTransition(route: .importKey) { transitions += 1 } + #expect(session.confirmation == .saveChanges) + session.saveAndProceed() + for _ in 0..<100 where transitions == 0 { await Task.yield() } + #expect(commits == 1) + #expect(transitions == 1) + + session.requestCancellation(route: .importKey) { transitions += 1 } + session.discardAndProceed() + #expect(commits == 1) + #expect(transitions == 2) + dirty = false + session.requestCancellation(route: .importKey) { transitions += 1 } + #expect(session.confirmationID == nil) + #expect(transitions == 3) + } + + @Test @MainActor + func failedAsyncSaveKeepsTheEditorOpenAndAllowsRetry() async throws { + let session = VaultWorkEditingSession() + var transitions = 0 + var commits = 0 + var completion: CheckedContinuation? + session.register( + route: .generateKey, isDirty: { true }, + commit: { + commits += 1 + return await withCheckedContinuation { completion = $0 } + }) + session.requestTransition(route: .generateKey) { transitions += 1 } + session.saveAndProceed() + for _ in 0..<100 where completion == nil { await Task.yield() } + let first = try #require(completion) + session.saveAndProceed() + session.requestTransition(route: .generateKey) { transitions += 10 } + session.discardAndProceed() + first.resume(returning: false) + for _ in 0..<100 where session.confirmationID != nil { await Task.yield() } + #expect(transitions == 0) + #expect(commits == 1) + + completion = nil + session.requestTransition(route: .generateKey) { transitions += 1 } + session.saveAndProceed() + for _ in 0..<100 where completion == nil { await Task.yield() } + let retry = try #require(completion) + #expect(transitions == 0) + retry.resume(returning: true) + for _ in 0..<100 where transitions == 0 { await Task.yield() } + #expect(transitions == 1) + #expect(commits == 2) + } + + @Test @MainActor + func anActiveEditorOperationBlocksDismissalUntilItFinishes() { + let session = VaultWorkEditingSession() + var busy = true + var transitions = 0 + session.register(route: .importKey, isDirty: { true }, isBusy: { busy }, commit: { true }) + session.requestTransition(route: .importKey) { transitions += 1 } + #expect(session.confirmationID == nil) + #expect(transitions == 0) + busy = false + session.requestTransition(route: .importKey) { transitions += 1 } + #expect(session.confirmationID != nil) + session.cancelTransition() + #expect(transitions == 0) + session.requestTransition(route: .importKey) { transitions += 1 } + session.discardAndProceed() + #expect(transitions == 1) + } } diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 3cdc80ac..64998f6b 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2595,7 +2595,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; @@ -2633,7 +2633,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "";