Unifed delete conformation logic
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)"))
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user