feat: refine confirmation UX
This commit is contained in:
1 parent
24534cefa2
commit
f2cdaafd3a
42 files changed
+1434
-306
No files matched your search
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in new issue
Block a user