Add persistent connection history

This commit is contained in:
Kaede Akino committed 2026-08-29 09:03:26 +08:00
1 parent 4c5f564b03
commit 9d02147995
27 files changed
+723 -137

No files matched your search

+42 -1
View File
@@ -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
+3
View File
@@ -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()
}
-1
View File
@@ -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
+27 -7
View File
@@ -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
+2 -2
View File
@@ -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() {
+1 -1
View File
@@ -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))
}
+4 -1
View File
@@ -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()
}
+52 -34
View File
@@ -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
+172
View File
@@ -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])
}
}
+24 -7
View File
@@ -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
+8
View File
@@ -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)
}
+14
View File
@@ -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")