feat: refine confirmation UX

This commit is contained in:
Kaede Akino committed 2026-10-04 21:09:22 +08:00
1 parent 24534cefa2
commit f2cdaafd3a
42 files changed
+1434 -306

No files matched your search

+255
View File
@@ -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" : {
@@ -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 {
@@ -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<Bool>
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<Bool>,
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
@@ -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"
}
@@ -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."
}
@@ -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"
}
+1
View File
@@ -29,6 +29,7 @@ struct MobileStartupView: View {
MobileQuickActionSceneRoot(app: app)
}
}
.mobileOperationConfirmations()
.modelContainer(app.container)
.preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil)
case .failure:
@@ -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) {
@@ -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) {
@@ -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)
@@ -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)
@@ -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(
@@ -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)
+14 -11
View File
@@ -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(
+26 -3
View File
@@ -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<Content: View>: 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
@@ -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(
@@ -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: {
@@ -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) {
@@ -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(
+41 -29
View File
@@ -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)
@@ -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
+37 -38
View File
@@ -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 {
@@ -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)
)
}
}
@@ -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<Bool> {
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,
@@ -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<Selection>(matching matches: @escaping (Selection) -> Bool) -> Binding<Bool>
where Value == Selection? {
Binding<Bool>(
get: { wrappedValue.map(matches) == true },
set: { presented in
if !presented, wrappedValue.map(matches) == true { wrappedValue = nil }
})
}
}
+27 -21
View File
@@ -20,10 +20,11 @@ struct MobileEditorSheet<Content: View>: 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<Void, Never>?
@@ -51,12 +52,17 @@ struct MobileEditorSheet<Content: View>: 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<Content: View>: 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<Content: View>: 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()
}
@@ -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<Bool>?
private var placement = OperationConfirmationPlacement.anchored
private var actions: [Action] = []
private weak var alert: UIAlertController?
private var presentationTask: Task<Void, Never>?
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<Bool>,
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
}
}
+20
View File
@@ -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(
+25 -4
View File
@@ -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<Void, Never>?
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 }
@@ -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))
}
}
+2 -2
View File
@@ -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()
}
+6 -2
View File
@@ -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.
+43 -10
View File
@@ -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<Void, Never>?
@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())
}
+66 -27
View File
@@ -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<Void, Never>?
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 {
+1 -1
View File
@@ -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() {
@@ -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 {
+1 -1
View File
@@ -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 }
+34 -18
View File
@@ -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
}
}
+1 -3
View File
@@ -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):
+60 -8
View File
@@ -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
}
}
}
+94
View File
@@ -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<Bool, Never>?
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)
}
}
+2 -2
View File
@@ -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 = "";