Add persistent connection history
This commit is contained in:
1 parent
4c5f564b03
commit
9d02147995
27 files changed
+723
-137
No files matched your search
@@ -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<Host.ID>()
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<PersistentHost>()) {
|
||||
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
|
||||
|
||||
|
||||
@@ -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<PersistentConnectionHistoryEntry>(
|
||||
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<PersistentConnectionHistoryEntry>(
|
||||
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<PersistentConnectionHistoryEntry>(
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PersistentHost>())
|
||||
#expect(migratedHosts.count == 1)
|
||||
#expect(migratedHosts.first?.lastUsedAt == nil)
|
||||
#expect(try migrated.mainContext.fetch(
|
||||
FetchDescriptor<PersistentConnectionHistoryEntry>()
|
||||
).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<PersistentConnectionHistoryEntry>()
|
||||
).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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<PersistentPortForwardingRule>()).isEmpty)
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentCommandHistoryEntry>()).isEmpty)
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentSFTPHistoryEntry>()).isEmpty)
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()).isEmpty)
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentPendingDeletion>()).isEmpty)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user