From 9d02147995118caa7214a432db7983ff956ca80a Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 29 Aug 2026 08:45:51 +0800 Subject: [PATCH] Add persistent connection history --- Termind/App/AppModel.swift | 43 ++++- Termind/App/TermindApp.swift | 3 + .../ConnectionHistoryModel.swift | 92 ++++++++++ .../FeatureModels/HostLibraryModel+Demo.swift | 3 +- .../FeatureModels/HostLibraryModel.swift | 29 --- .../ConnectionHistoryRepository.swift | 37 ++++ .../Connections/ConnectionHistoryEntry.swift | 31 ++++ Termind/Domain/Hosts/Host.swift | 1 - Termind/Features/Hosts/HostActions.swift | 34 +++- Termind/Features/Hosts/HostsPageView.swift | 4 +- .../Features/Keychain/KeychainPageView.swift | 4 +- .../Features/Ports/PortForwardingView.swift | 2 +- Termind/Features/SFTP/SFTPBrowserView.swift | 2 +- .../Features/Settings/ModelListEditor.swift | 2 +- .../Features/Sidebar/WindowSidebarView.swift | 104 +++++++---- .../Features/Snippets/SnippetsPageView.swift | 4 +- Termind/Features/Workspaces/Pane.swift | 5 +- .../Workspaces/WorkspaceContentView.swift | 4 +- .../Database/HostPersistence.swift | 28 ++- ...SwiftDataConnectionHistoryRepository.swift | 88 +++++++++ .../Database/SwiftDataHostRepository.swift | 8 + Termind/Resources/Localizable.xcstrings | 86 +++++---- Termind/UI/Components/NativeContextMenu.swift | 21 +++ TermindTests/ConnectionHistoryTests.swift | 172 ++++++++++++++++++ TermindTests/ListViewMigrationTests.swift | 31 +++- TermindTests/RepositoryOutboxTests.swift | 8 + TermindTests/SFTPSessionTests.swift | 14 ++ 27 files changed, 723 insertions(+), 137 deletions(-) create mode 100644 Termind/Application/FeatureModels/ConnectionHistoryModel.swift create mode 100644 Termind/Application/Repositories/ConnectionHistoryRepository.swift create mode 100644 Termind/Domain/Connections/ConnectionHistoryEntry.swift create mode 100644 Termind/Infrastructure/Database/SwiftDataConnectionHistoryRepository.swift create mode 100644 TermindTests/ConnectionHistoryTests.swift diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index 84ed1efa..b8b35a45 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -55,6 +55,7 @@ final class AppModel { let transfers: TransferLibraryModel let commandHistory: CommandHistoryModel let sftpHistory: SFTPHistoryModel + let connectionHistory: ConnectionHistoryModel private let externalToolCalls: ToolAuditModel var toolAudit: ToolAuditModel { externalToolCalls } let externalAuthentication: ExternalAuthCoordinator @@ -102,6 +103,7 @@ final class AppModel { hosts: HostLibraryModel, ai: AIConfigurationModel = AIConfigurationModel(), commandHistory: CommandHistoryModel = CommandHistoryModel(), sftpHistory: SFTPHistoryModel = SFTPHistoryModel(), + connectionHistory: ConnectionHistoryModel = ConnectionHistoryModel(), externalToolCalls: ToolAuditModel = ToolAuditModel(), dependencies: AppDependencies? = nil, speechTranscription: SpeechTranscriptionCoordinator? = nil @@ -115,6 +117,7 @@ final class AppModel { transfers = TransferLibraryModel(library: hosts) self.commandHistory = commandHistory self.sftpHistory = sftpHistory + self.connectionHistory = connectionHistory self.externalToolCalls = externalToolCalls externalAuthentication = ExternalAuthCoordinator() connections = connectionCoordinator @@ -247,6 +250,8 @@ final class AppModel { closeWorkspaces(for: hostID) sftpTransfers.cancelAll(for: hostID) commandHistory.clear(for: hostID) + sftpHistory.clear(for: hostID) + connectionHistory.clear(for: hostID) } } if !changedRuleIDs.isEmpty { forwarding.stop(Array(changedRuleIDs)) } @@ -851,6 +856,7 @@ final class AppModel { try hostRepository.requestHostDeletion(hostID) commandHistory.clear(for: hostID) sftpHistory.clear(for: hostID) + connectionHistory.clear(for: hostID) hosts.projectHostDeletion(hostID) } catch { hosts.reportStorageError(error) @@ -957,7 +963,12 @@ final class AppModel { /// thing that should differ between them. Inlining this was fine while there /// were two. private func beginOpening(_ host: Host, as target: HostOpenTarget) { - hosts.markUsed(host.id, at: Date()) + if hosts.host(host.id) != nil { + connectionHistory.record( + kind: target == .sshSession ? .ssh : .sftp, + for: host.id + ) + } switch target { case .sshSession: hosts.setStatus(.connecting, for: host.id) @@ -966,6 +977,36 @@ final class AppModel { } } + /// Clears every user-facing history owned by the selected saved Hosts. + /// Host records and credentials remain intact. + func clearAllHistory(for hostIDs: [Host.ID]) { + for hostID in Set(hostIDs) { + connectionHistory.clear(for: hostID) + commandHistory.clear(for: hostID) + sftpHistory.clear(for: hostID) + } + } + + /// Clears the global connection timeline and the Recents projection it + /// supersedes in the History sidebar. + func clearConnectionHistory() { + connectionHistory.clearAll() + } + + /// Saved Hosts ordered by their latest connection event, with each Host + /// appearing only once. + func recentHosts(limit: Int = 5) -> [Host] { + var seen = Set() + return connectionHistory.entries().compactMap { entry in + guard seen.insert(entry.hostID).inserted else { return nil } + return hosts.host(entry.hostID) + }.prefix(limit).map { $0 } + } + + func lastConnectedAt(for hostID: Host.ID) -> Date? { + connectionHistory.mostRecentDate(for: hostID) + } + /// Batch Startup, in either of its two shapes. /// /// The workspaces path is a loop over the singular `open` rather than its own code diff --git a/Termind/App/TermindApp.swift b/Termind/App/TermindApp.swift index b924e464..b8384ca8 100644 --- a/Termind/App/TermindApp.swift +++ b/Termind/App/TermindApp.swift @@ -65,6 +65,9 @@ struct TermindApp: App { ), commandHistory: CommandHistoryModel(modelContext: container.mainContext), sftpHistory: SFTPHistoryModel(modelContext: container.mainContext), + connectionHistory: ConnectionHistoryModel( + modelContext: container.mainContext + ), externalToolCalls: externalToolCalls, dependencies: AppDependencies( hostRepository: repository, diff --git a/Termind/Application/FeatureModels/ConnectionHistoryModel.swift b/Termind/Application/FeatureModels/ConnectionHistoryModel.swift new file mode 100644 index 00000000..0c1a4904 --- /dev/null +++ b/Termind/Application/FeatureModels/ConnectionHistoryModel.swift @@ -0,0 +1,92 @@ +import Foundation + +@MainActor +@Observable +final class ConnectionHistoryModel { + static let limit = 1_000 + + private let repository: any ConnectionHistoryRepository + private var cachedEntries: [ConnectionHistoryEntry]? + private(set) var revision: UInt64 = 0 + private(set) var storageError: String? + + init( + entries: [ConnectionHistoryEntry] = [], + repository: (any ConnectionHistoryRepository)? = nil + ) { + self.repository = repository + ?? InMemoryConnectionHistoryRepository(entries: entries) + } + + func entries() -> [ConnectionHistoryEntry] { + _ = revision + if let cachedEntries { return cachedEntries } + do { + let values = try repository.entries() + cachedEntries = values + storageError = nil + return values + } catch { + storageError = error.localizedDescription + return [] + } + } + + func entries(for hostID: Host.ID) -> [ConnectionHistoryEntry] { + entries().filter { $0.hostID == hostID } + } + + func mostRecentDate(for hostID: Host.ID) -> Date? { + entries().first { $0.hostID == hostID }?.connectedAt + } + + @discardableResult + func record( + kind: ConnectionHistoryEntry.Kind, for hostID: Host.ID, + at date: Date = Date() + ) -> ConnectionHistoryEntry? { + let entry = ConnectionHistoryEntry( + hostID: hostID, kind: kind, connectedAt: date + ) + do { + try repository.insert(entry, limit: Self.limit) + cachedEntries = nil + storageError = nil + revision &+= 1 + return entry + } catch { + storageError = error.localizedDescription + return nil + } + } + + func delete(_ id: ConnectionHistoryEntry.ID) { + do { + try repository.delete(id) + cachedEntries = nil + storageError = nil + revision &+= 1 + } + catch { storageError = error.localizedDescription } + } + + func clear(for hostID: Host.ID) { + do { + try repository.clear(for: hostID) + cachedEntries = nil + storageError = nil + revision &+= 1 + } + catch { storageError = error.localizedDescription } + } + + func clearAll() { + do { + try repository.clearAll() + cachedEntries = [] + storageError = nil + revision &+= 1 + } + catch { storageError = error.localizedDescription } + } +} diff --git a/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift b/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift index eef13fc6..90a77032 100644 --- a/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift +++ b/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift @@ -51,8 +51,7 @@ extension HostLibraryModel { // Half the fleet is left without an identity on purpose, so the "not set // yet" state is visible at P0 instead of first appearing in the field. // - // No host carries `lastUsedAt`, for the same reason. A fresh install has - // no connection history, so Recents starts empty and the sidebar shows + // A fresh install has no connection history, so Recents starts empty and the sidebar shows // its placeholder; seeding a history here put that empty state out of // reach and told a first launch it had already been on production. // `AppModel.open` fills the list the moment anything is opened. diff --git a/Termind/Application/FeatureModels/HostLibraryModel.swift b/Termind/Application/FeatureModels/HostLibraryModel.swift index ddc59de9..6cc7bb21 100644 --- a/Termind/Application/FeatureModels/HostLibraryModel.swift +++ b/Termind/Application/FeatureModels/HostLibraryModel.swift @@ -202,14 +202,6 @@ final class HostLibraryModel { return hosts.filter { host in host.groupID.map(tree.contains) ?? false } } - func recents(limit: Int = 5) -> [Host] { - hosts - .compactMap { host in host.lastUsedAt.map { (host, $0) } } - .sorted { $0.1 > $1.1 } - .prefix(limit) - .map(\.0) - } - func portForwardings(for host: Host.ID) -> [PortForwardingRule] { portForwardings.filter { $0.hostID == host } } @@ -764,27 +756,6 @@ final class HostLibraryModel { statuses[hostID] = nil } - func markUsed(_ id: Host.ID, at date: Date) { - guard let index = hosts.firstIndex(where: { $0.id == id }) else { return } - hosts[index].lastUsedAt = date - persistCore() - } - - /// Empties the Recents list. Only `lastUsedAt` is cleared — the hosts - /// themselves stay, because "clear recents" is about history, not data. - func clearRecents() { - for index in hosts.indices { - hosts[index].lastUsedAt = nil - } - persistCore() - } - - func forgetRecent(_ id: Host.ID) { - guard let index = hosts.firstIndex(where: { $0.id == id }) else { return } - hosts[index].lastUsedAt = nil - persistCore() - } - func setStatus(_ status: ConnectionStatus, for id: Host.ID) { statuses[id] = status } diff --git a/Termind/Application/Repositories/ConnectionHistoryRepository.swift b/Termind/Application/Repositories/ConnectionHistoryRepository.swift new file mode 100644 index 00000000..0f3c7d84 --- /dev/null +++ b/Termind/Application/Repositories/ConnectionHistoryRepository.swift @@ -0,0 +1,37 @@ +import Foundation + +@MainActor +protocol ConnectionHistoryRepository: AnyObject { + func entries() throws -> [ConnectionHistoryEntry] + func insert(_ entry: ConnectionHistoryEntry, limit: Int) throws + func delete(_ id: ConnectionHistoryEntry.ID) throws + func clear(for hostID: Host.ID) throws + func clearAll() throws +} + +@MainActor +final class InMemoryConnectionHistoryRepository: ConnectionHistoryRepository { + private var values: [ConnectionHistoryEntry] + + init(entries: [ConnectionHistoryEntry] = []) { values = entries } + + func entries() throws -> [ConnectionHistoryEntry] { + values.sorted { $0.connectedAt > $1.connectedAt } + } + + func insert(_ entry: ConnectionHistoryEntry, limit: Int) throws { + values.append(entry) + let retained = Set(try entries().prefix(limit).map(\.id)) + values.removeAll { !retained.contains($0.id) } + } + + func delete(_ id: ConnectionHistoryEntry.ID) throws { + values.removeAll { $0.id == id } + } + + func clear(for hostID: Host.ID) throws { + values.removeAll { $0.hostID == hostID } + } + + func clearAll() throws { values.removeAll() } +} diff --git a/Termind/Domain/Connections/ConnectionHistoryEntry.swift b/Termind/Domain/Connections/ConnectionHistoryEntry.swift new file mode 100644 index 00000000..90d2c333 --- /dev/null +++ b/Termind/Domain/Connections/ConnectionHistoryEntry.swift @@ -0,0 +1,31 @@ +import Foundation + +/// One user-initiated connection to a saved Host. +/// +/// Every attempt is retained as its own event so the History sidebar can show +/// repeated visits to the same destination. +nonisolated struct ConnectionHistoryEntry: Identifiable, Hashable, Sendable { + nonisolated enum Kind: String, Hashable, Sendable { + case ssh + case sftp + + var label: String { + switch self { + case .ssh: String(localized: "SSH") + case .sftp: String(localized: "SFTP") + } + } + + var symbol: String { + switch self { + case .ssh: "terminal" + case .sftp: "folder" + } + } + } + + var id: UUID = UUID() + var hostID: Host.ID + var kind: Kind + var connectedAt: Date = Date() +} diff --git a/Termind/Domain/Hosts/Host.swift b/Termind/Domain/Hosts/Host.swift index 1e31a150..36e0a6c3 100644 --- a/Termind/Domain/Hosts/Host.swift +++ b/Termind/Domain/Hosts/Host.swift @@ -75,7 +75,6 @@ nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { var groupID: HostGroup.ID? var tags: [String] = [] var colorTag: HostColor = .none - var lastUsedAt: Date? /// Fractional index so reordering never has to renumber siblings — CloudKit /// has no ordered relationships, so order has to be an ordinary attribute. var sortIndex: Double = 0 diff --git a/Termind/Features/Hosts/HostActions.swift b/Termind/Features/Hosts/HostActions.swift index 9bb079d1..833f50dc 100644 --- a/Termind/Features/Hosts/HostActions.swift +++ b/Termind/Features/Hosts/HostActions.swift @@ -60,7 +60,6 @@ struct HostContextMenu: View { var copy = host copy.id = UUID() copy.name = String(localized: "\(host.name) copy") - copy.lastUsedAt = nil app.hostLibrary.upsert(copy) } } @@ -78,16 +77,37 @@ struct HostContextMenu: View { Divider() } - Button("Remove from Recents") { - for host in hosts { app.hostLibrary.forgetRecent(host.id) } - } - .disabled(!hosts.contains { $0.lastUsedAt != nil }) - Button("Delete", role: .destructive) { - for host in hosts { app.hostLibrary.delete(hostID: host.id) } + DestructiveMenuButton("Clear All History", action: confirmClearAllHistory) + .disabled(!hasHistory) + DestructiveMenuButton("Delete") { + for host in hosts { app.requestHostDeletion(host.id) } selectionDeleted?() } .disabled(hosts.isEmpty) } + + private func confirmClearAllHistory() { + #if canImport(AppKit) + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "Clear all history for the selected Hosts?") + alert.informativeText = String( + localized: "This permanently removes connection, command, and File History without deleting the Hosts." + ) + alert.addButton(withTitle: String(localized: "Clear All History")) + alert.addButton(withTitle: String(localized: "Cancel")) + guard alert.runModal() == .alertFirstButtonReturn else { return } + #endif + app.clearAllHistory(for: hosts.map(\.id)) + } + + private var hasHistory: Bool { + hosts.contains { host in + !app.connectionHistory.entries(for: host.id).isEmpty + || !app.commandHistory.entries(for: host.id).isEmpty + || !app.sftpHistory.entries(for: host.id).isEmpty + } + } } /// `HostContextMenu` as native menu entries, for the sidebar rows that draw diff --git a/Termind/Features/Hosts/HostsPageView.swift b/Termind/Features/Hosts/HostsPageView.swift index 17a5dedb..32739000 100644 --- a/Termind/Features/Hosts/HostsPageView.swift +++ b/Termind/Features/Hosts/HostsPageView.swift @@ -203,7 +203,7 @@ struct HostsPageView: ManagedCardPage { .contextMenu { Button("Edit") { editGroup(group) } Divider() - Button("Delete Group", role: .destructive) { requestDelete(group) } + DestructiveMenuButton("Delete Group") { requestDelete(group) } } } else { CardSectionHeader( @@ -538,7 +538,7 @@ private struct HostCard: View { Spacer(minLength: Metrics.Space.xs) Text( - host.lastUsedAt + app.lastConnectedAt(for: host.id) .map { $0.formatted(.relative(presentation: .numeric).locale(locale)) } ?? String(localized: "Never") ) diff --git a/Termind/Features/Keychain/KeychainPageView.swift b/Termind/Features/Keychain/KeychainPageView.swift index 7b46c079..830efcce 100644 --- a/Termind/Features/Keychain/KeychainPageView.swift +++ b/Termind/Features/Keychain/KeychainPageView.swift @@ -1529,7 +1529,7 @@ private struct KeyCard: View { .disabled(actionKeys.isEmpty || actionKeys.contains { $0.publicKey.isEmpty }) Divider() } - Button("Delete", role: .destructive, action: delete) + DestructiveMenuButton("Delete", action: delete) } .help(Text(key.fingerprint)) } @@ -1614,7 +1614,7 @@ private struct IdentityCard: View { if actionKeys.isEmpty, actionIdentities.count == 1 { IdentityObjectActions(edit: edit) } - Button("Delete", role: .destructive, action: delete) + DestructiveMenuButton("Delete", action: delete) } .help(Text(identity.notes.isEmpty ? identity.username : identity.notes)) } diff --git a/Termind/Features/Ports/PortForwardingView.swift b/Termind/Features/Ports/PortForwardingView.swift index 0a8d2d7f..3d7d44a3 100644 --- a/Termind/Features/Ports/PortForwardingView.swift +++ b/Termind/Features/Ports/PortForwardingView.swift @@ -167,7 +167,7 @@ private struct HostForwardingContextMenu: View { Button("Copy SSH Command", action: copyCommand) .disabled(isActive || command == nil) Divider() - Button("Delete", role: .destructive, action: delete).disabled(isActive) + DestructiveMenuButton("Delete", action: delete).disabled(isActive) } private func toggle() { diff --git a/Termind/Features/SFTP/SFTPBrowserView.swift b/Termind/Features/SFTP/SFTPBrowserView.swift index 067e6a25..546f8ecd 100644 --- a/Termind/Features/SFTP/SFTPBrowserView.swift +++ b/Termind/Features/SFTP/SFTPBrowserView.swift @@ -955,7 +955,7 @@ struct SFTPBrowserView: View { .disabled(!sftpSession.allowsRemoteMutations) } } - Button(targets.count == 1 ? "Delete" : "Delete \(targets.count) Items", role: .destructive) { + DestructiveMenuButton(targets.count == 1 ? "Delete" : "Delete \(targets.count) Items") { deleteTargets = targets } .disabled(!sftpSession.allowsRemoteMutations) diff --git a/Termind/Features/Settings/ModelListEditor.swift b/Termind/Features/Settings/ModelListEditor.swift index e66cd777..f464040a 100644 --- a/Termind/Features/Settings/ModelListEditor.swift +++ b/Termind/Features/Settings/ModelListEditor.swift @@ -1124,7 +1124,7 @@ private struct ModelRow: View { Button("Test", action: test) .disabled(!canTest) Divider() - Button("Delete", role: .destructive, action: delete) + DestructiveMenuButton("Delete", action: delete) } .help(Text(result?.message ?? model.id)) .accessibilityElement(children: .combine) diff --git a/Termind/Features/Sidebar/WindowSidebarView.swift b/Termind/Features/Sidebar/WindowSidebarView.swift index bf870a40..0fb96b54 100644 --- a/Termind/Features/Sidebar/WindowSidebarView.swift +++ b/Termind/Features/Sidebar/WindowSidebarView.swift @@ -147,7 +147,7 @@ nonisolated struct SidebarListItem: Identifiable, Hashable, Sendable { case snippet(Snippet.ID) case history(CommandHistoryEntry.ID) case sftpHistory(SFTPHistoryEntry.ID) - case connection(Host.ID) + case connection(ConnectionHistoryEntry.ID) } nonisolated enum Content: Hashable, Sendable { @@ -158,7 +158,7 @@ nonisolated struct SidebarListItem: Identifiable, Hashable, Sendable { case snippet(Snippet) case history(CommandHistoryEntry, isNaming: Bool) case sftpHistory(SFTPHistoryEntry) - case connection(Host, address: String) + case connection(ConnectionHistoryEntry, Host, address: String) } let id: ID @@ -180,8 +180,13 @@ nonisolated struct SidebarListItem: Identifiable, Hashable, Sendable { .init(id: .sftpHistory(entry.id), content: .sftpHistory(entry)) } - static func connection(_ host: Host, address: String) -> Self { - .init(id: .connection(host.id), content: .connection(host, address: address)) + static func connection( + _ entry: ConnectionHistoryEntry, host: Host, address: String + ) -> Self { + .init( + id: .connection(entry.id), + content: .connection(entry, host, address: address) + ) } /// One height for every content card, because they are one card with four @@ -903,7 +908,7 @@ private struct SSHCommandHistorySidebar: View { }, .separator, NativeMenuEntry( - title: String(localized: "Delete from History"), + title: String(localized: "Remove this History"), isDestructive: true ) { delete(entry) }, NativeMenuEntry( @@ -1036,7 +1041,7 @@ private struct SFTPBrowseHistorySidebar: View { SidebarSearchFooter(text: $query, placeholder: String(localized: "Search History")) } .confirmationDialog( - "Clear SFTP history for this Host?", isPresented: $confirmsClear, + "Clear File History for this Host?", isPresented: $confirmsClear, titleVisibility: .visible ) { Button("Clear History", role: .destructive) { @@ -1050,7 +1055,8 @@ private struct SFTPBrowseHistorySidebar: View { [ NativeMenuEntry(title: String(localized: "Open"), isEnabled: false), NativeMenuEntry( - title: String(localized: "Delete from History"), isEnabled: false + title: String(localized: "Remove this History"), + isEnabled: false, isDestructive: true ), .separator, NativeMenuEntry( @@ -1080,7 +1086,7 @@ private struct SFTPBrowseHistorySidebar: View { NativeMenuEntry(title: String(localized: "Open")) { reopen(entry) }, .separator, NativeMenuEntry( - title: String(localized: "Delete from History"), isDestructive: true + title: String(localized: "Remove this History"), isDestructive: true ) { app.sftpHistory.delete(entry.id) }, NativeMenuEntry( title: String(localized: "Clear History"), isDestructive: true @@ -1108,24 +1114,30 @@ private struct ConnectionHistorySidebar: View { @Environment(\.locale) private var locale @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window - @AppStorage(SettingsKey.showRecents) private var showRecents = true @State private var query = "" - @State private var selectedID: Host.ID? + @State private var selectedID: ConnectionHistoryEntry.ID? @State private var confirmsClear = false - private var allHosts: [Host] { showRecents ? app.hostLibrary.recents(limit: 10) : [] } + private var allRows: [(entry: ConnectionHistoryEntry, host: Host)] { + _ = app.connectionHistory.revision + return app.connectionHistory.entries().compactMap { entry in + app.hostLibrary.host(entry.hostID).map { (entry, $0) } + } + } - private var hosts: [Host] { - guard !query.isEmpty else { return allHosts } - return allHosts.filter { - $0.matches(query) - || app.hostLibrary.loginAddress(for: $0).localizedCaseInsensitiveContains(query) + private var rows: [(entry: ConnectionHistoryEntry, host: Host)] { + guard !query.isEmpty else { return allRows } + return allRows.filter { entry, host in + host.matches(query) + || app.hostLibrary.loginAddress(for: host) + .localizedCaseInsensitiveContains(query) + || entry.kind.label.localizedCaseInsensitiveContains(query) } } var body: some View { VStack(spacing: 0) { - if hosts.isEmpty { + if rows.isEmpty { Group { if query.isEmpty { ContentUnavailableView( @@ -1139,18 +1151,20 @@ private struct ConnectionHistorySidebar: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .sidebarBlankAreaMenu { globalMenu } } else { - // Never animated. Opening a host moves it to the top of - // Recents, and animating that reorder made the list jump - // upwards under a pointer aimed at the row that had just moved. + // Never animated. Reopening a record inserts a new one at the + // top, and animating that insertion moves the clicked row. SidebarNativeList( - items: hosts.map { - .connection($0, address: app.hostLibrary.loginAddress(for: $0)) + items: rows.map { entry, host in + .connection( + entry, host: host, + address: app.hostLibrary.loginAddress(for: host) + ) }, animatesStructuralChanges: false, blankAreaMenu: { globalMenu } ) { item in - if case .connection(let host, let address) = item.content { - row(host, address: address) + if case .connection(let entry, let host, let address) = item.content { + row(entry, host: host, address: address) } } } @@ -1163,11 +1177,11 @@ private struct ConnectionHistorySidebar: View { titleVisibility: .visible ) { Button("Clear History", role: .destructive) { - app.hostLibrary.clearRecents() + app.clearConnectionHistory() } Button("Cancel", role: .cancel) {} } message: { - Text("This removes all Hosts from Recents without deleting the Hosts themselves.") + Text("This permanently removes all connection history without deleting any Hosts.") } } @@ -1175,41 +1189,51 @@ private struct ConnectionHistorySidebar: View { [ NativeMenuEntry(title: String(localized: "Quick Connect"), isEnabled: false), NativeMenuEntry( - title: String(localized: "Delete from History"), isEnabled: false + title: String(localized: "Remove this History"), + isEnabled: false, isDestructive: true ), .separator, NativeMenuEntry( title: String(localized: "Clear History"), - isEnabled: !allHosts.isEmpty, + isEnabled: !allRows.isEmpty, isDestructive: true ) { confirmsClear = true }, ] } - private func row(_ host: Host, address: String) -> some View { + private func row( + _ entry: ConnectionHistoryEntry, host: Host, address: String + ) -> some View { SidebarCard( - symbol: "server.rack", + symbol: entry.kind.symbol, title: host.name, subtitle: address, - isSelected: selectedID == host.id + isSelected: selectedID == entry.id ) { - if let date = host.lastUsedAt { - Text(date, format: .relative(presentation: .numeric).locale(locale)) + VStack(alignment: .trailing, spacing: 1) { + Text(entry.kind.label) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + Text(entry.connectedAt, format: .relative(presentation: .numeric).locale(locale)) .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) } } .onTapGesture(count: 2) { - app.open(host, as: .sshSession, in: window) + app.open( + host, + as: entry.kind == .ssh ? .sshSession : .sftpSession, + in: window + ) } - .onTapGesture { selectedID = host.id } - .termindSidebarRow(isSelected: selectedID == host.id) { + .onTapGesture { selectedID = entry.id } + .termindSidebarRow(isSelected: selectedID == entry.id) { HostMenuEntries.entries(for: host, app: app, window: window) + [ .separator, NativeMenuEntry( - title: String(localized: "Delete from History"), isDestructive: true - ) { app.hostLibrary.forgetRecent(host.id) }, + title: String(localized: "Remove this History"), isDestructive: true + ) { app.connectionHistory.delete(entry.id) }, NativeMenuEntry( title: String(localized: "Clear History"), isDestructive: true ) { confirmsClear = true }, @@ -1266,8 +1290,8 @@ private struct CommandHistoryCard: View { .contentShape(.rect) } .buttonStyle(.plain) - .help("Delete from History") - .accessibilityLabel("Delete from History") + .help("Remove this History") + .accessibilityLabel("Remove this History") } else { RiskBadge( assessment: CommandRiskClassifier.assess(entry.command), diff --git a/Termind/Features/Snippets/SnippetsPageView.swift b/Termind/Features/Snippets/SnippetsPageView.swift index d8ee6ce0..d90ec160 100644 --- a/Termind/Features/Snippets/SnippetsPageView.swift +++ b/Termind/Features/Snippets/SnippetsPageView.swift @@ -180,7 +180,7 @@ struct SnippetsPageView: ManagedCardPage { openPackageEditor(packageID: package.id, parentID: package.parentID) } Divider() - Button("Delete Package", role: .destructive) { + DestructiveMenuButton("Delete Package") { state.confirmation = .deletePackage(package) } } @@ -542,7 +542,7 @@ private struct SnippetCard: View { Button("Duplicate") { duplicate(snippet) } } Divider() - Button("Delete", role: .destructive) { delete(actionSnippets) } + DestructiveMenuButton("Delete") { delete(actionSnippets) } } .help(Text(snippet.body)) } diff --git a/Termind/Features/Workspaces/Pane.swift b/Termind/Features/Workspaces/Pane.swift index e0362660..4a19e131 100644 --- a/Termind/Features/Workspaces/Pane.swift +++ b/Termind/Features/Workspaces/Pane.swift @@ -293,10 +293,13 @@ final class Pane: Identifiable { func subtitle(in store: HostLibraryModel) -> String? { switch content { + case .sftpSession(let sftpSession): + sftpSession.path case _ where customTitle != nil: // A renamed pane shows its name and nothing under it: the subtitle // is the derived detail, and pairing it with a name the user chose - // reads as a correction. + // reads as a correction. SFTP is the exception above: its live + // path is navigation state, not an alternate name. nil case .sshSession(let sshSession): sshSession.status.isLive ? sshSession.workingDirectory : nil diff --git a/Termind/Features/Workspaces/WorkspaceContentView.swift b/Termind/Features/Workspaces/WorkspaceContentView.swift index b91e1d68..58c630b9 100644 --- a/Termind/Features/Workspaces/WorkspaceContentView.swift +++ b/Termind/Features/Workspaces/WorkspaceContentView.swift @@ -901,7 +901,7 @@ struct WelcomeView: View { @ViewBuilder private var results: some View { if query.isEmpty { - let hosts = app.hostLibrary.recents(limit: 6) + let hosts = app.recentHosts(limit: 6) if !hosts.isEmpty { section("Recents") { ForEach(hosts) { host in @@ -977,7 +977,7 @@ private struct WelcomeHostRow: View { @ViewBuilder private var trailing: some View { - if let date = host.lastUsedAt { + if let date = app.lastConnectedAt(for: host.id) { Text(date, format: .relative(presentation: .numeric)) .foregroundStyle(.tertiary) } diff --git a/Termind/Infrastructure/Database/HostPersistence.swift b/Termind/Infrastructure/Database/HostPersistence.swift index 8bf49c25..c45bcfc4 100644 --- a/Termind/Infrastructure/Database/HostPersistence.swift +++ b/Termind/Infrastructure/Database/HostPersistence.swift @@ -18,6 +18,9 @@ final class PersistentHost { var groupID: UUID? var tags: [String] = [] var colorTag = HostColor.none.rawValue + /// Legacy V1–V5 storage retained only so existing stores can migrate. + /// Recents and last-connected presentation are derived from connection + /// history; this column is never read or written by current code. var lastUsedAt: Date? var sortIndex = 0.0 @@ -41,7 +44,6 @@ final class PersistentHost { groupID = value.groupID tags = value.tags colorTag = value.colorTag.rawValue - lastUsedAt = value.lastUsedAt sortIndex = value.sortIndex } @@ -50,7 +52,7 @@ final class PersistentHost { id: id, name: name, host: address, port: port, identityID: identityID, groupID: groupID, tags: tags, colorTag: HostColor(rawValue: colorTag) ?? .none, - lastUsedAt: lastUsedAt, sortIndex: sortIndex + sortIndex: sortIndex ) host.jumpHostID = jumpHostID host.sftpSessionInitialPath = sftpSessionInitialPath @@ -371,11 +373,19 @@ enum TermindSchemaV5: VersionedSchema { } } +enum TermindSchemaV6: VersionedSchema { + static let versionIdentifier = Schema.Version(6, 0, 0) + + static var models: [any PersistentModel.Type] { + TermindSchemaV5.models + [PersistentConnectionHistoryEntry.self] + } +} + enum TermindMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [ TermindSchemaV1.self, TermindSchemaV2.self, TermindSchemaV3.self, - TermindSchemaV4.self, TermindSchemaV5.self, + TermindSchemaV4.self, TermindSchemaV5.self, TermindSchemaV6.self, ] } @@ -392,6 +402,16 @@ enum TermindMigrationPlan: SchemaMigrationPlan { ), MigrationStage.lightweight( fromVersion: TermindSchemaV4.self, toVersion: TermindSchemaV5.self + ), + MigrationStage.custom( + fromVersion: TermindSchemaV5.self, toVersion: TermindSchemaV6.self, + willMigrate: nil, + didMigrate: { context in + for host in try context.fetch(FetchDescriptor()) { + host.lastUsedAt = nil + } + try context.save() + } ) ] } @@ -399,7 +419,7 @@ enum TermindMigrationPlan: SchemaMigrationPlan { @MainActor final class HostPersistence { - static let schema = Schema(versionedSchema: TermindSchemaV5.self) + static let schema = Schema(versionedSchema: TermindSchemaV6.self) let context: ModelContext diff --git a/Termind/Infrastructure/Database/SwiftDataConnectionHistoryRepository.swift b/Termind/Infrastructure/Database/SwiftDataConnectionHistoryRepository.swift new file mode 100644 index 00000000..842c2110 --- /dev/null +++ b/Termind/Infrastructure/Database/SwiftDataConnectionHistoryRepository.swift @@ -0,0 +1,88 @@ +import Foundation +import SwiftData + +@Model +final class PersistentConnectionHistoryEntry { + @Attribute(.unique) var id: UUID = UUID() + var hostID: UUID = UUID() + var kind = ConnectionHistoryEntry.Kind.ssh.rawValue + var connectedAt = Date(timeIntervalSince1970: 0) + + init(_ value: ConnectionHistoryEntry) { + id = value.id + hostID = value.hostID + kind = value.kind.rawValue + connectedAt = value.connectedAt + } + + var value: ConnectionHistoryEntry { + ConnectionHistoryEntry( + id: id, hostID: hostID, + kind: ConnectionHistoryEntry.Kind(rawValue: kind) ?? .ssh, + connectedAt: connectedAt + ) + } +} + +@MainActor +final class SwiftDataConnectionHistoryRepository: ConnectionHistoryRepository { + private let context: ModelContext + init(context: ModelContext) { self.context = context } + + func entries() throws -> [ConnectionHistoryEntry] { + try context.fetch(FetchDescriptor( + sortBy: [SortDescriptor(\.connectedAt, order: .reverse)] + )).map(\.value) + } + + func insert(_ entry: ConnectionHistoryEntry, limit: Int) throws { + do { + context.insert(PersistentConnectionHistoryEntry(entry)) + try context.save() + + var overflow = FetchDescriptor( + sortBy: [SortDescriptor(\.connectedAt, order: .reverse)] + ) + overflow.fetchOffset = limit + let oldRows = try context.fetch(overflow) + if !oldRows.isEmpty { + for row in oldRows { context.delete(row) } + try context.save() + } + } catch { context.rollback(); throw error } + } + + func delete(_ id: ConnectionHistoryEntry.ID) throws { + do { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + if let row = try context.fetch(descriptor).first { context.delete(row) } + try context.save() + } catch { context.rollback(); throw error } + } + + func clear(for hostID: Host.ID) throws { + do { + try context.delete( + model: PersistentConnectionHistoryEntry.self, + where: #Predicate { $0.hostID == hostID } + ) + try context.save() + } catch { context.rollback(); throw error } + } + + func clearAll() throws { + do { + try context.delete(model: PersistentConnectionHistoryEntry.self) + try context.save() + } catch { context.rollback(); throw error } + } +} + +extension ConnectionHistoryModel { + convenience init(modelContext: ModelContext) { + self.init(repository: SwiftDataConnectionHistoryRepository(context: modelContext)) + } +} diff --git a/Termind/Infrastructure/Database/SwiftDataHostRepository.swift b/Termind/Infrastructure/Database/SwiftDataHostRepository.swift index 020caf9e..66121215 100644 --- a/Termind/Infrastructure/Database/SwiftDataHostRepository.swift +++ b/Termind/Infrastructure/Database/SwiftDataHostRepository.swift @@ -115,6 +115,14 @@ final class SwiftDataHostRepository: HostRepository { model: PersistentCommandHistoryEntry.self, where: #Predicate { $0.hostID == hostID } ) + try context.delete( + model: PersistentSFTPHistoryEntry.self, + where: #Predicate { $0.hostID == hostID } + ) + try context.delete( + model: PersistentConnectionHistoryEntry.self, + where: #Predicate { $0.hostID == hostID } + ) try commitMutation() } diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 1566a093..31ce98ee 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -2335,6 +2335,26 @@ } } }, + "Clear All History" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除所有历史" + } + } + } + }, + "Clear all history for the selected Hosts?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除所选主机的所有历史?" + } + } + } + }, "Clear command history for this Host?" : { "localizations" : { "zh-Hans" : { @@ -2357,6 +2377,16 @@ } } }, + "Clear File History for this Host?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除此主机的文件历史?" + } + } + } + }, "Clear filter" : { "localizations" : { "zh-Hans" : { @@ -2407,16 +2437,6 @@ } } }, - "Clear SFTP history for this Host?" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "清除此主机的SFTP历史记录?" - } - } - } - }, "Close All Workspaces" : { "localizations" : { "zh-Hans" : { @@ -3515,16 +3535,6 @@ } } }, - "Delete from History" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "从历史中删除" - } - } - } - }, "Delete Group" : { "localizations" : { "zh-Hans" : { @@ -8664,12 +8674,12 @@ } } }, - "Remove from Recents" : { + "Remove this History" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "从最近使用中移除" + "value" : "移除此条历史" } } } @@ -12310,6 +12320,16 @@ } } }, + "This permanently removes all connection history without deleting any Hosts." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将永久清除所有连接历史,但不会删除任何主机。" + } + } + } + }, "This permanently removes all saved commands for the current Host." : { "localizations" : { "zh-Hans" : { @@ -12320,6 +12340,16 @@ } } }, + "This permanently removes connection, command, and File History without deleting the Hosts." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将永久清除连接、命令和文件历史,但不会删除主机。" + } + } + } + }, "This permanently removes the conversation and its scratch files." : { "localizations" : { "zh-Hans" : { @@ -12330,18 +12360,6 @@ } } }, - "This removes all Hosts from Recents without deleting the Hosts themselves." : { - "comment" : "A message displayed in a confirmation dialog.", - "isCommentAutoGenerated" : true, - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "这会从最近使用中移除所有主机,但不会删除主机本身。" - } - } - } - }, "This run was interrupted and was not resumed." : { "localizations" : { "zh-Hans" : { diff --git a/Termind/UI/Components/NativeContextMenu.swift b/Termind/UI/Components/NativeContextMenu.swift index 4f18efcf..e80a22f3 100644 --- a/Termind/UI/Components/NativeContextMenu.swift +++ b/Termind/UI/Components/NativeContextMenu.swift @@ -1,6 +1,27 @@ import AppKit import SwiftUI +/// A destructive action inside a SwiftUI context menu. +/// +/// `ButtonRole.destructive` communicates semantics but does not consistently +/// tint macOS context-menu titles, so the label carries the same explicit red +/// treatment as `NativeMenuEntry.isDestructive`. +struct DestructiveMenuButton: View { + let title: LocalizedStringKey + let action: () -> Void + + init(_ title: LocalizedStringKey, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + var body: some View { + Button(role: .destructive, action: action) { + Text(title).foregroundStyle(.red) + } + } +} + /// One row of a hand-built native menu. /// /// A value type rather than an `NSMenuItem` so callers never own a target, keep diff --git a/TermindTests/ConnectionHistoryTests.swift b/TermindTests/ConnectionHistoryTests.swift new file mode 100644 index 00000000..b5ded320 --- /dev/null +++ b/TermindTests/ConnectionHistoryTests.swift @@ -0,0 +1,172 @@ +import Foundation +import SwiftData +import Testing + +@testable import Termind + +@Suite +@MainActor +struct ConnectionHistoryTests { + @Test + func schemaV5MigratesWithAnEmptyConnectionTimeline() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ConnectionHistoryMigration-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("Termind.store") + let legacySchema = Schema(versionedSchema: TermindSchemaV5.self) + + var legacyContainer: ModelContainer? = try ModelContainer( + for: legacySchema, + configurations: [ + localModelConfiguration( + "ConnectionHistoryMigration", schema: legacySchema, url: storeURL + ) + ] + ) + let legacyHost = PersistentHost(Host(name: "Existing", host: "existing.example")) + legacyHost.lastUsedAt = Date(timeIntervalSince1970: 123) + legacyContainer?.mainContext.insert(legacyHost) + try legacyContainer?.mainContext.save() + legacyContainer = nil + + let migrated = try ModelContainer( + for: HostPersistence.schema, + migrationPlan: TermindMigrationPlan.self, + configurations: [ + localModelConfiguration( + "ConnectionHistoryMigration", + schema: HostPersistence.schema, url: storeURL + ) + ] + ) + + let migratedHosts = try migrated.mainContext.fetch(FetchDescriptor()) + #expect(migratedHosts.count == 1) + #expect(migratedHosts.first?.lastUsedAt == nil) + #expect(try migrated.mainContext.fetch( + FetchDescriptor() + ).isEmpty) + } + + @Test + func repeatedHostsRemainDistinctAndSortedByAttemptTime() { + let hostID = UUID() + let model = ConnectionHistoryModel() + + model.record(kind: .ssh, for: hostID, at: Date(timeIntervalSince1970: 1)) + model.record(kind: .sftp, for: hostID, at: Date(timeIntervalSince1970: 3)) + model.record(kind: .ssh, for: hostID, at: Date(timeIntervalSince1970: 2)) + + let entries = model.entries() + #expect(entries.count == 3) + #expect(entries.map(\.kind) == [.sftp, .ssh, .ssh]) + #expect(entries.map(\.connectedAt) == [ + Date(timeIntervalSince1970: 3), + Date(timeIntervalSince1970: 2), + Date(timeIntervalSince1970: 1), + ]) + #expect(Set(entries.map(\.id)).count == 3) + } + + @Test + func globalLimitKeepsTheNewestAttempts() { + let model = ConnectionHistoryModel() + for index in 0...ConnectionHistoryModel.limit { + model.record( + kind: .ssh, for: UUID(), + at: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + + let entries = model.entries() + #expect(entries.count == ConnectionHistoryModel.limit) + #expect(entries.first?.connectedAt == Date( + timeIntervalSince1970: TimeInterval(ConnectionHistoryModel.limit) + )) + #expect(entries.last?.connectedAt == Date(timeIntervalSince1970: 1)) + } + + @Test + func swiftDataRepositoryPersistsDeletesAndClears() throws { + let configuration = localModelConfiguration( + "ConnectionHistoryTests", schema: HostPersistence.schema, + isStoredInMemoryOnly: true + ) + let container = try ModelContainer( + for: HostPersistence.schema, configurations: [configuration] + ) + let hostID = UUID() + let otherHostID = UUID() + let model = ConnectionHistoryModel(modelContext: container.mainContext) + + let first = try #require(model.record(kind: .ssh, for: hostID)) + model.record(kind: .sftp, for: hostID) + model.record(kind: .ssh, for: otherHostID) + #expect(model.storageError == nil) + #expect(try container.mainContext.fetch( + FetchDescriptor() + ).count == 3) + + model.delete(first.id) + #expect(model.entries(for: hostID).count == 1) + model.clear(for: hostID) + #expect(model.entries(for: hostID).isEmpty) + #expect(model.entries(for: otherHostID).count == 1) + model.clearAll() + #expect(model.entries().isEmpty) + } + + @Test + func openingSavedHostsRecordsKindsAndDrivesRecents() { + let first = Host(name: "First", host: "first.example") + let second = Host(name: "Second", host: "second.example") + let history = ConnectionHistoryModel() + let app = AppModel( + hosts: HostLibraryModel(hosts: [first, second]), + connectionHistory: history + ) + let window = WindowModel() + + app.open(first, as: .sshSession, in: window) + app.open(second, as: .sftpSession, in: window) + app.open(first, as: .sftpSession, in: window) + + #expect(history.entries().map(\.hostID) == [first.id, second.id, first.id]) + #expect(history.entries().map(\.kind) == [.sftp, .sftp, .ssh]) + #expect(app.recentHosts().map(\.id) == [first.id, second.id]) + #expect(app.lastConnectedAt(for: first.id) != nil) + } + + @Test + func clearAllHistoryForHostKeepsHostAndOtherHostHistory() { + let host = Host(name: "Host", host: "host.example") + let other = Host(name: "Other", host: "other.example") + let connections = ConnectionHistoryModel(entries: [ + ConnectionHistoryEntry(hostID: host.id, kind: .ssh), + ConnectionHistoryEntry(hostID: other.id, kind: .ssh), + ]) + let commands = CommandHistoryModel(entries: [ + CommandHistoryEntry(hostID: host.id, command: "uptime"), + ]) + let files = SFTPHistoryModel(entries: [ + SFTPHistoryEntry(hostID: host.id, kind: .directory, path: "/srv"), + ]) + let app = AppModel( + hosts: HostLibraryModel(hosts: [host, other]), + commandHistory: commands, sftpHistory: files, + connectionHistory: connections + ) + + app.clearAllHistory(for: [host.id]) + + #expect(app.hostLibrary.host(host.id) != nil) + #expect(connections.entries(for: host.id).isEmpty) + #expect(commands.entries(for: host.id).isEmpty) + #expect(files.entries(for: host.id).isEmpty) + #expect(connections.entries(for: other.id).count == 1) + #expect(app.recentHosts().map(\.id) == [other.id]) + } +} diff --git a/TermindTests/ListViewMigrationTests.swift b/TermindTests/ListViewMigrationTests.swift index 5ec9a2e4..3ece8dda 100644 --- a/TermindTests/ListViewMigrationTests.swift +++ b/TermindTests/ListViewMigrationTests.swift @@ -149,12 +149,17 @@ struct ListViewMigrationTests { @Test func everyHistoryKindSharesOneCardHeight() { let hostID = UUID() + let connection = ConnectionHistoryEntry(hostID: hostID, kind: .ssh) let items: [SidebarListItem] = [ .history(CommandHistoryEntry(hostID: hostID, command: "pwd"), isNaming: false), .sftpHistory( SFTPHistoryEntry(hostID: hostID, kind: .directory, path: "/var/log") ), - .connection(Host(name: "web-01", host: "web-01.example"), address: "deploy@web-01.example"), + .connection( + connection, + host: Host(id: hostID, name: "web-01", host: "web-01.example"), + address: "deploy@web-01.example" + ), ] for item in items { @@ -167,15 +172,27 @@ struct ListViewMigrationTests { @Test func historyIdentityIgnoresPresentationButNotTheRecord() { let host = Host(name: "web-01", host: "web-01.example") - let sameHost = SidebarListItem.connection(host, address: "root@web-01.example") - let renamedAddress = SidebarListItem.connection(host, address: "deploy@web-01.example") + let entry = ConnectionHistoryEntry(hostID: host.id, kind: .ssh) + let sameRecord = SidebarListItem.connection( + entry, host: host, address: "root@web-01.example" + ) + let renamedAddress = SidebarListItem.connection( + entry, host: host, address: "deploy@web-01.example" + ) + let otherRecord = SidebarListItem.connection( + ConnectionHistoryEntry(hostID: host.id, kind: .ssh), + host: host, address: "root@web-01.example" + ) let otherHost = SidebarListItem.connection( - Host(name: "db-01", host: "db-01.example"), address: "deploy@db-01.example" + ConnectionHistoryEntry(hostID: UUID(), kind: .ssh), + host: Host(name: "db-01", host: "db-01.example"), + address: "deploy@db-01.example" ) - #expect(sameHost.id == renamedAddress.id) - #expect(sameHost != renamedAddress) - #expect(sameHost.id != otherHost.id) + #expect(sameRecord.id == renamedAddress.id) + #expect(sameRecord != renamedAddress) + #expect(sameRecord.id != otherRecord.id) + #expect(sameRecord.id != otherHost.id) } @Test diff --git a/TermindTests/RepositoryOutboxTests.swift b/TermindTests/RepositoryOutboxTests.swift index 4c1c4f76..fa7654bd 100644 --- a/TermindTests/RepositoryOutboxTests.swift +++ b/TermindTests/RepositoryOutboxTests.swift @@ -116,6 +116,12 @@ struct RepositoryOutboxTests { CommandHistoryEntry(hostID: doomed.id, command: "uptime") ) ) + context.insert(PersistentSFTPHistoryEntry( + SFTPHistoryEntry(hostID: doomed.id, kind: .directory, path: "/srv") + )) + context.insert(PersistentConnectionHistoryEntry( + ConnectionHistoryEntry(hostID: doomed.id, kind: .ssh) + )) try context.save() let repository = SwiftDataHostRepository(context: context) @@ -150,6 +156,8 @@ struct RepositoryOutboxTests { ) #expect(try context.fetch(FetchDescriptor()).isEmpty) #expect(try context.fetch(FetchDescriptor()).isEmpty) + #expect(try context.fetch(FetchDescriptor()).isEmpty) + #expect(try context.fetch(FetchDescriptor()).isEmpty) #expect(try context.fetch(FetchDescriptor()).isEmpty) } diff --git a/TermindTests/SFTPSessionTests.swift b/TermindTests/SFTPSessionTests.swift index 2f6da359..7815376c 100644 --- a/TermindTests/SFTPSessionTests.swift +++ b/TermindTests/SFTPSessionTests.swift @@ -6,6 +6,20 @@ import Testing @Suite @MainActor struct SFTPSessionTests { + @Test + func workspaceSubtitleTracksSFTPPathEvenWhenRenamed() throws { + let host = Host(name: "Files", host: "files.example") + let store = HostLibraryModel(hosts: [host]) + let workspace = Workspace.sftpSession(host: host, initialPath: "/srv") + let session = try #require(workspace.focusedPane.sftpSession) + + #expect(workspace.subtitle(in: store) == "/srv") + session.navigate(to: "/var/log") + #expect(workspace.subtitle(in: store) == "/var/log") + workspace.focusedPane.customTitle = "Logs" + #expect(workspace.subtitle(in: store) == "/var/log") + } + @Test func findFocusRequestsRepeatWithoutClearingTheQuery() throws { let host = Host(name: "Files", host: "files.example")