diff --git a/FrameworkTests/DeletionConfirmationPolicyTests.swift b/FrameworkTests/DeletionConfirmationPolicyTests.swift new file mode 100644 index 00000000..ef6fcc40 --- /dev/null +++ b/FrameworkTests/DeletionConfirmationPolicyTests.swift @@ -0,0 +1,31 @@ +import TermindPresentation +import Testing + +@Suite +struct DeletionConfirmationPolicyTests { + @Test + func alwaysConfirmsItemsAndCollectionClears() { + #expect(DeletionConfirmationPolicy.always.requiresConfirmation(for: .item)) + #expect(DeletionConfirmationPolicy.always.requiresConfirmation(for: .clearCollection)) + } + + @Test + func clearOnlyConfirmsCollectionClears() { + #expect(!DeletionConfirmationPolicy.clearOnly.requiresConfirmation(for: .item)) + #expect(DeletionConfirmationPolicy.clearOnly.requiresConfirmation(for: .clearCollection)) + } + + @Test + func neverSkipsEveryConfirmation() { + #expect(!DeletionConfirmationPolicy.never.requiresConfirmation(for: .item)) + #expect(!DeletionConfirmationPolicy.never.requiresConfirmation(for: .clearCollection)) + } + + @Test + func contentCategoriesMapToTheSharedProductPolicy() { + #expect(DeletionContentCategory.protectedData.confirmationPolicy == .always) + #expect(DeletionContentCategory.recordedContent.confirmationPolicy == .clearOnly) + #expect(DeletionContentCategory.savedConfiguration.confirmationPolicy == .clearOnly) + #expect(DeletionContentCategory.transient.confirmationPolicy == .never) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 9adc1d71..a16eccd9 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -5059,6 +5059,22 @@ } } }, + "Clear Call History?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清空调用历史?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除呼叫歷史?" + } + } + } + }, "Clear Filter" : { "localizations" : { "zh-Hans" : { @@ -5092,6 +5108,22 @@ } } }, + "Clear Finished Transfers?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除已结束的传输?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除已結束的傳輸?" + } + } + } + }, "Clear History" : { "localizations" : { "zh-Hans" : { @@ -5142,6 +5174,22 @@ } } }, + "Clear Recent Connections?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除最近连接?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除最近連線?" + } + } + } + }, "Clear search" : { "localizations" : { "zh-Hans" : { @@ -7868,6 +7916,22 @@ } } }, + "Delete Hosts?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除主机?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除主機?" + } + } + } + }, "Delete Identity?" : { "extractionState" : "manual", "localizations" : { @@ -7902,6 +7966,22 @@ } } }, + "Delete Keychain Items?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除钥匙串项目?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除鑰匙圈項目?" + } + } + } + }, "Delete Known Host?" : { "extractionState" : "manual", "localizations" : { @@ -12375,6 +12455,22 @@ } } }, + "Hosts and identities using these Keychain items may need to be configured again." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用这些钥匙串项目的主机和身份可能需要重新配置。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用這些鑰匙圈項目的主機和身分可能需要重新設定。" + } + } + } + }, "Hosts using this identity will ask for credentials again." : { "extractionState" : "manual", "localizations" : { @@ -17324,6 +17420,22 @@ } } }, + "Open connections and references to the selected hosts will be removed." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将关闭所选主机的连接并移除相关引用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將關閉所選主機的連線並移除相關參照。" + } + } + } + }, "Open connections and references to this host will be removed." : { "extractionState" : "manual", "localizations" : { @@ -28166,6 +28278,22 @@ } } }, + "This permanently removes all saved MCP call records." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将永久删除所有保存的MCP调用记录。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這將永久刪除所有儲存的MCP呼叫記錄。" + } + } + } + }, "This permanently removes all connection history without deleting any Hosts." : { "localizations" : { "zh-Hans" : { @@ -28231,6 +28359,22 @@ } } }, + "This removes all connection history for this Host." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将删除此主机的所有连接历史。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這將刪除此主機的所有連線歷史。" + } + } + } + }, "This remote file may contain executable content. Review it before opening." : { "extractionState" : "manual", "localizations" : { @@ -31484,4 +31628,4 @@ } }, "version" : "1.2" -} \ No newline at end of file +} diff --git a/Frameworks/TermindPresentation/DeletionConfirmationPolicy.swift b/Frameworks/TermindPresentation/DeletionConfirmationPolicy.swift new file mode 100644 index 00000000..db89f184 --- /dev/null +++ b/Frameworks/TermindPresentation/DeletionConfirmationPolicy.swift @@ -0,0 +1,50 @@ +/// Describes whether a destructive UI action removes one selected item or clears +/// every item represented by the current collection. +public enum DeletionOperationScope: Equatable, Sendable { + case item + case clearCollection +} + +/// Shared confirmation semantics used by the macOS and iOS presentation layers. +public enum DeletionConfirmationPolicy: Equatable, Sendable { + /// Protect every deletion, including a single item. + case always + /// Let individual rows be removed immediately, but confirm clearing a collection. + case clearOnly + /// Perform the destructive action immediately. + case never + + public func requiresConfirmation(for scope: DeletionOperationScope) -> Bool { + switch self { + case .always: + true + case .clearOnly: + scope == .clearCollection + case .never: + false + } + } +} + +/// Product-level categories that keep destructive behavior aligned across platforms. +public enum DeletionContentCategory: Sendable { + case protectedData + case recordedContent + case savedConfiguration + case transient + + public var confirmationPolicy: DeletionConfirmationPolicy { + switch self { + case .protectedData: + .always + case .recordedContent, .savedConfiguration: + .clearOnly + case .transient: + .never + } + } + + public func requiresConfirmation(for scope: DeletionOperationScope) -> Bool { + confirmationPolicy.requiresConfirmation(for: scope) + } +} diff --git a/TFI/Features/Settings/MobileAISettingsView.swift b/TFI/Features/Settings/MobileAISettingsView.swift index 39c91672..8579cd7a 100644 --- a/TFI/Features/Settings/MobileAISettingsView.swift +++ b/TFI/Features/Settings/MobileAISettingsView.swift @@ -66,7 +66,6 @@ enum MobileAISettingsDestination: String, CaseIterable, Identifiable { struct MobileModelsSettingsView: View { let ai: AIConfigurationModel @State private var editing: ModelProvider? - @State private var deleting: ModelProvider? var body: some View { List { @@ -101,10 +100,14 @@ struct MobileModelsSettingsView: View { .buttonStyle(.plain) .contextMenu { Button("Edit", systemImage: "pencil") { editing = provider } - Button("Delete", systemImage: "trash", role: .destructive) { deleting = provider } + Button("Delete", systemImage: "trash", role: .destructive) { + ai.delete(modelProviderID: provider.id) + } } .swipeActions { - Button("Delete", systemImage: "trash", role: .destructive) { deleting = provider } + Button("Delete", systemImage: "trash", role: .destructive) { + ai.delete(modelProviderID: provider.id) + } } } } @@ -120,21 +123,6 @@ struct MobileModelsSettingsView: View { } } .sheet(item: $editing) { provider in MobileProviderEditor(ai: ai, provider: provider) } - .confirmationDialog( - "Delete", - isPresented: Binding( - get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible - ) { - Button("Delete", role: .destructive) { - if let deleting { ai.delete(modelProviderID: deleting.id) } - deleting = nil - } - } message: { - Text("Permanently deletes this provider, its key, and its models.") - if let deleting, !ai.roles(using: deleting.id).isEmpty { - Text("Assigned roles will return to their defaults.") - } - } } } diff --git a/TFI/Features/Settings/MobileLoggingView.swift b/TFI/Features/Settings/MobileLoggingView.swift index e0fa918b..c877ead7 100644 --- a/TFI/Features/Settings/MobileLoggingView.swift +++ b/TFI/Features/Settings/MobileLoggingView.swift @@ -9,7 +9,6 @@ struct MobileLoggingView: View { @AppStorage(SettingsKey.loggingLevel) private var loggingLevel = ApplicationLogLevel.info.rawValue @State private var files: [ApplicationLogFile] = [] @State private var selectedFile: ApplicationLogFile? - @State private var pendingDelete: ApplicationLogFile? @State private var showsDeleteAll = false @State private var exportDocument: MobileLoggingFileDocument? @State private var exportFilename = "termind.log" @@ -56,16 +55,6 @@ struct MobileLoggingView: View { exportDocument = nil if case .failure(let error) = result { errorMessage = error.localizedDescription } } - .confirmationDialog( - "Delete this logging file?", isPresented: deleteFilePresented, - titleVisibility: .visible - ) { - Button("Delete", role: .destructive) { - guard let pendingDelete else { return } - perform { try logging.delete(pendingDelete.id) } - self.pendingDelete = nil - } - } .confirmationDialog( "Delete all logging files?", isPresented: $showsDeleteAll, titleVisibility: .visible @@ -136,18 +125,14 @@ struct MobileLoggingView: View { } Divider() Button("Delete", systemImage: "trash", role: .destructive) { - pendingDelete = file + perform { try logging.delete(file.id) } } } .swipeActions { - Button("Delete", role: .destructive) { pendingDelete = file } + Button("Delete", role: .destructive) { perform { try logging.delete(file.id) } } } } - private var deleteFilePresented: Binding { - Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }) - } - private var errorPresented: Binding { Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } }) } diff --git a/TFI/Features/Settings/MobileMCPSettings.swift b/TFI/Features/Settings/MobileMCPSettings.swift index 02967be1..3f2acb43 100644 --- a/TFI/Features/Settings/MobileMCPSettings.swift +++ b/TFI/Features/Settings/MobileMCPSettings.swift @@ -8,7 +8,6 @@ struct MobileMCPSettings: View { let ai: AIConfigurationModel let pro: ProAccessController @State private var adding: MCPServer? - @State private var deleting: MCPServer? var body: some View { List { @@ -30,10 +29,14 @@ struct MobileMCPSettings: View { ForEach(ai.mcpServers) { server in serverLink(server) .contextMenu { - Button("Delete", systemImage: "trash", role: .destructive) { deleting = server } + Button("Delete", systemImage: "trash", role: .destructive) { + ai.delete(serverID: server.id) + } } .swipeActions(edge: .trailing) { - Button("Delete", systemImage: "trash", role: .destructive) { deleting = server } + Button("Delete", systemImage: "trash", role: .destructive) { + ai.delete(serverID: server.id) + } } } } footer: { @@ -50,19 +53,6 @@ struct MobileMCPSettings: View { } } .navigationDestination(item: $adding) { MobileMCPEditor(ai: ai, pro: pro, server: $0) } - .confirmationDialog( - "Delete Server", - isPresented: Binding( - get: { deleting != nil }, - set: { if !$0 { deleting = nil } } - ), - titleVisibility: .visible - ) { - Button("Delete Server", role: .destructive) { - if let deleting { ai.delete(serverID: deleting.id) } - deleting = nil - } - } } @ViewBuilder @@ -185,7 +175,6 @@ struct MobileMCPEditor: View { let pro: ProAccessController @State private var model: MobileMCPEditorModel @State private var editingHeader: MCPHeader? - @State private var deleting = false @State private var alert: EditorAlert? @Environment(\.dismiss) private var dismiss @@ -285,7 +274,7 @@ struct MobileMCPEditor: View { .disabled(!model.canSave || model.running) if ai.mcpServer(model.original.id) != nil { Divider() - Button("Delete", systemImage: "trash", role: .destructive) { deleting = true } + Button("Delete", systemImage: "trash", role: .destructive, action: delete) } } ToolbarItem(placement: .confirmationAction) { @@ -311,16 +300,6 @@ struct MobileMCPEditor: View { .onChange(of: ai.mcpServer(model.original.id)) { _, _ in model.cancel() } .onChange(of: pro.hasProAccess) { _, granted in if !granted { model.cancel() } } .onDisappear { model.cancel() } - .confirmationDialog("Delete Server?", isPresented: $deleting, titleVisibility: .visible) { - Button("Delete", role: .destructive) { - ai.delete(serverID: model.original.id) - if let error = ai.storageError ?? ai.secretStorageError { - alert = EditorAlert(title: "Unable to Delete", message: error) - } else { - dismiss() - } - } - } .alert( alert?.title ?? "Unable to Save", isPresented: Binding(get: { alert != nil }, set: { if !$0 { alert = nil } }) @@ -331,6 +310,15 @@ struct MobileMCPEditor: View { } } + private func delete() { + ai.delete(serverID: model.original.id) + if let error = ai.storageError ?? ai.secretStorageError { + alert = EditorAlert(title: "Unable to Delete", message: error) + } else { + dismiss() + } + } + private func save() { do { try model.save() diff --git a/TFI/Features/Settings/MobileSkillSettings.swift b/TFI/Features/Settings/MobileSkillSettings.swift index 12930c09..dcd80cc5 100644 --- a/TFI/Features/Settings/MobileSkillSettings.swift +++ b/TFI/Features/Settings/MobileSkillSettings.swift @@ -8,7 +8,6 @@ import UniformTypeIdentifiers struct MobileSkillSettings: View { let skills: SkillLibraryModel @State private var editing: AgentSkill? - @State private var deleting: AgentSkill? @State private var importing = false @State private var warnings: [String] = [] @State private var error: String? @@ -46,10 +45,14 @@ struct MobileSkillSettings: View { .buttonStyle(.plain) .contextMenu { Button("Edit", systemImage: "pencil") { editing = skill } - Button("Delete", systemImage: "trash", role: .destructive) { deleting = skill } + Button("Delete", systemImage: "trash", role: .destructive) { + skills.delete(id: skill.id) + } } .swipeActions(edge: .trailing) { - Button("Delete", systemImage: "trash", role: .destructive) { deleting = skill } + Button("Delete", systemImage: "trash", role: .destructive) { + skills.delete(id: skill.id) + } } } } footer: { @@ -83,20 +86,6 @@ struct MobileSkillSettings: View { editing = installed } catch { self.error = error.localizedDescription } } - .confirmationDialog( - "Delete Skill", - isPresented: Binding( - get: { deleting != nil }, - set: { if !$0 { deleting = nil } } - ), - titleVisibility: .visible - ) { - Button("Delete Skill", role: .destructive) { - if let deleting { skills.delete(id: deleting.id) } - deleting = nil - } - } - } } @@ -104,7 +93,6 @@ struct MobileSkillEditor: View { let library: SkillLibraryModel @State private var model: MobileSkillEditorModel @State private var exporting = false - @State private var deleting = false @State private var error: String? @Environment(\.dismiss) private var dismiss @@ -181,12 +169,6 @@ struct MobileSkillEditor: View { ) { result in if case .failure(let error) = result { self.error = error.localizedDescription } } - .confirmationDialog("Delete Skill?", isPresented: $deleting, titleVisibility: .visible) { - Button("Delete", role: .destructive) { - library.delete(id: model.original.id) - if let error = library.storageError { self.error = error } else { dismiss() } - } - } } private var toolbarActions: [MobileEditorToolbarAction] { @@ -199,8 +181,13 @@ struct MobileSkillEditor: View { actions.append( MobileEditorToolbarAction( id: "delete", title: "Delete", systemImage: "trash", role: .destructive, - startsGroup: true, perform: { deleting = true })) + startsGroup: true, perform: delete)) } return actions } + + private func delete() { + library.delete(id: model.original.id) + if let error = library.storageError { self.error = error } else { dismiss() } + } } diff --git a/TFI/Features/Transfers/MobileTransfersView.swift b/TFI/Features/Transfers/MobileTransfersView.swift index dcc7d039..483d0e76 100644 --- a/TFI/Features/Transfers/MobileTransfersView.swift +++ b/TFI/Features/Transfers/MobileTransfersView.swift @@ -9,6 +9,7 @@ struct MobileTransfersView: View { let transfers: SFTPTransferCoordinator let hosts: HostLibraryModel @State private var previewURL: URL? + @State private var confirmsClearFinished = false private var items: [SFTPTransfer] { transfers.sftpTransfers.sorted { $0.queuedAt > $1.queuedAt } @@ -38,10 +39,17 @@ struct MobileTransfersView: View { .quickLookPreview($previewURL) .toolbar { if hasFinished { - Button("Clear Finished", systemImage: "trash") { transfers.clearFinished() } + Button("Clear Finished", systemImage: "trash") { confirmsClearFinished = true } .labelStyle(.iconOnly) } } + .confirmationDialog( + "Clear Finished Transfers?", isPresented: $confirmsClearFinished, + titleVisibility: .visible + ) { + Button("Clear Finished", role: .destructive) { transfers.clearFinished() } + Button("Cancel", role: .cancel) {} + } } private func transferCard(_ transfer: SFTPTransfer) -> some View { diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 2e22a8d6..28b9214d 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -716,10 +716,10 @@ struct MobileVaultView: View { } private func requestDelete(_ group: HostGroup) { - if app.library.hosts(inTreeOf: group.id).isEmpty { - app.deleteHostGroup(group.id, keepHosts: false) - } else { + if DeletionContentCategory.protectedData.requiresConfirmation(for: .item) { deletingGroup = group + } else { + app.deleteHostGroup(group.id, keepHosts: false) } } diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index aef578d6..31261314 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -32,6 +32,7 @@ struct MobileWorkspaceListView: View { @Bindable var scene: MobileSceneModel @Environment(\.colorScheme) private var colorScheme @Environment(\.locale) private var locale + @State private var clearingRecentHost: Host? static let historyLimit = 5 @@ -94,6 +95,24 @@ struct MobileWorkspaceListView: View { await Task.yield() scene.activatePendingWorkspace() } + .confirmationDialog( + "Clear Recent Connections?", + isPresented: Binding( + get: { clearingRecentHost != nil }, + set: { if !$0 { clearingRecentHost = nil } } + ), + titleVisibility: .visible + ) { + Button("Clear History", role: .destructive) { + if let host = clearingRecentHost { + app.connectionHistory.clear(for: host.id) + } + clearingRecentHost = nil + } + Button("Cancel", role: .cancel) { clearingRecentHost = nil } + } message: { + Text("This removes all connection history for this Host.") + } } @ViewBuilder @@ -181,12 +200,12 @@ struct MobileWorkspaceListView: View { } if !kinds.isEmpty { Divider() } Button("Delete", systemImage: "trash", role: .destructive) { - app.connectionHistory.clear(for: row.host.id) + requestHistoryClear(for: row.host) } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button("Delete", systemImage: "trash", role: .destructive) { - app.connectionHistory.clear(for: row.host.id) + requestHistoryClear(for: row.host) } ForEach( MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: row.host), id: \.self @@ -213,6 +232,14 @@ struct MobileWorkspaceListView: View { } } + private func requestHistoryClear(for host: Host) { + if DeletionContentCategory.recordedContent.requiresConfirmation(for: .clearCollection) { + clearingRecentHost = host + } else { + app.connectionHistory.clear(for: host.id) + } + } + private func historyLabel(_ row: MobileRecentHostRow) -> some View { HStack(spacing: MobileWorkspaceListMetrics.rowSpacing) { hostIcon(row.host) diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index b1b17c67..29d9a4e1 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -75,6 +75,22 @@ struct MobileAppTests { #expect(commands.entries(for: hostID).map(\.command) == ["echo persisted"]) #expect(files.entries(for: hostID).map(\.path) == ["/srv/app"]) } + + @Test func clearingRecentsPreservesCommandAndFileHistory() throws { + let app = try MobileAppModel(inMemory: true) + defer { app.outbox.stop() } + let host = TermindDomain.Host(name: "Recent", host: "recent.example.invalid") + app.library.upsert(host) + _ = app.connectionHistory.record(kind: .ssh, for: host.id) + app.commandHistory.record("pwd", for: host.id) + app.sftpHistory.record(kind: .directory, path: "/srv", for: host.id) + + app.connectionHistory.clear(for: host.id) + + #expect(app.connectionHistory.entries(for: host.id).isEmpty) + #expect(app.commandHistory.entries(for: host.id).map(\.command) == ["pwd"]) + #expect(app.sftpHistory.entries(for: host.id).map(\.path) == ["/srv"]) + } } @Suite @MainActor diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 2d59c6ac..fe8ac8cc 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -31,6 +31,7 @@ struct HostEditorView: View { @State private var didLoad = false @State private var customIdentity = HostCustomIdentityDraft() @State private var selectedProtocol = HostTerminalProtocol.ssh + @State private var confirmsDeletion = false init(hostID: Host.ID?, initialGroupID: HostGroup.ID? = nil) { self.hostID = hostID @@ -72,6 +73,19 @@ struct HostEditorView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() .animation(.snappy(duration: 0.2), value: route) + .confirmationDialog( + "Delete Host?", isPresented: $confirmsDeletion, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + guard let hostID else { return } + app.requestHostDeletion(hostID) + closeTab() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Open connections and references to this host will be removed.") + } } private var form: some View { @@ -685,9 +699,7 @@ struct HostEditorView: View { HStack(spacing: Metrics.Space.m) { if hostID != nil { Button("Delete", role: .destructive) { - guard let hostID else { return } - app.requestHostDeletion(hostID) - closeTab() + confirmsDeletion = true } } if let saveError { diff --git a/TFM/Features/Hosts/HostActions.swift b/TFM/Features/Hosts/HostActions.swift index 6f83c592..b1c2aa3e 100644 --- a/TFM/Features/Hosts/HostActions.swift +++ b/TFM/Features/Hosts/HostActions.swift @@ -15,8 +15,7 @@ import struct TermindDomain.Host struct HostContextMenu: View { let hosts: [Host] let showsDuplicate: Bool - let showsDelete: Bool - var selectionDeleted: (() -> Void)? + let requestDelete: (() -> Void)? @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @@ -24,24 +23,21 @@ struct HostContextMenu: View { init( host: Host, showsDuplicate: Bool = true, - showsDelete: Bool = true + requestDelete: (() -> Void)? = nil ) { hosts = [host] self.showsDuplicate = showsDuplicate - self.showsDelete = showsDelete - selectionDeleted = nil + self.requestDelete = requestDelete } init( hosts: [Host], showsDuplicate: Bool = true, - showsDelete: Bool = true, - selectionDeleted: (() -> Void)? = nil + requestDelete: @escaping () -> Void ) { self.hosts = hosts self.showsDuplicate = showsDuplicate - self.showsDelete = showsDelete - self.selectionDeleted = selectionDeleted + self.requestDelete = requestDelete } var body: some View { @@ -91,10 +87,9 @@ struct HostContextMenu: View { Divider() } - if showsDelete { + if let requestDelete { DestructiveMenuButton("Delete") { - for host in hosts { app.requestHostDeletion(host.id) } - selectionDeleted?() + requestDelete() } .disabled(hosts.isEmpty) } diff --git a/TFM/Features/Hosts/HostsPageView.swift b/TFM/Features/Hosts/HostsPageView.swift index d4a8781a..8734c720 100644 --- a/TFM/Features/Hosts/HostsPageView.swift +++ b/TFM/Features/Hosts/HostsPageView.swift @@ -9,10 +9,13 @@ import struct TermindDomain.Host final class HostsPageState { enum ConfirmationRoute: Identifiable { case deleteGroup(HostGroup) + case deleteHosts([Host]) var id: String { switch self { case .deleteGroup(let group): "delete-group-\(group.id)" + case .deleteHosts(let hosts): + "delete-hosts-\(hosts.map(\.id.uuidString).sorted().joined(separator: "-"))" } } } @@ -23,6 +26,13 @@ final class HostsPageState { var selectedTags: Set = [] var confirmation: ConfirmationRoute? + func requestDeletion(of hosts: [Host]) { + guard !hosts.isEmpty, + DeletionContentCategory.protectedData.requiresConfirmation(for: .item) + else { return } + confirmation = .deleteHosts(hosts) + } + /// Drops filters for tags no host carries any more. /// /// There is no tag registry to delete from — `HostLibraryModel.hostTags` is @@ -180,7 +190,7 @@ struct HostsPageView: ManagedCardPage { select: context.select, extendSelection: context.extendSelection, actionHosts: context.actionItems, - selectionDeleted: pruneHostSelection + requestDelete: { state.requestDeletion(of: context.actionItems) } ) } @@ -248,14 +258,11 @@ struct HostsPageView: ManagedCardPage { // MARK: - Deleting a group - /// Only asks when there is something to ask about. A group with no machines - /// anywhere beneath it takes nothing with it but empty folders, and a dialog - /// for that is a dialog people learn to dismiss without reading. private func requestDelete(_ group: HostGroup) { - if app.hostLibrary.hosts(inTreeOf: group.id).isEmpty { - delete(group, keepHosts: false) - } else { + if DeletionContentCategory.protectedData.requiresConfirmation(for: .item) { state.confirmation = .deleteGroup(group) + } else { + delete(group, keepHosts: false) } } @@ -267,6 +274,13 @@ struct HostsPageView: ManagedCardPage { window.pruneVaultSelections(in: app.hostLibrary) } + private func delete(_ hosts: [Host]) { + for host in hosts { app.requestHostDeletion(host.id) } + state.confirmation = nil + pruneHostSelection() + window.pruneVaultSelections(in: app.hostLibrary) + } + private func deletionMessage(for group: HostGroup) -> String { let hosts = app.hostLibrary.hosts(inTreeOf: group.id).count let nested = app.hostLibrary.descendantGroups(of: group.id).count @@ -331,6 +345,7 @@ struct HostsPageView: ManagedCardPage { liveTags: app.hostLibrary.hostTags, actions: HostsPagePresentation.Actions( deleteGroup: { delete($0, keepHosts: $1) }, + deleteHosts: delete, deletionMessage: deletionMessage ) ) @@ -352,6 +367,7 @@ struct HostsPageView: ManagedCardPage { struct HostsPagePresentation: ManagedCardPresentationModifier { struct Actions { let deleteGroup: (HostGroup, Bool) -> Void + let deleteHosts: ([Host]) -> Void let deletionMessage: (HostGroup) -> String } @@ -379,6 +395,10 @@ struct HostsPagePresentation: ManagedCardPresentationModifier { actions.deleteGroup(group, false) } Button("Keep Hosts") { actions.deleteGroup(group, true) } + case .deleteHosts(let hosts): + Button("Delete", role: .destructive) { + actions.deleteHosts(hosts) + } case nil: EmptyView() } @@ -391,6 +411,8 @@ struct HostsPagePresentation: ManagedCardPresentationModifier { private var confirmationTitle: Text { switch state.confirmation { case .deleteGroup(let group): Text("Delete “\(group.name)”?") + case .deleteHosts(let hosts): + hosts.count == 1 ? Text("Delete Host?") : Text("Delete Hosts?") case nil: Text("") } } @@ -398,6 +420,8 @@ struct HostsPagePresentation: ManagedCardPresentationModifier { private var confirmationMessage: Text { switch state.confirmation { case .deleteGroup(let group): Text(actions.deletionMessage(group)) + case .deleteHosts: + Text("Open connections and references to the selected hosts will be removed.") case nil: Text("") } } @@ -413,7 +437,7 @@ private struct HostCard: View { let select: () -> Void let extendSelection: () -> Void let actionHosts: [Host] - let selectionDeleted: () -> Void + let requestDelete: () -> Void @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @@ -439,7 +463,7 @@ private struct HostCard: View { .contextMenu { HostContextMenu( hosts: actionHosts, - selectionDeleted: selectionDeleted + requestDelete: requestDelete ) } .help(Text("\(app.hostLibrary.preferredTerminalLoginAddress(for: host)) — \(status.label)")) diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index 343162e4..0625ffe2 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -11,6 +11,14 @@ import struct TermindDomain.Host final class KeychainPageState { var query = "" var errorMessage: String? + var deleting: [KeychainPageView.Item] = [] + + func requestDeletion(of items: [KeychainPageView.Item]) { + guard !items.isEmpty, + DeletionContentCategory.protectedData.requiresConfirmation(for: .item) + else { return } + deleting = items + } } /// Everything Termind knows about how you prove who you are — in two parts. @@ -139,10 +147,7 @@ struct KeychainPageView: ManagedCardPage { }, export: { requestExport(context.actionItems.compactMap(\.key)) }, delete: { - delete( - keys: context.actionItems.compactMap(\.key), - identities: context.actionItems.compactMap(\.identity) - ) + requestDelete(context.actionItems) } ) case .identity(let identity): @@ -157,10 +162,7 @@ struct KeychainPageView: ManagedCardPage { window.openVaultWork(.identityEditor(identity.id), on: .keychain) }, delete: { - delete( - keys: context.actionItems.compactMap(\.key), - identities: context.actionItems.compactMap(\.identity) - ) + requestDelete(context.actionItems) } ) } @@ -228,10 +230,16 @@ struct KeychainPageView: ManagedCardPage { window.openVaultWork(.exportKeys(keys.map(\.id)), on: .keychain) } + private func requestDelete(_ items: [Item]) { + state.requestDeletion(of: items) + } + /// Identities go first so deleting a mixed selection never leaves a host /// pointing at an identity whose key was removed moments earlier. Keychain /// failures leave that key selected and report the first error. - private func delete(keys: [SSHKeyRef], identities: [Identity]) { + private func delete(_ items: [Item]) { + let keys = items.compactMap(\.key) + let identities = items.compactMap(\.identity) for identity in identities { if !app.credentials.delete(identityID: identity.id) { state.errorMessage = @@ -246,6 +254,7 @@ struct KeychainPageView: ManagedCardPage { state.errorMessage = error.localizedDescription } } + state.deleting = [] window.pruneVaultSelections(in: app.credentials) } @@ -280,6 +289,7 @@ struct KeychainPageView: ManagedCardPage { KeychainPagePresentation( state: state, actions: KeychainPagePresentation.Actions( + delete: delete, pruneSelection: { window.pruneVaultSelections(in: app.credentials) } ) ) @@ -289,6 +299,7 @@ struct KeychainPageView: ManagedCardPage { struct KeychainPagePresentation: ManagedCardPresentationModifier { struct Actions { + let delete: ([KeychainPageView.Item]) -> Void let pruneSelection: () -> Void } @@ -299,6 +310,21 @@ struct KeychainPagePresentation: ManagedCardPresentationModifier { @Bindable var state = state content .onAppear(perform: actions.pruneSelection) + .confirmationDialog( + confirmationTitle, + isPresented: Binding( + get: { !state.deleting.isEmpty }, + set: { if !$0 { state.deleting = [] } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + actions.delete(state.deleting) + } + Button("Cancel", role: .cancel) { state.deleting = [] } + } message: { + confirmationMessage + } // A refused Keychain delete used to be a line of orange text in the // page toolbar. With the toolbar gone it needs somewhere it cannot // be missed: leaving a key the user asked to remove sitting there @@ -315,6 +341,26 @@ struct KeychainPagePresentation: ManagedCardPresentationModifier { Text(state.errorMessage ?? "") } } + + private var confirmationTitle: Text { + let keys = state.deleting.compactMap(\.key) + let identities = state.deleting.compactMap(\.identity) + if keys.count == 1, identities.isEmpty { return Text("Delete Key?") } + if identities.count == 1, keys.isEmpty { return Text("Delete Identity?") } + return Text("Delete Keychain Items?") + } + + private var confirmationMessage: Text { + let keys = state.deleting.compactMap(\.key) + let identities = state.deleting.compactMap(\.identity) + if keys.count == 1, identities.isEmpty { + return Text("Identities using this key will no longer have access to it.") + } + if identities.count == 1, keys.isEmpty { + return Text("Hosts using this identity will ask for credentials again.") + } + return Text("Hosts and identities using these Keychain items may need to be configured again.") + } } struct KeychainImportDetail: View { diff --git a/TFM/Features/Settings/LoggingFilesSettings.swift b/TFM/Features/Settings/LoggingFilesSettings.swift index f3a58f61..a69a81bd 100644 --- a/TFM/Features/Settings/LoggingFilesSettings.swift +++ b/TFM/Features/Settings/LoggingFilesSettings.swift @@ -9,7 +9,6 @@ struct LoggingFilesSettings: View { @AppStorage(SettingsKey.loggingLevel) private var loggingLevel = ApplicationLogLevel.info.rawValue @State private var files: [ApplicationLogFile] = [] @State private var selectedFile: ApplicationLogFile? - @State private var pendingDelete: ApplicationLogFile? @State private var showsDeleteAll = false @State private var exportDocument: LoggingFileDocument? @State private var exportFilename = "termind.log" @@ -45,16 +44,6 @@ struct LoggingFilesSettings: View { if case .failure(let error) = result { errorMessage = error.localizedDescription } } .background(LoggingSharingService(url: $shareURL)) - .confirmationDialog( - "Delete this logging file?", isPresented: deleteFilePresented, - titleVisibility: .visible - ) { - Button("Delete", role: .destructive) { - guard let pendingDelete else { return } - perform { try app.logging.delete(pendingDelete.id) } - self.pendingDelete = nil - } - } .confirmationDialog( "Delete all logging files?", isPresented: $showsDeleteAll, titleVisibility: .visible @@ -139,15 +128,11 @@ struct LoggingFilesSettings: View { } Divider() DestructiveMenuButton("Delete") { - pendingDelete = file + perform { try app.logging.delete(file.id) } } } } - private var deleteFilePresented: Binding { - Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }) - } - private var errorPresented: Binding { Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } }) } diff --git a/TFM/Features/Settings/MCPServerSettings.swift b/TFM/Features/Settings/MCPServerSettings.swift index fc4185f8..c6ec4774 100644 --- a/TFM/Features/Settings/MCPServerSettings.swift +++ b/TFM/Features/Settings/MCPServerSettings.swift @@ -10,6 +10,7 @@ struct MCPServerSettings: View { @Environment(AppModel.self) private var app @State private var showsToken = false @State private var historyQuery = "" + @State private var confirmsHistoryClear = false private var server: MCPServerController { app.mcpServer } @@ -125,13 +126,22 @@ struct MCPServerSettings: View { ForEach(rows.prefix(200)) { row in ExternalToolCallRow(row: row) } } HStack { - Button("Clear History", role: .destructive) { app.toolAudit.clear() } + Button("Clear History", role: .destructive) { confirmsHistoryClear = true } Spacer() Text("Stores commands, paths and byte counts; never file bodies, stdin, stdout or stderr.") .font(.caption) .foregroundStyle(.secondary) } } + .confirmationDialog( + "Clear Call History?", isPresented: $confirmsHistoryClear, + titleVisibility: .visible + ) { + Button("Clear History", role: .destructive) { app.toolAudit.clear() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This permanently removes all saved MCP call records.") + } } private var displayToken: String { server.token } diff --git a/TFM/Features/StatusBar/StatusBarView.swift b/TFM/Features/StatusBar/StatusBarView.swift index d1979208..2b151199 100644 --- a/TFM/Features/StatusBar/StatusBarView.swift +++ b/TFM/Features/StatusBar/StatusBarView.swift @@ -528,6 +528,7 @@ private struct TransferCenterPopover: View { let coordinator: SFTPTransferCoordinator let hosts: HostLibraryModel let dismiss: () -> Void + @State private var confirmsClearFinished = false private var transfers: [SFTPTransfer] { coordinator.sftpTransfers.sorted { $0.queuedAt > $1.queuedAt } @@ -548,8 +549,7 @@ private struct TransferCenterPopover: View { Spacer() if hasFinished { Button("Clear") { - coordinator.clearFinished() - if coordinator.sftpTransfers.isEmpty { dismiss() } + confirmsClearFinished = true } .buttonStyle(.plain) .termindInputAwareFocusEffect() @@ -581,6 +581,16 @@ private struct TransferCenterPopover: View { .frame(height: Metrics.TransferCenter.listHeight(itemCount: transferItems.count)) } .frame(width: Metrics.TransferCenter.width) + .confirmationDialog( + "Clear Finished Transfers?", isPresented: $confirmsClearFinished, + titleVisibility: .visible + ) { + Button("Clear Finished", role: .destructive) { + coordinator.clearFinished() + if coordinator.sftpTransfers.isEmpty { dismiss() } + } + Button("Cancel", role: .cancel) {} + } } private var transferItems: [TransferCenterListItem] { diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index a1627fa4..d8f88ec4 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -1217,7 +1217,6 @@ private struct WelcomeWorkspaceTemplateRow: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @Environment(\.controlActiveState) private var controlActiveState - @State private var confirmsDeletion = false @State private var isRenaming = false @State private var renameTitle = "" @@ -1232,22 +1231,10 @@ private struct WelcomeWorkspaceTemplateRow: View { Button("Rename", systemImage: MenuSymbol.rename) { beginRename() } Divider() DestructiveMenuButton("Delete") { - confirmsDeletion = true - } - } - .confirmationDialog( - "Delete Workspace Template?", - isPresented: $confirmsDeletion, - titleVisibility: .visible - ) { - Button("Delete", role: .destructive) { if !app.hostLibrary.deleteWorkspaceTemplate(template.id) { window.workspaceTemplateNotice = app.hostLibrary.storageError } } - Button("Cancel", role: .cancel) {} - } message: { - Text("The template “\(template.name)” will be removed from the Vault.") } } @@ -1346,6 +1333,7 @@ private struct WelcomeHostRow: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window + @State private var confirmsHistoryClear = false var body: some View { Button { @@ -1383,14 +1371,28 @@ private struct WelcomeHostRow: View { .termindGlassSurface(cornerRadius: Metrics.Radius.medium, isInteractive: true) .contextMenu { HostContextMenu( - host: host, - showsDelete: false + host: host ) Divider() DestructiveMenuButton("Delete") { - app.clearAllHistory(for: [host.id]) + if DeletionContentCategory.recordedContent.requiresConfirmation(for: .clearCollection) { + confirmsHistoryClear = true + } else { + app.connectionHistory.clear(for: host.id) + } } } + .confirmationDialog( + "Clear Recent Connections?", isPresented: $confirmsHistoryClear, + titleVisibility: .visible + ) { + Button("Clear History", role: .destructive) { + app.connectionHistory.clear(for: host.id) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes all connection history for this Host.") + } } @ViewBuilder diff --git a/TFMTests/WindowStateTests.swift b/TFMTests/WindowStateTests.swift index a8a88dfd..f604ca72 100644 --- a/TFMTests/WindowStateTests.swift +++ b/TFMTests/WindowStateTests.swift @@ -1037,6 +1037,26 @@ struct WindowStateTests { } } + @Test + func protectedVaultDeletionRequestsRemainPendingUntilConfirmed() { + let host = Host(name: "Protected", host: "protected.example.invalid") + let hosts = HostsPageState() + + hosts.requestDeletion(of: [host]) + + guard case .deleteHosts(let pendingHosts) = hosts.confirmation else { + Issue.record("Expected a pending Host deletion confirmation") + return + } + #expect(pendingHosts.map(\.id) == [host.id]) + + let identity = Identity(name: "Deploy", username: "deploy") + let keychain = KeychainPageState() + keychain.requestDeletion(of: [.identity(identity)]) + + #expect(keychain.deleting == [.identity(identity)]) + } + @Test func sessionsMixSSHAndSFTPInWorkspaceAndPaneOrder() { let firstHost = Host(name: "First", host: "first.example")