diff --git a/FrameworkTests/RecentConnectionHostTests.swift b/FrameworkTests/RecentConnectionHostTests.swift new file mode 100644 index 00000000..3a5e8b30 --- /dev/null +++ b/FrameworkTests/RecentConnectionHostTests.swift @@ -0,0 +1,50 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +import struct TermindDomain.Host + +@Suite @MainActor +struct RecentConnectionHostTests { + @Test func projectionKeepsTheNewestLocalEventForEachSavedHost() { + let localID = UUID() + let first = Host(name: "First", host: "first.example") + let second = Host(name: "Second", host: "second.example") + let removedID = UUID() + let firstOlder = ConnectionHistoryEntry( + hostID: first.id, kind: .ssh, + connectedAt: Date(timeIntervalSince1970: 10), sourceDeviceID: localID) + let secondLatest = ConnectionHistoryEntry( + hostID: second.id, kind: .telnet, + connectedAt: Date(timeIntervalSince1970: 30), sourceDeviceID: localID) + let firstLatest = ConnectionHistoryEntry( + hostID: first.id, kind: .sftp, + connectedAt: Date(timeIntervalSince1970: 20), sourceDeviceID: localID) + let history = ConnectionHistoryModel( + entries: [ + firstOlder, + ConnectionHistoryEntry( + hostID: first.id, kind: .ssh, + connectedAt: Date(timeIntervalSince1970: 50), sourceDeviceID: UUID()), + secondLatest, + ConnectionHistoryEntry( + hostID: removedID, kind: .ssh, + connectedAt: Date(timeIntervalSince1970: 40), sourceDeviceID: localID), + firstLatest, + ConnectionHistoryEntry( + hostID: second.id, kind: .ssh, + connectedAt: Date(timeIntervalSince1970: 60)), + ], + sourceDeviceID: localID) + let library = HostLibraryModel(hosts: [first, second]) + + let all = history.recentLocalHosts(in: library, limit: 5) + #expect(all.map(\.id) == [second.id, first.id]) + #expect(all.map(\.entry.id) == [secondLatest.id, firstLatest.id]) + #expect(all.map(\.host.name) == ["Second", "First"]) + #expect(history.recentLocalHosts(in: library, limit: 1).map(\.id) == [second.id]) + #expect(history.recentLocalHosts(in: library, limit: 0).isEmpty) + #expect(history.recentLocalHosts(in: library, limit: -1).isEmpty) + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift index ac9b7420..45feeb50 100644 --- a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift @@ -4,6 +4,13 @@ import TermindDomain import struct TermindDomain.Host +public struct RecentConnectionHost: Identifiable { + public let entry: ConnectionHistoryEntry + public let host: Host + + public var id: Host.ID { host.id } +} + @MainActor @Observable public final class ConnectionHistoryModel { @@ -55,6 +62,20 @@ public final class ConnectionHistoryModel { entries().filter(isLocal) } + /// The latest local connection for each saved Host, in connection order. + public func recentLocalHosts(in library: HostLibraryModel, limit: Int) -> [RecentConnectionHost] { + guard limit > 0 else { return [] } + + var seen = Set() + var hosts: [RecentConnectionHost] = [] + for entry in localEntries() { + guard let host = library.host(entry.hostID), seen.insert(entry.hostID).inserted else { continue } + hosts.append(RecentConnectionHost(entry: entry, host: host)) + if hosts.count == limit { break } + } + return hosts + } + public func mostRecentDate(for hostID: Host.ID) -> Date? { entries().first { $0.hostID == hostID }?.connectedAt } diff --git a/TFI/Application/Workspaces/MobileConnectionHistory.swift b/TFI/Application/Workspaces/MobileConnectionHistory.swift index af27f506..b71577df 100644 --- a/TFI/Application/Workspaces/MobileConnectionHistory.swift +++ b/TFI/Application/Workspaces/MobileConnectionHistory.swift @@ -49,28 +49,20 @@ enum MobileConnectionHistoryActionPolicy { @MainActor enum MobileConnectionHistory { static func recentHostRows( - entries: [ConnectionHistoryEntry], + history: ConnectionHistoryModel, library: HostLibraryModel, limit: Int ) -> [MobileRecentHostRow] { - guard limit > 0 else { return [] } - - var seen = Set() - var rows: [MobileRecentHostRow] = [] - for entry in entries { - guard let host = library.host(entry.hostID) else { continue } - guard seen.insert(host.id).inserted else { continue } + history.recentLocalHosts(in: library, limit: limit).map { recent in + let host = recent.host let preferredKind = preferredKind(for: host) - rows.append( - MobileRecentHostRow( - entry: entry, - host: host, - loginAddress: library.preferredTerminalLoginAddress(for: host), - preferredKind: preferredKind - )) - if rows.count == limit { break } + return MobileRecentHostRow( + entry: recent.entry, + host: host, + loginAddress: library.preferredTerminalLoginAddress(for: host), + preferredKind: preferredKind + ) } - return rows } static func eventRows( diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index 1eba6aa7..a694ec1e 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -187,7 +187,7 @@ struct MobileWorkspaceListView: View { var historyRows: [MobileRecentHostRow] { _ = app.connectionHistory.revision return MobileConnectionHistory.recentHostRows( - entries: app.connectionHistory.localEntries(), + history: app.connectionHistory, library: app.library, limit: Self.historyLimit ) diff --git a/TFI/Platform/MobileHomeScreenQuickActions.swift b/TFI/Platform/MobileHomeScreenQuickActions.swift index af29c347..38bcd8ce 100644 --- a/TFI/Platform/MobileHomeScreenQuickActions.swift +++ b/TFI/Platform/MobileHomeScreenQuickActions.swift @@ -77,7 +77,7 @@ enum MobileHomeScreenQuickActions { static func refresh(app: MobileAppModel) { let rows = MobileConnectionHistory.recentHostRows( - entries: app.connectionHistory.localEntries(), + history: app.connectionHistory, library: app.library, limit: ConnectionHistoryModel.limit ) diff --git a/TFITests/MobileHomeScreenQuickActionTests.swift b/TFITests/MobileHomeScreenQuickActionTests.swift index 66015fca..051734ca 100644 --- a/TFITests/MobileHomeScreenQuickActionTests.swift +++ b/TFITests/MobileHomeScreenQuickActionTests.swift @@ -37,7 +37,7 @@ struct MobileHomeScreenQuickActionTests { kind: .ssh, for: UUID(), at: Date(timeIntervalSince1970: 70)) let rows = MobileConnectionHistory.recentHostRows( - entries: app.connectionHistory.localEntries(), + history: app.connectionHistory, library: app.library, limit: ConnectionHistoryModel.limit ) diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index c96f87b6..283cdc40 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -980,17 +980,6 @@ final class AppModel { connectionHistory.clearAll() } - /// Saved Hosts ordered by their latest connection event, with each Host - /// appearing only once. - func recentHosts(limit: Int = 5, localOnly: Bool = false) -> [Host] { - var seen = Set() - let entries = localOnly ? connectionHistory.localEntries() : connectionHistory.entries() - return 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) } diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index 2ab268ed..b45e18e7 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -989,11 +989,12 @@ struct WelcomeView: View { } } } - let hosts = app.recentHosts(limit: max(0, 6 - templates.count), localOnly: true) + let hosts = app.connectionHistory.recentLocalHosts( + in: app.hostLibrary, limit: max(0, 6 - templates.count)) if !hosts.isEmpty { section(AppStrings.Workspace.recents) { - ForEach(hosts) { host in - WelcomeHostRow(host: host) + ForEach(hosts) { recent in + WelcomeHostRow(host: recent.host) } } } diff --git a/TFMTests/ConnectionHistoryTests.swift b/TFMTests/ConnectionHistoryTests.swift index 7b804e42..683028bb 100644 --- a/TFMTests/ConnectionHistoryTests.swift +++ b/TFMTests/ConnectionHistoryTests.swift @@ -246,7 +246,7 @@ struct ConnectionHistoryTests { #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(history.recentLocalHosts(in: app.hostLibrary, limit: 5).map(\.id) == [first.id, second.id]) #expect(app.lastConnectedAt(for: first.id) != nil) } @@ -270,8 +270,8 @@ struct ConnectionHistoryTests { let app = AppModel( hosts: HostLibraryModel(hosts: [local, remote]), connectionHistory: history) - #expect(app.recentHosts(localOnly: true).map(\.id) == [local.id]) - #expect(app.recentHosts(localOnly: false).map(\.id) == [remote.id, local.id]) + #expect(history.recentLocalHosts(in: app.hostLibrary, limit: 5).map(\.id) == [local.id]) + #expect(history.entries().map(\.hostID) == [remote.id, local.id]) #expect(app.lastConnectedLocallyAt(for: local.id) == Date(timeIntervalSince1970: 10)) #expect(app.lastConnectedLocallyAt(for: remote.id) == nil) } @@ -280,10 +280,13 @@ struct ConnectionHistoryTests { 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 localID = UUID() + let connections = ConnectionHistoryModel( + entries: [ + ConnectionHistoryEntry(hostID: host.id, kind: .ssh, sourceDeviceID: localID), + ConnectionHistoryEntry(hostID: other.id, kind: .ssh, sourceDeviceID: localID), + ], + sourceDeviceID: localID) let commands = CommandHistoryModel(entries: [ CommandHistoryEntry(hostID: host.id, command: "uptime") ]) @@ -303,6 +306,6 @@ struct ConnectionHistoryTests { #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]) + #expect(connections.recentLocalHosts(in: app.hostLibrary, limit: 5).map(\.id) == [other.id]) } }