Unifed delete conformation logic

This commit is contained in:
Kaede Akino committed 2026-09-23 00:36:59 +08:00
1 parent 7c47e0478d
commit bc21d83b08
20 files changed
+491 -163

No files matched your search

@@ -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)
}
}
+145 -1
View File
@@ -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"
}
}
@@ -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)
}
}
@@ -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.")
}
}
}
}
+2 -17
View File
@@ -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<Bool> {
Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } })
}
private var errorPresented: Binding<Bool> {
Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
}
+16 -28
View File
@@ -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()
+12 -25
View File
@@ -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() }
}
}
@@ -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 {
+3 -3
View File
@@ -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)
}
}
@@ -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)
+16
View File
@@ -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
+15 -3
View File
@@ -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 {
+7 -12
View File
@@ -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)
}
+33 -9
View File
@@ -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<String> = []
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)"))
+55 -9
View File
@@ -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 {
@@ -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<Bool> {
Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } })
}
private var errorPresented: Binding<Bool> {
Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
}
+11 -1
View File
@@ -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 }
+12 -2
View File
@@ -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] {
@@ -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
+20
View File
@@ -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")